diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87cbb3b..769424a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,13 @@ name: Golang CI -on: [ push, pull_request ] +on: [push, pull_request] jobs: build: strategy: matrix: - go-version: [ 1.16.x ] - os: [ ubuntu-latest, macos-latest, windows-latest ] + go-version: [1.18.x] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: GOPATH: ${{ github.workspace }} @@ -32,3 +32,5 @@ jobs: path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} - name: Build run: go build -o dist/digitalstrom-mqtt-${{matrix.os}} ./main.go + - name: Test + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed2a77f..1d7b0b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,25 +3,22 @@ name: Release on: push: tags: - - '*' + - "*" jobs: release: runs-on: ubuntu-latest steps: - - - name: Login to Docker Registry + - name: Login to Docker Registry run: | echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ docker login -u gaetancollaud --password-stdin \ && docker info - - - name: Checkout + - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - - - name: Set up Go + - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.19 diff --git a/.gitignore b/.gitignore index 0b4949e..899b2bc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,9 @@ vendor/ dist/ tests/ +digitalstrom-mqtt + +docs/docker/homeassistant .env -config.yaml +/config.yaml +*.private.env.json diff --git a/README.md b/README.md index e16ad0d..7a7803a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ You can set the output values using the command topic and get the current value ![](./docs/images/mqtt-explorer.png) +## Migrating from version 1.x to version 2.x + +See [Migration from V1.x to V2.x](./migration_v1_v2.md) + ## Motivation [DigitalSTROM](https://www.digitalstrom.com/en/) system is built upon scenes. You press a button, and a scene starts. @@ -13,12 +17,13 @@ The scene can trigger as many output devices as you want. While this is fine for difficult to integrate with a more complex automation system. Basically, if you want the master of your automation to be an external system, you will have a bad time. -DigitalSTROM provides a REST api, but it’s not that easy to use since there are a lot of different concepts (scenes, -groups, areas, …). There is also an event endpoint, so you can react to some events. Unfortunately it’s pretty limited ( -for example you don’t have an event when a device output is changed). +DigitalSTROM provides a [REST api](https://developer.digitalstrom.org/api/), but it’s not that easy to use since the +structures of the element can be quite complex. There is also a notification endpoint that uses websocket to alert you +of any value change. This app uses latest version of the REST API and the notification endpoint to provide a simple MQTT +interface. -Currently, digitalSTROM integrations with home automation systems are rare and sometimes limited. The intent of this app is -to solve this issue as all of them support MQTT. +Currently, digitalSTROM integrations with home automation systems are rare and sometimes limited. The intent of this app +is to solve this issue as all of them support MQTT. ## Concept @@ -30,37 +35,57 @@ it. ### Technical -This app use the `json/device/` api to set the values, `json/property` to get the cached output value (digitalSTROM -takes 1-2s per call if you ask the actual value from the device) and `json/event/` to be notified when a scene change. - -Since we don’t have an event when an output value changes we have to work around this limitation. Any push of a button ( -for example) will trigger a scene. DigitalSTROM provides an event when a scene is called. We can then get the state of -the devices in this scene. This overfetch a bit too much data (since we ask for all devices in a zone) but narrows the -update state request, so we don’t have to ask for all the devices in the system. +This app use the [Smarthome API from digitalSTROM](https://developer.digitalstrom.org/api/). It doesn't use the old +`json/device/` api. The documentation is sometimes lacking, so I had to browser various forum and discussion groups to +find all the relevant information. ## Configuration You have two ways of configuring the app. Either using a `config.yaml` file next to the executable or with environment variables. -| required | property | description | default | example | -| --- | --- | --- | --- | --- | -| * | DIGITALSTROM_HOST | Ip address of the digitalstrom system | | 192.168.1.10 | -| | DIGITALSTROM_PORT | Secure port of the rest API | 8080 | | -| * | DIGITALSTROM_USERNAME | Username for digitalstrom | | dssadmin | -| * | DIGITALSTROM_PASSWORD | Password for digitalstrom | | 9TyVg74e5S | -| * | MQTT_URL | MQTT url | | tcp://192.168.1.20:1883 | -| | MQTT_USERNAME | MQTT username | | myUser | -| | MQTT_PASSWORD | MQTT password | | 9TyVg74e5S | -| | MQTT_TOPIC_PREFIX | Topic prefix | digitalstrom | | -| | MQTT_NORMALIZE_DEVICE_NAME | Remove special chars from device name | true | | -| | MQTT_RETAIN | Retain MQTT messages | false | | -| | REFRESH_AT_START | should the states be refreshed at start | true | | -| | LOG_LEVEL | log level | INFO | TRACE,DEBUG,INFO,WARN,ERROR | -| | INVERT_BLINDS_POSITION | 100% is fully close | false | | -| | HOME_ASSISTANT_DISCOVERY_ENABLED | Whether or not publish MQTT Discovery messages for Home Assistant | false | | -| | HOME_ASSISTANT_DISCOVERY_PREFIX | Topic prefix where to publish the MQTT Discovery messaged for Home Assistant | `homeassistant` | | -| | HOME_ASSISTANT_REMOVE_REGEXP_FROM_NAME | Regular expression to remove from device names when announcing to Home Assistant | | `"(light\|cover)"` +| required | property | description | default | example | +|----------|----------------------------------------|----------------------------------------------------------------------------------|-----------------|-----------------------------| +| * | DIGITALSTROM_HOST | Ip address of the digitalstrom system | | 192.168.1.10 | +| | DIGITALSTROM_PORT | Secure port of the rest API | 8080 | | +| * | DIGITALSTROM_API_KEY | DigitalSTROM API key | | 782f...6075d | +| * | MQTT_URL | MQTT url | | tcp://192.168.1.20:1883 | +| | MQTT_USERNAME | MQTT username | | myUser | +| | MQTT_PASSWORD | MQTT password | | 9TyVg74e5S | +| | MQTT_TOPIC_PREFIX | Topic prefix | digitalstrom | | +| | MQTT_NORMALIZE_DEVICE_NAME | Remove special chars from device name | true | | +| | MQTT_RETAIN | Retain MQTT messages | true | | +| | REFRESH_AT_START | should the states be refreshed at start | true | | +| | LOG_LEVEL | log level | INFO | TRACE,DEBUG,INFO,WARN,ERROR | +| | INVERT_BLINDS_POSITION | 100% is fully close | false | | +| | HOME_ASSISTANT_DISCOVERY_ENABLED | Whether or not publish MQTT Discovery messages for Home Assistant | true | | +| | HOME_ASSISTANT_DISCOVERY_PREFIX | Topic prefix where to publish the MQTT Discovery messaged for Home Assistant | `homeassistant` | | +| | HOME_ASSISTANT_REMOVE_REGEXP_FROM_NAME | Regular expression to remove from device names when announcing to Home Assistant | | `"(light\|cover)"` + +## Obtaining the API key + +There is a build-in tool to get the API key. You can run it with the following command: + +```shell +./digitalstrom-mqtt -mode=get-api-key -host 192.168.1.x -username=dssadmin -password=XXX +``` + +Just copy the API key spilled out in the console in your config file. Be sure to save the key, you want be able to see +it again and you will have to create another key. +The key will then be visible in the digitalSTROM web api under System -> Access Authorization. You can also remove it +from there if you want to. + +To see all available option, you can do: + +```shell +./digitalstrom-mqtt -h +``` + +From docker you can run this: + +```shell +docker run --rm gaetancollaud/digitalstrom-mqtt -mode=get-api-key -host 192.168.1.x -username=dssadmin -password=XXX +``` ## Minimal config file @@ -68,8 +93,7 @@ config.yaml ```yaml DIGITALSTROM_HOST: 192.168.1.x -DIGITALSTROM_USERNAME: dssadmin -DIGITALSTROM_PASSWORD: XXX +DIGITALSTROM_API_KEY: XXX MQTT_URL: tcp://192.168.1.X:1883 ``` @@ -79,27 +103,13 @@ The topic format is as follows for the devices: `{prefix}/devices/{deviceName}/{channel}/{commandState}` -The topic format is as follows for the circuits: - -`{prefix}/circuits/{deviceName}/{channel}/state` +The topic format is as follows for the meterings: -The topic format is as follows for the scenes: - -`{prefix}/scenes/{zoneName}/{sceneName}/event` +`{prefix}/meterings/{deviceName}/{channel}/state` The server status topic is -`{prefix}/server/state` - -| variable | description | example | -| --- | --- | --- | -| {prefix} | | Defined by `MQTT_TOPIC_PREFIX` | -| {deviceType} | | `circuit` or `device` | -| {deviceName} | Device or Circuit name | `light_bathroom` | -| {channel} | DS channel | | -| {commandState} | | `command` or `state` | -| {zoneName} | Zone name | `bathroom` | -| {sceneName} | Scene name | `double_press` | +`{prefix}/server/status` ## How to run @@ -119,8 +129,7 @@ Start the executable ```shell docker run \ -e DIGITALSTROM_HOST=192.168.1.x \ - -e DIGITALSTROM_USERNAME=dssadmin \ - -e DIGITALSTROM_PASSWORD=XXX \ + -e DIGITALSTROM_API_KEY=XXX \ -e MQTT_URL=tcp://192.168.1.X:1883 \ gaetancollaud/digitalstrom-mqtt ``` @@ -152,11 +161,11 @@ digitalstrom/devices/DEVICE_NAME/shadeOpeningAngleOutside/state digitalstrom/devices/DEVICE_NAME/shadeOpeningAngleOutside/command ``` -### dSS20 (circuits) +### dSS20 (controllers) ``` -digitalstrom/circuits/chambres/consumptionW/state -digitalstrom/circuits/chambres/EnergyWs/state +digitalstrom/meterings/chambres/consumptionW/state +digitalstrom/meterings/chambres/energyWs/state ``` ## Tested devices @@ -178,7 +187,8 @@ digitalSTROM-MQTT was tested successfully with these devices: Some devices are known to have issues or limitations: -* BL-KM300 (see [#7](https://github.com/gaetancollaud/digitalstrom-mqtt/issues/7) [#19](https://github.com/gaetancollaud/digitalstrom-mqtt/issues/19)) +* BL-KM300 ( + see [#7](https://github.com/gaetancollaud/digitalstrom-mqtt/issues/7) [#19](https://github.com/gaetancollaud/digitalstrom-mqtt/issues/19)) * GE-UMv200 (see [#22](https://github.com/gaetancollaud/digitalstrom-mqtt/issues/22)) Feel free to create an issue or to directly edit this file if you have tested this software with your devices. diff --git a/config.yaml.example b/config.yaml.example index bca4fdf..148684c 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,5 +1,4 @@ -DIGITALSTROM_HOST: 192.168.1.X -DIGITALSTROM_USERNAME: dssadmin -DIGITALSTROM_PASSWORD: XXX -MQTT_URL: tcp://192.168.1.X:1883 -REFRESH_AT_START: false +digitalstrom_host: 192.168.1.X +digitalstrom_api_key: XXX +mqtt_url: tcp://192.168.1.X:1883 +refresh_at_start: false diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 19629ec..0000000 --- a/config/config.go +++ /dev/null @@ -1,145 +0,0 @@ -package config - -import ( - "bytes" - "fmt" - "io/ioutil" - "os" - - "github.com/rs/zerolog/log" - "github.com/spf13/viper" -) - -type ConfigDigitalstrom struct { - Host string - Port int - Username string - Password string -} -type ConfigMqtt struct { - MqttUrl string - Username string - Password string - TopicFormat string - TopicPrefix string - NormalizeDeviceName bool - Retain bool -} -type ConfigHomeAssistant struct { - DiscoveryEnabled bool - DiscoveryTopicPrefix string - RemoveRegexpFromName string - DigitalStromHost string - Retain bool -} -type Config struct { - Digitalstrom ConfigDigitalstrom - Mqtt ConfigMqtt - HomeAssistant ConfigHomeAssistant - RefreshAtStart bool - LogLevel string - InvertBlindsPosition bool -} - -const ( - Undefined string = "" - configFile string = "config.yaml" - envKeyDigitalstromHost string = "DIGITALSTROM_HOST" - envKeyDigitalstromPort string = "DIGITALSTROM_PORT" - envKeyDigitalstromUsername string = "DIGITALSTROM_USERNAME" - envKeyDigitalstromPassword string = "DIGITALSTROM_PASSWORD" - envKeyMqttUrl string = "MQTT_URL" - envKeyMqttUsername string = "MQTT_USERNAME" - envKeyMqttPassword string = "MQTT_PASSWORD" - envKeyMqttTopicFormat string = "MQTT_TOPIC_FORMAT" - envKeyMqttTopicPrefix string = "MQTT_TOPIC_PREFIX" - envKeyMqttNormalizeTopicName string = "MQTT_NORMALIZE_DEVICE_NAME" - envKeyMqttRetain string = "MQTT_RETAIN" - envKeyInvertBlindsPosition string = "INVERT_BLINDS_POSITION" - envKeyRefreshAtStart string = "REFRESH_AT_START" - envKeyLogLevel string = "LOG_LEVEL" - envKeyHomeAssistantDiscoveryEnabled string = "HOME_ASSISTANT_DISCOVERY_ENABLED" - envKeyHomeAssistantDiscoveryPrefix string = "HOME_ASSISTANT_DISCOVERY_PREFIX" - envKeyHomeAssistantRemoveRegexpFromName string = "HOME_ASSISTANT_REMOVE_REGEXP_FROM_NAME" -) - -func check(e error) { - if e != nil { - log.Panic(). - Err(e).Msg("Error when reading config") - } -} - -func readConfig(defaults map[string]interface{}) (*viper.Viper, error) { - v := viper.New() - for key, value := range defaults { - v.SetDefault(key, value) - } - f, err := os.OpenFile(configFile, os.O_RDONLY|os.O_CREATE, 0600) - check(err) - f.Close() - d, err := ioutil.ReadFile(configFile) - check(err) - v.SetConfigType("yaml") - v.AutomaticEnv() - err = v.ReadConfig(bytes.NewBuffer(d)) - return v, err -} - -// FromEnv returns a Config from env variables -func FromEnv() *Config { - v, err := readConfig(map[string]interface{}{ - envKeyDigitalstromHost: Undefined, - envKeyDigitalstromPort: 8080, - envKeyDigitalstromUsername: Undefined, - envKeyDigitalstromPassword: Undefined, - envKeyMqttUrl: Undefined, - envKeyMqttUsername: Undefined, - envKeyMqttPassword: Undefined, - envKeyMqttTopicPrefix: "digitalstrom", - envKeyMqttTopicFormat: "deprecated", - envKeyMqttNormalizeTopicName: true, - envKeyMqttRetain: false, - envKeyRefreshAtStart: true, - envKeyLogLevel: "INFO", - envKeyInvertBlindsPosition: false, - envKeyHomeAssistantDiscoveryEnabled: false, - envKeyHomeAssistantDiscoveryPrefix: "homeassistant", - envKeyHomeAssistantRemoveRegexpFromName: "", - }) - check(err) - - c := &Config{ - Digitalstrom: ConfigDigitalstrom{ - Host: v.GetString(envKeyDigitalstromHost), - Port: v.GetInt(envKeyDigitalstromPort), - Username: v.GetString(envKeyDigitalstromUsername), - Password: v.GetString(envKeyDigitalstromPassword), - }, - Mqtt: ConfigMqtt{ - MqttUrl: v.GetString(envKeyMqttUrl), - Username: v.GetString(envKeyMqttUsername), - Password: v.GetString(envKeyMqttPassword), - TopicFormat: v.GetString(envKeyMqttTopicFormat), - TopicPrefix: v.GetString(envKeyMqttTopicPrefix), - NormalizeDeviceName: v.GetBool(envKeyMqttNormalizeTopicName), - Retain: v.GetBool(envKeyMqttRetain), - }, - HomeAssistant: ConfigHomeAssistant{ - DiscoveryEnabled: v.GetBool(envKeyHomeAssistantDiscoveryEnabled), - DiscoveryTopicPrefix: v.GetString(envKeyHomeAssistantDiscoveryPrefix), - RemoveRegexpFromName: v.GetString(envKeyHomeAssistantRemoveRegexpFromName), - DigitalStromHost: v.GetString(envKeyDigitalstromHost), - Retain: v.GetBool(envKeyMqttRetain), - }, - RefreshAtStart: v.GetBool(envKeyRefreshAtStart), - LogLevel: v.GetString(envKeyLogLevel), - InvertBlindsPosition: v.GetBool(envKeyInvertBlindsPosition), - } - - return c -} - -func (c *Config) String() string { - return fmt.Sprintf("%+v\n", c.Digitalstrom) -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index bdf1b40..0000000 --- a/config/config_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package config - -import ( - "os" - "testing" -) - -func TestFromEnv(t *testing.T) { - os.Setenv(envKeyDigitalstromHost, "test_ip") - - c := FromEnv() - if c.Digitalstrom.Host != "test_ip" { - t.Errorf("wrong Endpoint") - } -} diff --git a/digitalstrom/circuit_test.go b/digitalstrom/circuit_test.go deleted file mode 100644 index 2881130..0000000 --- a/digitalstrom/circuit_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package digitalstrom - -import ( - "testing" -) - -var circuitNormal, _ = getJson(` { - "name": "chambres", - "dsid": "302ed89f43f00e40000123ac", - "dSUID": "302ed89f43f0000000000e40000123ac00", - "DisplayID": "000123ac", - "hwVersion": 0, - "hwVersionString": "12.1.4.0", - "swVersion": "1.41.0.0 / DSP: 1.9.1.0", - "armSwVersion": 19464192, - "dspSwVersion": 17367296, - "isUpToDate": true, - "apiVersion": 772, - "authorized": false, - "hwName": "dSM12", - "isPresent": true, - "isValid": true, - "busMemberType": 17, - "hasDevices": true, - "hasMetering": true, - "hasBlinking": true, - "VdcConfigURL": "", - "VdcModelUID": "", - "VdcHardwareGuid": "", - "VdcHardwareModelGuid": "", - "VdcImplementationId": "", - "VdcVendorGuid": "", - "VdcOemGuid": "", - "ignoreActionsFromNewDevices": false - }`) - -var noDsid, _ = getJson(` { - "name": "chambres", - "dsid": "", - "dSUID": "302ed89f43f0000000000e40000123ac00", - "DisplayID": "000123ac", - }`) - -var circuitManager = CircuitsManager{} - -func TestSupportedCircuit(t *testing.T) { - expectBool(t, circuitManager.supportedCircuit(circuitNormal), true, "heater should be supported") - expectBool(t, circuitManager.supportedCircuit(noDsid), false, "noDsid should not be supported") -} diff --git a/digitalstrom/circuits.go b/digitalstrom/circuits.go deleted file mode 100644 index 9b8da78..0000000 --- a/digitalstrom/circuits.go +++ /dev/null @@ -1,126 +0,0 @@ -package digitalstrom - -import ( - "strings" - - "github.com/gaetancollaud/digitalstrom-mqtt/utils" - "github.com/rs/zerolog/log" -) - -type CircuitValueChanged struct { - Circuit Circuit - ConsumptionW int64 - EnergyWs int64 -} - -type Circuit struct { - Name string - Dsid string - HwName string - HasMetering bool - IsValid bool - consumptionW int64 - energyWs int64 -} - -type CircuitsManager struct { - httpClient *HttpClient - circuits []Circuit - circuitValuesChan chan CircuitValueChanged -} - -func NewCircuitManager(httpClient *HttpClient) *CircuitsManager { - dm := new(CircuitsManager) - dm.httpClient = httpClient - dm.circuitValuesChan = make(chan CircuitValueChanged) - - return dm -} - -func (dm *CircuitsManager) Start() { - dm.reloadAllCircuits() -} - -func (dm *CircuitsManager) reloadAllCircuits() { - log.Info().Msg("Reloading circuits") - response, err := dm.httpClient.get("json/apartment/getCircuits") - if err != nil { - log.Error(). - Err(err). - Msg("Unable to load circuit list") - } else { - circuits := response.mapValue["circuits"].([]interface{}) - for _, s := range circuits { - m := s.(map[string]interface{}) - if dm.supportedCircuit(m) { - dm.circuits = append(dm.circuits, Circuit{ - Dsid: m["dsid"].(string), - Name: m["name"].(string), - HwName: m["hwName"].(string), - HasMetering: m["hasMetering"].(bool), - IsValid: m["isValid"].(bool), - consumptionW: -1, - energyWs: -1, - }) - } - } - - log.Debug(). - Str("circuits", utils.PrettyPrintArray(dm.circuits)). - Msg("Circuits loaded") - } -} - -func (dm *CircuitsManager) supportedCircuit(m map[string]interface{}) bool { - name := "" - if m["name"] != nil { - name = m["name"].(string) - } - if len(strings.TrimSpace(name)) == 0 { - log.Warn().Msg("Circuit not supported because it has no name. Enable debug to see the complete devices") - log.Debug().Str("device", utils.PrettyPrintMap(m)).Msg("Circuit not supported because it has no name") - return false - } - if m["dsid"] == nil || len(m["dsid"].(string)) == 0 { - log.Info().Str("name", name).Msg("Circuit not supported because it has no dsid. Enable debug to see the complete devices") - log.Debug().Str("device", utils.PrettyPrintMap(m)).Msg("Circuit not supported because it has no dsid") - return false - } - if !m["isValid"].(bool) { - log.Info().Str("name", name).Msg("Circuit is not valid. Enable debug to see the complete devices") - log.Debug().Str("device", utils.PrettyPrintMap(m)).Msg("Circuit is not valid") - return false - } - return true -} - -func (dm *CircuitsManager) UpdateCircuitsValue() { - for _, circuit := range dm.circuits { - if circuit.HasMetering { - consumptionW := int64(-1) - energyWs := int64(-1) - - response, err := dm.httpClient.get("json/circuit/getConsumption?id=" + circuit.Dsid) - if utils.CheckNoErrorAndPrint(err) { - consumptionW = int64(response.mapValue["consumption"].(float64)) - } - - response, err = dm.httpClient.get("json/circuit/getEnergyMeterValue?id=" + circuit.Dsid) - if utils.CheckNoErrorAndPrint(err) { - energyWs = int64(response.mapValue["meterValue"].(float64)) - } - - dm.updateValue(circuit, consumptionW, energyWs) - } - } -} - -func (dm *CircuitsManager) updateValue(circuit Circuit, newConsumptionW int64, newEnergyWs int64) { - dm.circuitValuesChan <- CircuitValueChanged{ - Circuit: circuit, - ConsumptionW: newConsumptionW, - EnergyWs: newEnergyWs, - } - circuit.consumptionW = newConsumptionW - circuit.energyWs = newEnergyWs -} diff --git a/digitalstrom/devices.go b/digitalstrom/devices.go deleted file mode 100644 index 8f4105b..0000000 --- a/digitalstrom/devices.go +++ /dev/null @@ -1,303 +0,0 @@ -package digitalstrom - -import ( - "errors" - "strconv" - "strings" - "time" - - "github.com/gaetancollaud/digitalstrom-mqtt/utils" - "github.com/rs/zerolog/log" -) - -type DeviceType string - -const ( - Light DeviceType = "GE" - Blind DeviceType = "GR" - Joker DeviceType = "SW" - Unknown DeviceType = "Unknown" -) - -type DeviceAction string - -const ( - Set DeviceAction = "set" - Stop DeviceAction = "stop" -) - -type DeviceStateChanged struct { - Device Device - Channel string - NewValue float64 -} - -type DeviceCommand struct { - DeviceName string - Channel string - Action DeviceAction - NewValue float64 -} - -type DeviceProperties struct { - Dimmable bool -} - -type Device struct { - Name string - Dsid string - Dsuid string - DeviceType DeviceType - HwInfo string - MeterDsid string - MeterDsuid string - MeterName string - ZoneId int - OutputChannels []string - Groups []int - Values map[string]float64 - Properties DeviceProperties -} - -type DevicesManager struct { - httpClient *HttpClient - invertBlindsPosition bool - devices []Device - deviceStateChan chan DeviceStateChanged - lastDeviceCommand time.Time -} - -func NewDevicesManager(httpClient *HttpClient, invertBlindsPosition bool) *DevicesManager { - dm := new(DevicesManager) - dm.httpClient = httpClient - dm.invertBlindsPosition = invertBlindsPosition - dm.deviceStateChan = make(chan DeviceStateChanged) - dm.lastDeviceCommand = time.Now() - - return dm -} - -func (dm *DevicesManager) Start() { - dm.reloadAllDevices() -} - -func (dm *DevicesManager) reloadAllDevices() { - response, err := dm.httpClient.get("json/apartment/getDevices") - if utils.CheckNoErrorAndPrint(err) { - for _, s := range response.arrayValue { - m := s.(map[string]interface{}) - if dm.supportedDevice(m) { - dm.devices = append(dm.devices, Device{ - Dsid: m["id"].(string), - Dsuid: m["dSUID"].(string), - Name: m["name"].(string), - HwInfo: m["hwInfo"].(string), - MeterDsid: m["meterDSID"].(string), - MeterDsuid: m["meterDSUID"].(string), - MeterName: m["meterName"].(string), - ZoneId: int(m["zoneID"].(float64)), - Groups: extractGroups(m), - DeviceType: extractDeviceType(m), - OutputChannels: extractOutputChannels(m), - Values: make(map[string]float64), - Properties: DeviceProperties{ - // outputMode is set to 22 for GE devices where the - // output is configure to be "dimmed". - Dimmable: m["outputMode"].(float64) == 22, - }, - }) - } - } - - log.Debug().Str("devices", utils.PrettyPrintArray(dm.devices)).Msg("Devices loaded") - } -} - -func (dm *DevicesManager) supportedDevice(m map[string]interface{}) bool { - if m["dSUID"] == nil || len(m["dSUID"].(string)) == 0 { - log.Info().Str("name", m["name"].(string)).Msg("Device not supported because it has no dSUID. Enable debug to see the complete devices") - log.Debug().Str("device", utils.PrettyPrintMap(m)).Msg("Device not supported because it has no dSUID") - return false - } - return true -} - -func extractGroups(data map[string]interface{}) []int { - groupsItf := data["groups"].([]interface{}) - var outputs []int - for _, group := range groupsItf { - outputs = append(outputs, int(group.(float64))) - } - return outputs -} - -func extractDeviceType(data map[string]interface{}) DeviceType { - hwInfo := data["hwInfo"].(string) - if strings.HasPrefix(hwInfo, "GE") { - return Light - } - if strings.HasPrefix(hwInfo, "GR") { - return Blind - } - if strings.HasPrefix(hwInfo, "SW") { - return Joker - } - return Unknown -} - -func extractOutputChannels(data map[string]interface{}) []string { - outputChannels := data["outputChannels"].([]interface{}) - - var outputs []string - - for _, outputChannel := range outputChannels { - chanObj := outputChannel.(map[string]interface{}) - if chanObj["channelName"] != nil { - id := chanObj["channelName"].(string) - outputs = append(outputs, id) - } - } - return outputs -} - -func (dm *DevicesManager) getTreeFloat(path string) (float64, error) { - response, err := dm.httpClient.get("json/property/getFloating?path=" + path) - if err == nil { - return response.mapValue["value"].(float64), nil - } - return 0, err - -} - -func (dm *DevicesManager) updateZone(zoneId int) { - for _, device := range dm.devices { - if device.ZoneId == zoneId && len(device.OutputChannels) > 0 { - dm.updateDevice(device) - } - } -} - -func (dm *DevicesManager) updateGroup(groupId int) { - for _, device := range dm.devices { - for _, gId := range device.Groups { - if gId == groupId && len(device.OutputChannels) > 0 { - log.Info().Int("Group", groupId).Str("device", device.Name).Msg("Updating device from group") - dm.updateDevice(device) - } - } - } -} - -func (dm *DevicesManager) updateDevice(device Device) { - // device need to be updated - log.Debug().Str("device", device.Name).Msg("Updating device ") - for _, channel := range device.OutputChannels { - newValue, err := dm.getTreeFloat("/apartment/zones/zone" + strconv.Itoa(device.ZoneId) + "/devices/" + device.Dsuid + "/status/outputs/" + channel + "/targetValue") - if err == nil { - dm.updateValue(device, channel, newValue) - } else { - log.Warn(). - Str("device", device.Name). - Err(err). - Msg("Unable to update device") - } - } -} - -func (dm *DevicesManager) updateValue(device Device, channel string, newValue float64) { - newValue = dm.invertValueIfNeeded(channel, newValue) - - publishValue := false - if oldVal, ok := device.Values[channel]; ok { - //we have an old value - if oldVal != newValue { - device.Values[channel] = newValue - log.Info(). - Str("device", device.Name). - Str("channel", channel). - Float64("oldValue", oldVal). - Float64("newValue", newValue). - Msg("Value changed") - publishValue = true - } - } else { - // new value - device.Values[channel] = newValue - log.Info(). - Str("device", device.Name). - Str("channel", channel). - Float64("newValue", newValue). - Msg("New value") - publishValue = true - } - if publishValue { - dm.deviceStateChan <- DeviceStateChanged{ - Device: device, - Channel: channel, - NewValue: newValue, - } - } -} - -func (dm *DevicesManager) SetValue(command DeviceCommand) error { - now := time.Now() - duration := now.Sub(dm.lastDeviceCommand) - if duration < time.Second { - log.Debug(). - Dur("lastCommand", duration). - Msg("Waiting before setting value. DigitalSTROM cannot handle more than 1 change/seconds") - time.Sleep(time.Second - duration) - } - dm.lastDeviceCommand = now - - deviceFound := false - channelFound := false - for _, device := range dm.devices { - if device.Name == command.DeviceName && len(device.OutputChannels) > 0 { - deviceFound = true - for _, c := range device.OutputChannels { - if c == command.Channel { - channelFound = true - - newValue := dm.invertValueIfNeeded(c, command.NewValue) - - log.Info(). - Str("device", command.DeviceName). - Str("channel", command.Channel). - Str("action", string(command.Action)). - Float64("value", newValue). - Msg("Setting value ") - - var err error - if command.Action == Stop { - _, err = dm.httpClient.get("json/zone/callAction?application=2&id=" + strconv.Itoa(device.ZoneId) + "&action=app.stop") - } else { - strValue := strconv.Itoa(int(newValue)) - _, err = dm.httpClient.get("json/device/setOutputChannelValue?dsid=" + device.Dsid + "&channelvalues=" + c + "=" + strValue + "&applyNow=1") - } - if utils.CheckNoErrorAndPrint(err) { - dm.updateValue(device, command.Channel, newValue) - } - } - } - } - } - if !deviceFound { - return errors.New("No device '" + command.DeviceName + "' found") - } - if !channelFound { - return errors.New("No channel '" + command.Channel + "' found on device '" + command.DeviceName + "'") - } - return nil -} - -func (dm *DevicesManager) invertValueIfNeeded(channel string, value float64) float64 { - if dm.invertBlindsPosition { - if strings.HasPrefix(strings.ToLower(channel), "shadeposition") { - return 100 - value - } - } - - // nothing to invert - return value -} diff --git a/digitalstrom/devices_test.go b/digitalstrom/devices_test.go deleted file mode 100644 index 89fbec5..0000000 --- a/digitalstrom/devices_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package digitalstrom - -import ( - "encoding/json" - "testing" -) - -var deviceHeater, _ = getJson(` { - "name": "hz-infrarot-ez-og", - "dsid": "303505d7f80017c00006f47c", - "dSUID": "303505d7f8000000000017c00006f47c00", - "deviceType": "SW", - "meterDSID": "303505d7f80002c0000030e6", - "meterdSUID": "303505d7f8000000000002c0000030e600", - "metername": "db-hz-kue-ez-wz-dsm#10", - "zoneId": 16792, - "outputChannels": [ - "heatingPower" - ], - "values": {} - }`) - -var nodSUID, _ = getJson(` { - "name": "hz-infrarot-ez-og", - "dsid": "303505d7f80017c00006f47c", - "dSUID": "", - "deviceType": "SW", - "meterDSID": "303505d7f80002c0000030e6", - "meterdSUID": "303505d7f8000000000002c0000030e600", - "metername": "db-hz-kue-ez-wz-dsm#10", - "zoneId": 16792, - "outputChannels": [ - "brightness" - ], - "values": {} - }`) - -var deviceManager = DevicesManager{} - -func TestSupportedDevices(t *testing.T) { - expectBool(t, deviceManager.supportedDevice(deviceHeater), true, "heater should be supported") - expectBool(t, deviceManager.supportedDevice(nodSUID), false, "nodSUID should not be supported") -} - -func expectBool(t *testing.T, result bool, expect bool, msg string) { - if expect != result { - t.Errorf("%s Expected='%t' but got '%t'", msg, expect, result) - } -} - -func expectFloat(t *testing.T, result float64, expect float64, msg string) { - if expect != result { - t.Errorf("%s Expected='%.5f' but got '%.5f'", msg, expect, result) - } -} - -func getJson(str string) (map[string]interface{}, error) { - var f map[string]interface{} - err := json.Unmarshal([]byte(str), &f) - return f, err -} - -func TestInvertValue(t *testing.T) { - expectFloat(t, deviceManager.invertValueIfNeeded("brightness", 40), 40, "light should not be inverted") - expectFloat(t, deviceManager.invertValueIfNeeded("shadePositionOutside", 40), 40, "blinds should not be inverted") - - deviceManager.invertBlindsPosition = true - - expectFloat(t, deviceManager.invertValueIfNeeded("brightness", 40), 40, "light should not be inverted") - expectFloat(t, deviceManager.invertValueIfNeeded("shadePositionOutside", 40), 60, "blinds should be inverted") -} diff --git a/digitalstrom/digitalstrom.go b/digitalstrom/digitalstrom.go deleted file mode 100644 index 724dbd6..0000000 --- a/digitalstrom/digitalstrom.go +++ /dev/null @@ -1,139 +0,0 @@ -package digitalstrom - -import ( - "time" - - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/rs/zerolog/log" -) - -type Digitalstrom struct { - config *config.Config - cron DigitalstromCron - httpClient *HttpClient - eventsManager *EventsManager - devicesManager *DevicesManager - circuitManager *CircuitsManager - sceneManager *SceneManager -} - -type DigitalstromCron struct { - ticker *time.Ticker - tickerDone chan bool -} - -func New(config *config.Config) *Digitalstrom { - ds := new(Digitalstrom) - ds.config = config - ds.httpClient = NewHttpClient(&config.Digitalstrom) - ds.eventsManager = NewDigitalstromEvents(ds.httpClient) - ds.devicesManager = NewDevicesManager(ds.httpClient, config.InvertBlindsPosition) - ds.circuitManager = NewCircuitManager(ds.httpClient) - ds.sceneManager = NewSceneManager(ds.httpClient) - return ds -} - -func (ds *Digitalstrom) Start() { - log.Info().Msg("Staring digitalstrom") - ds.cron.ticker = time.NewTicker(30 * time.Second) - ds.cron.tickerDone = make(chan bool) - go ds.digitalstromCron() - - ds.eventsManager.Start() - ds.circuitManager.Start() - ds.devicesManager.Start() - - go ds.circuitManager.UpdateCircuitsValue() - - go ds.eventReceived(ds.eventsManager.events) - - if ds.config.RefreshAtStart { - go ds.refreshAllDevices() - } -} - -func (ds *Digitalstrom) Stop() { - log.Info().Msg("Stopping digitalstrom") - if ds.cron.ticker != nil { - ds.cron.ticker.Stop() - ds.cron.tickerDone <- true - ds.cron.ticker = nil - } - ds.eventsManager.Stop() -} - -func (ds *Digitalstrom) digitalstromCron() { - for { - select { - case <-ds.cron.tickerDone: - return - case <-ds.cron.ticker.C: - log.Debug().Msg("Updating circuits values") - ds.circuitManager.UpdateCircuitsValue() - } - } -} - -func (ds *Digitalstrom) eventReceived(events chan Event) { - for event := range events { - log.Info(). - Int("SceneId", event.SceneId). - Int("GroupId", event.GroupId). - Int("ZoneId", event.ZoneId). - Bool("isApartment", event.IsApartment). - Bool("isDevice", event.IsDevice). - Bool("isGroup", event.IsGroup). - Msg("Event received, updating devices") - - ds.sceneManager.EventReceived(event) - - ds.devicesManager.updateZone(event.ZoneId) - - if event.IsGroup && event.GroupId >= 10 { - // event is from a group, and it's not a build in groups - ds.devicesManager.updateGroup(event.GroupId) - } - - time.AfterFunc(2*time.Second, func() { - // update again because maybe the three was not up to date yet - ds.devicesManager.updateZone(event.ZoneId) - - if event.IsGroup && event.GroupId >= 10 { - ds.devicesManager.updateGroup(event.GroupId) - } - }) - } -} - -func (ds *Digitalstrom) GetDeviceChangeChannel() chan DeviceStateChanged { - return ds.devicesManager.deviceStateChan -} - -func (ds *Digitalstrom) GetCircuitChangeChannel() chan CircuitValueChanged { - return ds.circuitManager.circuitValuesChan -} - -func (ds *Digitalstrom) GetSceneEventsChannel() chan SceneEvent { - return ds.sceneManager.sceneEvent -} - -func (ds *Digitalstrom) SetDeviceValue(command DeviceCommand) error { - return ds.devicesManager.SetValue(command) -} - -func (ds *Digitalstrom) refreshAllDevices() { - log.Info(). - Int("size", len(ds.devicesManager.devices)). - Msg("Refreshing all devices") - for _, device := range ds.devicesManager.devices { - ds.devicesManager.updateDevice(device) - } -} - -func (ds *Digitalstrom) GetAllDevices() []Device { - return ds.devicesManager.devices -} - -func (ds *Digitalstrom) GetAllCircuits() []Circuit { - return ds.circuitManager.circuits -} diff --git a/digitalstrom/events.go b/digitalstrom/events.go deleted file mode 100644 index 574a7da..0000000 --- a/digitalstrom/events.go +++ /dev/null @@ -1,117 +0,0 @@ -package digitalstrom - -import ( - "strconv" - "time" - - "github.com/gaetancollaud/digitalstrom-mqtt/utils" - "github.com/rs/zerolog/log" -) - -const SUBSCRIPTION_ID = "42" - -// https://developer.digitalstrom.org/Architecture/system-interfaces.pdf#1e - -const EVENT_CALL_SCENE = "callScene" -const EVENT_UNDO_SCENE = "undoScene" -const EVENT_BUTTON_CLICK = "buttonClick" -const EVENT_DEVICE_SENSOR_EVENT = "deviceSensorEvent" -const EVENT_RUNNING = "running" -const EVENT_MODEL_READY = "model_ready" -const EVENT_DSMETER_READY = "dsMeter_ready" - -type Event struct { - ZoneId int - SceneId int - GroupId int - - IsApartment bool - IsDevice bool - IsGroup bool -} - -type EventsManager struct { - httpClient *HttpClient - events chan Event - running bool - lastTokenCounter int -} - -func NewDigitalstromEvents(httpClient *HttpClient) *EventsManager { - em := new(EventsManager) - em.httpClient = httpClient - em.events = make(chan Event) - em.lastTokenCounter = -1 - return em -} - -func (em *EventsManager) Start() { - log.Info().Msg("Starting event manager") - em.running = true - go em.listeningToEvents() -} - -func (em *EventsManager) Stop() { - log.Info().Msg("Stopping events") - em.running = false -} - -func (em *EventsManager) registerSubscription() { - log.Info().Str("SubscriptionId", SUBSCRIPTION_ID).Msg("Registering to events") - em.httpClient.get("json/event/subscribe?name=" + EVENT_CALL_SCENE + "&subscriptionID=" + SUBSCRIPTION_ID) - em.httpClient.get("json/event/subscribe?name=" + EVENT_BUTTON_CLICK + "&subscriptionID=" + SUBSCRIPTION_ID) - em.httpClient.get("json/event/subscribe?name=" + EVENT_MODEL_READY + "&subscriptionID=" + SUBSCRIPTION_ID) -} - -func (em *EventsManager) listeningToEvents() { - for { - if !em.running { - return - } - - if em.lastTokenCounter < em.httpClient.TokenManager.tokenCounter { - // new token ? so new subscription - em.registerSubscription() - em.lastTokenCounter = em.httpClient.TokenManager.tokenCounter - } - - response, err := em.httpClient.get("json/event/get?subscriptionID=" + SUBSCRIPTION_ID) - if utils.CheckNoErrorAndPrint(err) { - if ret, ok := response.mapValue["events"]; ok { - events := ret.([]interface{}) - - log.Trace().Str("event", utils.PrettyPrintArray(events)).Msg("Events received :") - - for _, event := range events { - m := event.(map[string]interface{}) - source := m["source"].(map[string]interface{}) - properties := m["properties"].(map[string]interface{}) - sceneId := -1 - groupId := -1 - if scene, ok := properties["sceneID"]; ok { - sceneId, err = strconv.Atoi(scene.(string)) - utils.CheckNoErrorAndPrint(err) - } - if group, ok := properties["groupID"]; ok { - groupId, err = strconv.Atoi(group.(string)) - utils.CheckNoErrorAndPrint(err) - } - eventObj := Event{ - ZoneId: int(source["zoneID"].(float64)), - GroupId: groupId, - SceneId: sceneId, - IsApartment: source["isApartment"].(bool), - IsDevice: source["isDevice"].(bool), - IsGroup: source["isGroup"].(bool), - } - em.events <- eventObj - } - } else { - log.Warn().Msg("No event present") - time.Sleep(1000 * time.Millisecond) - } - } else { - time.Sleep(1000 * time.Millisecond) - } - } -} diff --git a/digitalstrom/http-client.go b/digitalstrom/http-client.go deleted file mode 100644 index 8cca752..0000000 --- a/digitalstrom/http-client.go +++ /dev/null @@ -1,130 +0,0 @@ -package digitalstrom - -import ( - "crypto/tls" - "encoding/json" - "errors" - "io/ioutil" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/rs/zerolog/log" -) - -const MAX_RETRIES = 3 - -type HttpClient struct { - client *http.Client - config *config.ConfigDigitalstrom - TokenManager *TokenManager -} - -type DigitalStromResponse struct { - isMap bool - mapValue map[string]interface{} - isArray bool - arrayValue []interface{} -} - -func NewHttpClient(config *config.ConfigDigitalstrom) *HttpClient { - httpClient := new(HttpClient) - httpClient.client = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - } - httpClient.config = config - httpClient.TokenManager = NewTokenManager(config, httpClient) - return httpClient -} - -func (httpClient *HttpClient) get(path string) (*DigitalStromResponse, error) { - for i := 1; i <= MAX_RETRIES; i++ { - token := httpClient.TokenManager.GetToken() - if !strings.Contains(path, "?") { - path = path + "?token=" + token - } else { - path = path + "&token=" + token - } - response, err := httpClient.getWithoutToken(path) - if err == nil { - return response, err - } else { - log.Warn().Err(err).Msg("Failed GET request") - } - if strings.Contains(err.Error(), "not logged in") { - // Issue with token, invalidate the old one before retrying. - httpClient.TokenManager.InvalidateToken() - } else { - // Don't retry in case its not an authetication error. - return nil, err - } - // This is a retry, wait a bit before we retry to avoid loops. - time.Sleep(2 * time.Second) - } - return nil, errors.New("unable to refresh token after " + strconv.Itoa(MAX_RETRIES) + " retries") -} - -func (httpClient *HttpClient) getWithoutToken(path string) (*DigitalStromResponse, error) { - url := "https://" + httpClient.config.Host + ":" + strconv.Itoa(httpClient.config.Port) + "/" + path - - request, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := httpClient.client.Do(request) - if err != nil { - return nil, err - } - - if resp.Body != nil { - defer resp.Body.Close() - } - - body, readErr := ioutil.ReadAll(resp.Body) - if readErr != nil { - return nil, err - } - - log.Trace(). - Str("url", url). - Str("status", resp.Status). - Msg("Response received") - - var jsonValue map[string]interface{} - json.Unmarshal(body, &jsonValue) - - if val, ok := jsonValue["ok"]; ok { - if !val.(bool) { - return nil, errors.New("error with DigitalStrom API: " + jsonValue["message"].(string)) - } - } else { - return nil, errors.New("no 'ok' field present, cannot check request") - } - - if val, ok := jsonValue["result"]; ok { - res := new(DigitalStromResponse) - mapValue, ok := val.(map[string]interface{}) - if ok { - res.isMap = true - res.isArray = false - res.mapValue = mapValue - return res, nil - } - arrayValue, ok := val.([]interface{}) - if ok { - res.isMap = false - res.isArray = true - res.arrayValue = arrayValue - return res, nil - } - return nil, errors.New("unknown return type") - } - // no value returned - return nil, nil -} diff --git a/digitalstrom/scene.go b/digitalstrom/scene.go deleted file mode 100644 index 10cf24e..0000000 --- a/digitalstrom/scene.go +++ /dev/null @@ -1,127 +0,0 @@ -package digitalstrom - -import ( - "strconv" - - "github.com/gaetancollaud/digitalstrom-mqtt/utils" - "github.com/rs/zerolog/log" -) - -type SceneEvent struct { - ZoneId int - ZoneName string - GroupId int - GroupName string - SceneId int - SceneName string -} - -type SceneIdentifier struct { - ZoneId int - GroupId int - SceneId int -} - -type SceneManager struct { - httpClient *HttpClient - zonesById map[int]string - sceneById map[SceneIdentifier]string - sceneEvent chan SceneEvent -} - -func NewSceneManager(httpClient *HttpClient) *SceneManager { - sm := new(SceneManager) - sm.httpClient = httpClient - sm.sceneEvent = make(chan SceneEvent) - sm.zonesById = make(map[int]string) - sm.sceneById = make(map[SceneIdentifier]string) - return sm -} - -func (sm *SceneManager) Start() { -} - -func (sm *SceneManager) getZoneName(zoneId int) (string, error) { - name, ok := sm.zonesById[zoneId] - if ok { - return name, nil - } else { - response, err := sm.httpClient.get("/json/zone/getName?id=" + strconv.Itoa(zoneId)) - if utils.CheckNoErrorAndPrint(err) { - name = response.mapValue["name"].(string) - if len(name) == 0 { - name = "unnamed-zone-" + strconv.Itoa(zoneId) - } - sm.zonesById[zoneId] = name - return name, nil - } - return "", err - } -} - -func (sm *SceneManager) getSceneName(zoneId int, groupId int, sceneId int) (string, error) { - if groupId == -1 { - return "", nil - } - id := SceneIdentifier{ - ZoneId: zoneId, - GroupId: groupId, - SceneId: sceneId, - } - name, ok := sm.sceneById[id] - if ok { - return name, nil - } else { - response, err := sm.httpClient.get("/json/zone/sceneGetName?id=" + strconv.Itoa(zoneId) + "&groupID=" + strconv.Itoa(groupId) + "&sceneNumber=" + strconv.Itoa(sceneId)) - if utils.CheckNoErrorAndPrint(err) { - name = response.mapValue["name"].(string) - if len(name) == 0 { - name = "unnamed-scene-" + strconv.Itoa(sceneId) - } - sm.sceneById[id] = name - return name, nil - } - return "", err - } -} - -func (sm *SceneManager) EventReceived(event Event) { - log.Debug().Int("zoneId", event.ZoneId).Int("sceneId", event.SceneId).Msg("New scene event") - zoneName, errZone := sm.getZoneName(event.ZoneId) - sceneName, errScene := sm.getSceneName(event.ZoneId, event.GroupId, event.SceneId) - if errZone == nil && errScene == nil { - sceneEvent := SceneEvent{ - ZoneId: event.ZoneId, - ZoneName: zoneName, - GroupId: event.GroupId, - GroupName: sm.getGroupName(event.GroupId), - SceneId: event.SceneId, - SceneName: sceneName, - } - sm.sceneEvent <- sceneEvent - } -} - -func (sm *SceneManager) getGroupName(id int) string { - switch id { - case 1: - return "light" - case 2: - return "shade" - case 3: - return "climate" - case 4: - return "audio" - case 5: - return "video" - case 6: - return "safety" - case 7: - return "access" - case 8: - return "joker" - default: - return "unknown" - } - -} diff --git a/digitalstrom/token.go b/digitalstrom/token.go deleted file mode 100644 index 6c0f281..0000000 --- a/digitalstrom/token.go +++ /dev/null @@ -1,54 +0,0 @@ -package digitalstrom - -import ( - "time" - - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/rs/zerolog/log" -) - -type TokenManager struct { - config *config.ConfigDigitalstrom - httpClient *HttpClient - token string - lastTokenTime time.Time - tokenCounter int -} - -func NewTokenManager(config *config.ConfigDigitalstrom, httpClient *HttpClient) *TokenManager { - tm := new(TokenManager) - tm.config = config - tm.httpClient = httpClient - tm.tokenCounter = 0 - return tm -} - -func (tm *TokenManager) refreshToken() string { - response, err := tm.httpClient.getWithoutToken("json/system/login?user=" + tm.config.Username + "&password=" + tm.config.Password) - - if err != nil { - log.Error().Err(err).Msg("Unable to refresh token, will wait a bit for next retry") - time.Sleep(2 * time.Second) - } - - if response.isMap { - return response.mapValue["token"].(string) - } - return "" -} - -func (tm *TokenManager) GetToken() string { - // no token, or more than 50sec - if tm.token == "" { - log.Debug().Dur("last token", time.Now().Sub(tm.lastTokenTime)).Msg("Refreshing token") - tm.token = tm.refreshToken() - tm.lastTokenTime = time.Now() - tm.tokenCounter++ - } - return tm.token -} - -func (tm *TokenManager) InvalidateToken() { - tm.token = "" - log.Info().Msg("Invalidating token") -} diff --git a/digitalstrom_mqtt/mqtt.go b/digitalstrom_mqtt/mqtt.go deleted file mode 100644 index c69ea54..0000000 --- a/digitalstrom_mqtt/mqtt.go +++ /dev/null @@ -1,244 +0,0 @@ -package digitalstrom_mqtt - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - mqtt "github.com/eclipse/paho.mqtt.golang" - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/gaetancollaud/digitalstrom-mqtt/digitalstrom" - "github.com/gaetancollaud/digitalstrom-mqtt/utils" - "github.com/google/uuid" - "github.com/rs/zerolog/log" -) - -const ( - Online string = "online" - Offline string = "offline" - DisconnectTimeout uint = 1000 // 1 second -) - -type DigitalstromMqtt struct { - config *config.ConfigMqtt - client mqtt.Client - digitalstrom *digitalstrom.Digitalstrom - home_assistant *HomeAssistantMqtt -} - -var messagePubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) { - log.Debug(). - Str("topic", msg.Topic()). - Bytes("payload", msg.Payload()). - Msg("Message received") -} - -var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) { - log.Error(). - Err(err). - Msg("MQTT connection lost") - time.Sleep(5 * time.Second) -} - -func New(config *config.Config, digitalstrom *digitalstrom.Digitalstrom) *DigitalstromMqtt { - inst := new(DigitalstromMqtt) - inst.config = &config.Mqtt - u, err := uuid.NewRandom() - clientPostfix := "-" - if utils.CheckNoErrorAndPrint(err) { - clientPostfix = "-" + u.String() - } - inst.home_assistant = &HomeAssistantMqtt{ - mqtt: inst, - config: &config.HomeAssistant, - } - - opts := mqtt.NewClientOptions() - opts.AddBroker(fmt.Sprintf(config.Mqtt.MqttUrl)) - opts.SetClientID("digitalstrom-mqtt" + clientPostfix) - if len(config.Mqtt.Username) > 0 { - opts.SetUsername(config.Mqtt.Username) - } - if len(config.Mqtt.Password) > 0 { - opts.SetPassword(config.Mqtt.Password) - } - opts.SetDefaultPublishHandler(messagePubHandler) - opts.OnConnect = func(client mqtt.Client) { - log.Info().Msg("MQTT Connected") - inst.subscribeToAllDevicesCommands() - } - opts.OnConnectionLost = connectLostHandler - client := mqtt.NewClient(opts) - - inst.client = client - inst.digitalstrom = digitalstrom - - return inst -} - -func (dm *DigitalstromMqtt) Start() { - if token := dm.client.Connect(); token.Wait() && token.Error() != nil { - log.Panic(). - Err(token.Error()). - Str("url", dm.config.MqttUrl). - Msg("Unable to connect to the mqtt broken") - } - - go dm.ListenSceneEvent(dm.digitalstrom.GetSceneEventsChannel()) - go dm.ListenForDeviceState(dm.digitalstrom.GetDeviceChangeChannel()) - go dm.ListenForCircuitValues(dm.digitalstrom.GetCircuitChangeChannel()) - - // Notify that digitalstrom-mqtt is connected and online. - dm.publishServerStatus(Online) - dm.home_assistant.Start() - dm.subscribeToAllDevicesCommands() -} - -// Perform cleanup operations when Stopping DigitalstromMqtt. -func (dm *DigitalstromMqtt) Stop() { - // Notify that difitalstrom-mqtt is not longer online. - dm.publishServerStatus(Offline) - // Gracefully close the connection to the MQTT server. - log.Info().Msg("Stopping MQTT client.") - dm.client.Disconnect(DisconnectTimeout) - log.Info().Msg("Disconnected from MQTT server.") -} - -func (dm *DigitalstromMqtt) ListenSceneEvent(changes chan digitalstrom.SceneEvent) { - for event := range changes { - dm.publishSceneEvent(event) - } -} - -func (dm *DigitalstromMqtt) ListenForDeviceState(changes chan digitalstrom.DeviceStateChanged) { - for event := range changes { - dm.publishDevice(event) - } -} - -func (dm *DigitalstromMqtt) ListenForCircuitValues(changes chan digitalstrom.CircuitValueChanged) { - for event := range changes { - dm.publishCircuit(event) - } -} - -// Publish the current binary status into the MQTT topic. -func (dm *DigitalstromMqtt) publishServerStatus(message string) { - topic := dm.getStatusTopic() - log.Info().Str("status", message).Str("topic", topic).Msg("Updating server status topic") - dm.client.Publish(topic, 0, true, message) -} - -func (dm *DigitalstromMqtt) publishSceneEvent(sceneEvent digitalstrom.SceneEvent) { - sceneNameOrId := sceneEvent.SceneName - if len(sceneNameOrId) == 0 { - // no name for the scene we take the id instead - sceneNameOrId = strconv.Itoa(sceneEvent.SceneId) - } - - topic := dm.getTopic("scenes", strconv.Itoa(sceneEvent.ZoneId), sceneEvent.ZoneName, sceneNameOrId, "event") - - json, err := json.Marshal(sceneEvent) - if utils.CheckNoErrorAndPrint(err) { - dm.client.Publish(topic, 0, dm.config.Retain, json) - } -} - -func (dm *DigitalstromMqtt) publishDevice(changed digitalstrom.DeviceStateChanged) { - topic := dm.getTopic("devices", changed.Device.Dsid, changed.Device.Name, changed.Channel, "state") - - dm.client.Publish(topic, 0, dm.config.Retain, fmt.Sprintf("%.2f", changed.NewValue)) -} - -func (dm *DigitalstromMqtt) publishCircuit(changed digitalstrom.CircuitValueChanged) { - //log.Info().Msg("Updating meter", changed.Circuit.Name, changed.ConsumptionW, changed.EnergyWs) - - if changed.ConsumptionW != -1 { - topic := dm.getTopic("circuits", changed.Circuit.Dsid, changed.Circuit.Name, "consumptionW", "state") - dm.client.Publish(topic, 0, dm.config.Retain, fmt.Sprintf("%d", changed.ConsumptionW)) - } - - if changed.EnergyWs != -1 { - topic := dm.getTopic("circuits", changed.Circuit.Dsid, changed.Circuit.Name, "EnergyWs", "state") - dm.client.Publish(topic, 0, dm.config.Retain, fmt.Sprintf("%d", changed.EnergyWs)) - } -} - -func (dm *DigitalstromMqtt) deviceReceiverHandler(deviceName string, channel string, msg mqtt.Message) { - payloadStr := string(msg.Payload()) - log.Info(). - Str("device", deviceName). - Str("channel", channel). - Str("payload", payloadStr). - Msg("MQTT message received") - if strings.ToLower(payloadStr) == "stop" { - dm.digitalstrom.SetDeviceValue(digitalstrom.DeviceCommand{ - Action: digitalstrom.Stop, - DeviceName: deviceName, - Channel: channel, - }) - } else { - value, err := strconv.ParseFloat(payloadStr, 64) - if utils.CheckNoErrorAndPrint(err) { - dm.digitalstrom.SetDeviceValue(digitalstrom.DeviceCommand{ - Action: digitalstrom.Set, - DeviceName: deviceName, - Channel: channel, - NewValue: value, - }) - } else { - log.Error().Err(err).Str("payload", payloadStr).Msg("Unable to parse payload") - } - } -} - -func (dm *DigitalstromMqtt) subscribeToAllDevicesCommands() { - for _, device := range dm.digitalstrom.GetAllDevices() { - for _, channel := range device.OutputChannels { - deviceName := device.Name // deep copy - deviceId := device.Dsid // deep copy - channelCopy := channel // deep copy - topic := dm.getTopic("devices", deviceId, deviceName, channelCopy, "command") - log.Trace().Str("topic", topic).Str("deviceName", deviceName).Str("channel", channelCopy).Msg("Subscribing for topic") - dm.client.Subscribe(topic, 0, func(client mqtt.Client, message mqtt.Message) { - log.Debug().Str("topic", topic).Str("deviceName", deviceName).Str("channel", channelCopy).Msg("Message received") - dm.deviceReceiverHandler(deviceName, channelCopy, message) - }) - } - } -} - -func (dm *DigitalstromMqtt) getTopic(deviceType string, deviceId string, deviceName string, channel string, commandState string) string { - topic := dm.config.TopicPrefix - - if dm.config.NormalizeDeviceName { - deviceName = normalizeForTopicName(deviceName) - } - - topic += "/"+deviceType - topic += "/"+deviceName - topic += "/"+channel - topic += "/"+commandState - - return topic -} - -// Returns MQTT topic to publish the Server status. -func (dm *DigitalstromMqtt) getStatusTopic() string { - return dm.config.TopicPrefix + "/server/state" -} - -func normalizeForTopicName(item string) string { - output := "" - for i := 0; i < len(item); i++ { - c := item[i] - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { - output += string(c) - } else if c == ' ' || c == '/' { - output += "_" - } - } - return output -} diff --git a/digitalstrom_mqtt/mqtt_homeassistant.go b/digitalstrom_mqtt/mqtt_homeassistant.go deleted file mode 100644 index 38e8432..0000000 --- a/digitalstrom_mqtt/mqtt_homeassistant.go +++ /dev/null @@ -1,331 +0,0 @@ -package digitalstrom_mqtt - -import ( - "encoding/json" - "fmt" - "github.com/rs/zerolog/log" - "strings" - - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/gaetancollaud/digitalstrom-mqtt/digitalstrom" - "github.com/gaetancollaud/digitalstrom-mqtt/utils" -) - -// Define Home Assistant components. -type Component string - -const ( - Light Component = "light" - Cover Component = "cover" - Sensor Component = "sensor" -) - -type HomeAssistantMqtt struct { - mqtt *DigitalstromMqtt - config *config.ConfigHomeAssistant -} - -func (hass *HomeAssistantMqtt) Start() { - if !hass.config.DiscoveryEnabled { - return - } - hass.publishDiscoveryMessages() -} - -// Publish the current binary status into the MQTT topic. -func (hass *HomeAssistantMqtt) publishDiscoveryMessages() { - for _, device := range hass.mqtt.digitalstrom.GetAllDevices() { - messages, err := hass.deviceToHomeAssistantDiscoveryMessage(device) - if utils.CheckNoErrorAndPrint(err) { - for _, discovery_message := range messages { - hass.mqtt.client.Publish(discovery_message.topic, 0, true, discovery_message.message) - } - } - } - for _, circuit := range hass.mqtt.digitalstrom.GetAllCircuits() { - messages, err := hass.circuitToHomeAssistantDiscoveryMessage(circuit) - if utils.CheckNoErrorAndPrint(err) { - for _, discovery_message := range messages { - hass.mqtt.client.Publish(discovery_message.topic, 0, true, discovery_message.message) - } - } - } -} - -// Define a Home Assistant discovery message as its MQTT topic and the message -// to be published. -type HassDiscoveryMessage struct { - topic string - message []byte -} - -// Generates the discovery topic where to publish the message following the -// Home Assistant convention. -func (hass *HomeAssistantMqtt) discoveryTopic(component Component, deviceId string, objectId string) string { - return hass.config.DiscoveryTopicPrefix + "/" + string(component) + "/" + deviceId + "/" + objectId + "/config" -} - -// Return the definition of the light and cover entities coming from a device. -func (hass *HomeAssistantMqtt) deviceToHomeAssistantDiscoveryMessage(device digitalstrom.Device) ([]HassDiscoveryMessage, error) { - // Check for device instances where the discovery message can not be created. - if device.Name == "" { - return nil, fmt.Errorf("empty device name, skipping discovery message") - } - if (device.DeviceType != digitalstrom.Light) && (device.DeviceType != digitalstrom.Blind) { - return nil, fmt.Errorf("device type not supported %s", device.DeviceType) - } - deviceConfig := map[string]interface{}{ - "configuration_url": "https://" + hass.config.DigitalStromHost, - "identifiers": []interface{}{device.Dsid, device.Dsuid}, - "manufacturer": "DigitalStrom", - "model": device.HwInfo, - "name": device.Name, - } - availability := []interface{}{ - map[string]interface{}{ - "topic": hass.mqtt.getStatusTopic(), - "payload_available": Online, - "payload_not_available": Offline, - }, - } - var message map[string]interface{} - var topic string - if device.DeviceType == digitalstrom.Light { - if device.OutputChannels == nil || len(device.OutputChannels) == 0 { - log.Warn(). - Str("dsid", device.Dsid). - Str("deviceName", device.Name). - Msg("Light device has no output channel") - } else { - // Setup configuration for a MQTT Cover in Home Assistant: - // https://www.home-assistant.io/integrations/light.mqtt/ - nodeId := "light" - topic = hass.discoveryTopic(Light, device.Dsid, nodeId) - message = map[string]interface{}{ - "device": deviceConfig, - "name": utils.RemoveRegexp( - device.Name, - hass.config.RemoveRegexpFromName), - "unique_id": device.Dsid + "_" + nodeId, - "availability": availability, - "availability_mode": "all", - "command_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - device.OutputChannels[0], - "command"), - "state_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - device.OutputChannels[0], - "state"), - "payload_on": "100.00", - "payload_off": "0.00", - "qos": 0, - } - if device.Properties.Dimmable { - message["on_command_type"] = "brightness" - message["brightness_scale"] = 100 - message["brightness_state_topic"] = hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - device.OutputChannels[0], - "state") - message["brightness_command_topic"] = hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - device.OutputChannels[0], - "command") - - } - - } - } else if device.DeviceType == digitalstrom.Blind { - // Setup configuration for a MQTT Cover in Home Assistant: - // https://www.home-assistant.io/integrations/cover.mqtt/ - - // Covers expose up to two output channels: - // * to control the position, and - // * to control the tilt - // For that reason let's extract the correspondent channel for each - // action. - positionChannel := "" - tiltChannel := "" - for _, channelName := range device.OutputChannels { - if strings.Contains(channelName, "Angle") { - tiltChannel = channelName - } - if strings.Contains(channelName, "Position") { - positionChannel = channelName - } - } - // If position channel has not been found raise error. - if positionChannel == "" { - return nil, fmt.Errorf("position channel could not be found for device " + device.Name) - } - - nodeId := "cover" - topic = hass.discoveryTopic(Cover, device.Dsid, nodeId) - message = map[string]interface{}{ - "device": deviceConfig, - "name": utils.RemoveRegexp( - device.Name, - hass.config.RemoveRegexpFromName), - "unique_id": device.Dsid + "_" + nodeId, - "device_class": "blind", - "availability": availability, - "availability_mode": "all", - "state_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - positionChannel, - "state"), - "state_closed": "0.00", - "state_open": "100.00", - "command_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - positionChannel, - "command"), - "payload_close": "0.00", - "payload_open": "100.00", - "payload_stop": "STOP", - "position_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - positionChannel, - "state"), - "set_position_topic": hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - positionChannel, - "command"), - "position_template": "{{ value | int }}", - "qos": 0, - } - // In case the cover supports tilting. - if tiltChannel != "" { - message["tilt_status_topic"] = hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - tiltChannel, - "state") - message["tilt_command_topic"] = hass.mqtt.getTopic( - "devices", - device.Dsid, - device.Name, - tiltChannel, - "command") - message["tilt_status_template"] = "{{ value | int }}" - } - } else { - return nil, fmt.Errorf("device type is not supported to be announce to Home Assistant discovery") - } - json, err := json.Marshal(message) - if err != nil { - return nil, err - } - return []HassDiscoveryMessage{ - { - topic: topic, - message: json, - }, - }, nil -} - -// Return the definition of the power and energy sensors coming from the circuit -// devices. -func (hass *HomeAssistantMqtt) circuitToHomeAssistantDiscoveryMessage(circuit digitalstrom.Circuit) ([]HassDiscoveryMessage, error) { - deviceConfig := map[string]interface{}{ - "configuration_url": "https://" + hass.config.DigitalStromHost, - "identifiers": []interface{}{circuit.Dsid}, - "manufacturer": "DigitalStrom", - "model": circuit.HwName, - "name": circuit.Name, - } - availability := []interface{}{ - map[string]interface{}{ - "topic": hass.mqtt.getStatusTopic(), - "payload_available": Online, - "payload_not_available": Offline, - }, - } - // Setup configuration for a MQTT Cover in Home Assistant: - // https://www.home-assistant.io/integrations/sensor.mqtt/ - // Define sensor for power consumption. This is a straightforward - // definition. - powerNodeId := "power" - powerTopic := hass.discoveryTopic(Sensor, circuit.Dsid, powerNodeId) - powerMessage := map[string]interface{}{ - "device": deviceConfig, - "name": "Power " + circuit.Name, - "unique_id": circuit.Dsid + "_" + powerNodeId, - "availability": availability, - "availability_mode": "all", - "state_topic": hass.mqtt.getTopic( - "circuits", - circuit.Dsid, - circuit.Name, - "consumptionW", - "state"), - "unit_of_measurement": "W", - "device_class": "power", - "icon": "mdi:flash", - "qos": 0, - } - // Define the energy sensor. We need to define the state class in order to - // make sure statistics are bing computed and stored. We also use the - // `value_template` field to make the conversion from Ws reported in the - // MQTT topic, to kWh which is the default unit of measurement of energy in - // Home Assistant. - energyNodeId := "energy" - energyTopic := hass.discoveryTopic(Sensor, circuit.Dsid, energyNodeId) - energyMessage := map[string]interface{}{ - "device": deviceConfig, - "name": "Energy " + circuit.Name, - "unique_id": circuit.Dsid + "_" + energyNodeId, - "availability": availability, - "availability_mode": "all", - "state_topic": hass.mqtt.getTopic( - "circuits", - circuit.Dsid, - circuit.Name, - "EnergyWs", - "state"), - "unit_of_measurement": "kWh", - "device_class": "energy", - "state_class": "total_increasing", - // Convert the vaue from Ws to kWh which is the default energy unit in - // Home Assistant - "value_template": "{{ (value | float / (3600*1000)) | round(3) }}", - "icon": "mdi:lightning-bolt", - "qos": 0, - } - powerJson, err := json.Marshal(powerMessage) - if err != nil { - return nil, err - } - enegryJson, err := json.Marshal(energyMessage) - if err != nil { - return nil, err - } - return []HassDiscoveryMessage{ - { - topic: powerTopic, - message: powerJson, - }, - { - topic: energyTopic, - message: enegryJson, - }, - }, nil -} diff --git a/docker-compose.yml b/docker-compose.yml index e839f44..3bdc8a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,27 @@ services: - MQTT_URL=tcp://mosquitto:1883 - MQTT_RETAIN=true - REFRESH_AT_START=true + - HOME_ASSISTANT_DISCOVERY_ENABLED=true env_file: - .env mosquitto: - image: eclipse-mosquitto:1.5 + image: eclipse-mosquitto:1.6 volumes: - ./docs/docker/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro ports: - "1883:1883" - "9001:9001" + + homeassistant: + container_name: homeassistant + image: ghcr.io/home-assistant/home-assistant:stable + volumes: +# - ./docs/docker/homeassistant.yaml:/config/configuration.yaml + - ./docs/docker/homeassistant:/config/ + - /etc/localtime:/etc/localtime:ro + - /run/dbus:/run/dbus:ro + ports: + - "8123:8123" +# network_mode: host +# privileged: true \ No newline at end of file diff --git a/docs/api/apartment.http b/docs/api/apartment.http new file mode 100644 index 0000000..f059db0 --- /dev/null +++ b/docs/api/apartment.http @@ -0,0 +1,9 @@ +// @no-cookie-jar +GET {{host}}/api/v1/apartment?include=dsDevice +Authorization: Bearer {{api-key}} + +### + +// @no-cookie-jar +GET {{host}}/api/v1/apartment/status +Authorization: Bearer {{api-key}} diff --git a/docs/api/devices.http b/docs/api/devices.http new file mode 100644 index 0000000..1a01fe3 --- /dev/null +++ b/docs/api/devices.http @@ -0,0 +1,44 @@ +// @no-cookie-jar +GET {{host}}/api/v1/apartment/dsDevices?includeAll=true +Authorization: Bearer {{api-key}} + +### +// @no-cookie-jar +GET {{host}}/api/v1/apartment/functionBlocks +Authorization: Bearer {{api-key}} + +### +PATCH {{host}}/api/v1/apartment/dsDevices/303505d7f8000000000000400013befc00/status +Authorization: Bearer {{api-key}} +Content-Type: application/json + +[ + { + "op": "replace", + "path": "/functionBlocks/303505d7f8000000000000400013befc00/outputs/brightness/value", + "value": 0 + } +] + + +### +// @no-cookie-jar +GET {{host}}/api/v1/apartment/submodules +Authorization: Bearer {{api-key}} + + +### +GET {{host}}/api/v1/apartment/dsDevices +Authorization: Bearer {{api-key}} + +### +GET {{host}}/api/v1/apartment/dsDevices/303505d7f8000000000000400013befc00 +Authorization: Bearer {{api-key}} + +### +GET {{host}}/api/v1/apartment/dsDevices/status +Authorization: Bearer {{api-key}} + +### +GET {{host}}/api/v1/apartment/dsDevices/303505d7f8000000000000400013befc00/status +Authorization: Bearer {{api-key}} diff --git a/docs/api/meterings.http b/docs/api/meterings.http new file mode 100644 index 0000000..56a2891 --- /dev/null +++ b/docs/api/meterings.http @@ -0,0 +1,9 @@ +// @no-cookie-jar +GET {{host}}/api/v1/apartment/meterings +Authorization: Bearer {{api-key}} + +### + +// @no-cookie-jar +GET {{host}}/api/v1/apartment/meterings/values +Authorization: Bearer {{api-key}} diff --git a/docs/api/notifications.http b/docs/api/notifications.http new file mode 100644 index 0000000..013582d --- /dev/null +++ b/docs/api/notifications.http @@ -0,0 +1,3 @@ +// @no-cookie-jar +WEBSOCKET {{websocketHost}}/api/v1/apartment/notifications +Content-Type: application-json // Used for content highlighting only diff --git a/docs/api/scenarios.http b/docs/api/scenarios.http new file mode 100644 index 0000000..e974845 --- /dev/null +++ b/docs/api/scenarios.http @@ -0,0 +1,15 @@ +// @no-cookie-jar +GET {{host}}/api/v1/apartment/scenarios +Authorization: Bearer {{api-key}} + +### +POST {{host}}/api/v1/apartment/scenarios/invoke +Authorization: Bearer {{api-key}} + +{ + "context": "deviceStandard", + "actionId": "on", + "application": "lights", + "zone": "5", + "dsDevice": "303505d7f8000000000000400013befc00" +} \ No newline at end of file diff --git a/docs/api/token.http b/docs/api/token.http new file mode 100644 index 0000000..59f267e --- /dev/null +++ b/docs/api/token.http @@ -0,0 +1,30 @@ +GET {{host}}/json/system/login?user={{username}}&password={{password}} + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.log(`Response Json: ${response.body.result.token}`) + client.global.set("token", response.body.result.token); +%} + +### +GET {{host}}/json/system/login?user={{username}}&password={{password}} + +### +DELETE {{host}}/api/v1/apartment/applicationTokens/dbaa8fec2cd7841ce7cc0071d2094dcfe05d53be8c554d383085de5b43a425cd + + + +### +// @no-cookie-jar +POST {{host}}/api/v1/apartment/applicationTokens?token={{token}} + +{ + "data": { + "type": "applicationToken", + "attributes": { + "name": "test" + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 5b56a92..70fcd10 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,32 @@ module github.com/gaetancollaud/digitalstrom-mqtt -go 1.15 +go 1.18 require ( github.com/eclipse/paho.mqtt.golang v1.3.2 github.com/google/uuid v1.2.0 + github.com/mitchellh/mapstructure v1.5.0 github.com/rs/zerolog v1.21.0 github.com/spf13/viper v1.7.1 + github.com/stretchr/testify v1.3.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.4.7 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.1 // indirect + github.com/pelletier/go-toml v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/afero v1.1.2 // indirect + github.com/spf13/cast v1.3.0 // indirect + github.com/spf13/jwalterweatherman v1.0.0 // indirect + github.com/spf13/pflag v1.0.3 // indirect + github.com/subosito/gotenv v1.2.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/ini.v1 v1.51.0 // indirect + gopkg.in/yaml.v2 v2.2.4 // indirect ) diff --git a/go.sum b/go.sum index a1e29f0..162c1da 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -31,6 +32,7 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -67,9 +69,12 @@ github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= @@ -97,14 +102,17 @@ github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/J github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -118,8 +126,9 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -130,6 +139,7 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -149,7 +159,9 @@ github.com/rs/zerolog v1.21.0/go.mod h1:ZPhntP/xmq1nnND05hhpAh2QMhSsA4UN3MGZ6O2J github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -166,6 +178,7 @@ github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= @@ -218,10 +231,12 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -244,17 +259,21 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -302,6 +321,7 @@ google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiq google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= diff --git a/main.go b/main.go index c4afe65..c76a832 100644 --- a/main.go +++ b/main.go @@ -1,25 +1,60 @@ package main import ( + "flag" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/digitalstrom" "os" "os/signal" "syscall" "time" - "github.com/gaetancollaud/digitalstrom-mqtt/config" - "github.com/gaetancollaud/digitalstrom-mqtt/digitalstrom" - "github.com/gaetancollaud/digitalstrom-mqtt/digitalstrom_mqtt" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/controller" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { - // TODO put in config log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) zerolog.SetGlobalLevel(zerolog.InfoLevel) - config := config.FromEnv() + mode := flag.String("mode", "standard", "Operation mode (standard, get-api-key)") + + host := flag.String("host", "test", "DigitalSTROM server host") + port := flag.Int("port", 8080, "DigitalSTROM server port") + username := flag.String("username", "dssadmin", "DigitalSTROM user name") + password := flag.String("password", "", "DigitalSTROM password") + integrationName := flag.String("integrationName", "digitalstrom-to-mqtt", "Name of the integration. It will appear in digitalSTROM system panel") + + flag.Parse() + + if *mode == "standard" { + modeStandard() + } else if *mode == "get-api-key" { + modeGetApiKey(*host, *port, *username, *password, *integrationName) + } else { + log.Error().Str("mode", *mode).Msg("Unknown mode") + flag.PrintDefaults() + } +} + +func modeGetApiKey(host string, port int, user string, password string, integrationName string) { + apiKey, err := digitalstrom.GetApiKey(host, port, user, password, integrationName) + if err != nil { + log.Fatal().Err(err).Msg("Unable to get API key.") + } else { + log.Info(). + Str("DIGITALSTROM_API_KEY", apiKey). + Msg("API key successfully retrieved. Please save it in the config file, this cannot be retrieved a second time. You will have to create a new API key.") + } +} + +func modeStandard() { + config, err := config.ReadConfig() + if err != nil { + log.Fatal().Err(err).Msg("Error found when reading the config.") + } if config.LogLevel == "TRACE" { zerolog.SetGlobalLevel(zerolog.TraceLevel) @@ -33,24 +68,21 @@ func main() { zerolog.SetGlobalLevel(zerolog.ErrorLevel) } - if config.Mqtt.TopicFormat != "deprecated" { - log.Fatal().Msg("MQTT_TOPIC_FORMAT is deprecated and cannot be used anymore, please use MQTT_TOPIC_PREFIX instead") - os.Exit(1) - } - - log.Info().Msg("String digitalstrom MQTT!") + log.Info().Msg("Starting DigitalStrom MQTT!") - ds := digitalstrom.New(config) - mqtt := digitalstrom_mqtt.New(config, ds) - - ds.Start() - mqtt.Start() + // Initialize controller responsible for all the bridge logic. + ctrl := controller.NewController(config) + if err := ctrl.Start(); err != nil { + log.Fatal().Err(err).Msg("Error on starting the controller") + } // Subscribe for interruption happening during execution. - exitSignal := make(chan os.Signal) + exitSignal := make(chan os.Signal, 2) signal.Notify(exitSignal, os.Interrupt, syscall.SIGTERM) <-exitSignal - // Gracefulle stop the connections. - mqtt.Stop() + // Gracefulle stop all the modules loops and logic. + if err := ctrl.Stop(); err != nil { + log.Fatal().Err(err).Msg("Error when stopping the controller") + } } diff --git a/migration_v1_v2.md b/migration_v1_v2.md new file mode 100644 index 0000000..fafa50f --- /dev/null +++ b/migration_v1_v2.md @@ -0,0 +1,54 @@ +# Migrating from version 1.x to version 2.x + +## Reason for a version 2 + +DigitalSTROM introduced a [new API called Smarthome](https://developer.digitalstrom.org/api/#auth). This API was a +complete rewrite of the previous API. It's more modern, but also different. Some of the features of the old API +don't exists anymore or where renamed. This is the reason of the rewrite of this application and the version 2.0. + +## Authentication + +Previously you needed to provide the username and password of the digitalSTROM user. Now you need to provide an API key. +This is more safe in a sense that digitalstrom-mqtt will not be aware of your credentials anymore. You can create +a new key using the script provided (see README). You will have to provide a new config called `DIGITALSTROM_API_KEY`. +If you provide the username or password in the config, the app will now complain. + +You can now manage the api-key in the digitalSTROM web api under System -> Access Authorization. + +## Circuits + +Circuits don't exist anymore. They are replaced by the concept of "controllers". In addition, the power consumption +has been moved to a new entity called "metering". Thus, the MQTT interface don't expose circuits anymore but +"meterings". On top of that there is one metering for the "apartment", which basically just sum up all the meterings of +the controllers. + +## Metering interval + +Power and energy consumption are now updated every 10 seconds instead of 30 seconds. + +## Scenes + +Scenes event are not propagated anymore, so this was removed from the MQTT interface. + +## Buttons + +Buttons were kind of a hack in the previous version. Since scenes are not exposed anyway it's not possible to redo this +hack and expose button presses anymore. This was also removed + +## Devices + +Devices are 100% compatible, but if you used home assistant, maybe the ids may have changed. Indeed the new API exposes +"deviceId", "outputId", etc. so it's possible that the mapping of the devices in home assistant has to be redo. The +previous implementation used `dsid` which is fully accessible in the new API anymore. + +## Home Assistant + +Since home assistant is the de-facto standard for home automation nowadays, it is enabled by default. To better +help home assistant define the state of the devices the MQTT retain flag will also be enabled by default. If you don't +want to use home assistant you can simply disable it. Although having it enabled should not affect you. The +home assistant topic can just be ignored. The rest of the MQTT interface is still the same. + +```yaml +MQTT_RETAIN: false +HOME_ASSISTANT_DISCOVERY_ENABLED: false +``` \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..3864f01 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + "fmt" + "github.com/spf13/viper" +) + +type ConfigDigitalstrom struct { + Host string + Port int + // Deprecated: use apiKey instead + Username string + // Deprecated: use apiKey instead + Password string + ApiKey string +} +type ConfigMqtt struct { + MqttUrl string + Username string + Password string + TopicPrefix string + NormalizeDeviceName bool + Retain bool +} +type ConfigHomeAssistant struct { + DiscoveryEnabled bool + DiscoveryTopicPrefix string + RemoveRegexpFromName string + DigitalStromHost string + Retain bool +} +type Config struct { + Digitalstrom ConfigDigitalstrom + Mqtt ConfigMqtt + HomeAssistant ConfigHomeAssistant + RefreshAtStart bool + LogLevel string + InvertBlindsPosition bool +} + +const ( + undefined string = "__undefined__" + deprecated string = "__deprecated__" + configFile string = "config.yaml" + envKeyDigitalstromHost string = "digitalstrom_host" + envKeyDigitalstromPort string = "digitalstrom_port" + envKeyDigitalstromUsername string = "digitalstrom_username" + envKeyDigitalstromPassword string = "digitalstrom_password" + envKeyDigitalstromApiKey string = "digitalstrom_api_key" + envKeyMqttUrl string = "mqtt_url" + envKeyMqttUsername string = "mqtt_username" + envKeyMqttPassword string = "mqtt_password" + envKeyMqttTopicFormat string = "mqtt_topic_format" + envKeyMqttTopicPrefix string = "mqtt_topic_prefix" + envKeyMqttNormalizeTopicName string = "mqtt_normalize_device_name" + envKeyMqttRetain string = "mqtt_retain" + envKeyInvertBlindsPosition string = "invert_blinds_position" + envKeyRefreshAtStart string = "refresh_at_start" + envKeyLogLevel string = "log_level" + envKeyHomeAssistantDiscoveryEnabled string = "home_assistant_discovery_enabled" + envKeyHomeAssistantDiscoveryPrefix string = "home_assistant_discovery_prefix" + envKeyHomeAssistantRemoveRegexpFromName string = "home_assistant_remove_regexp_from_name" +) + +var defaultConfig = map[string]interface{}{ + envKeyDigitalstromHost: undefined, + envKeyDigitalstromPort: 8080, + envKeyDigitalstromUsername: deprecated, + envKeyDigitalstromPassword: deprecated, + envKeyDigitalstromApiKey: undefined, + envKeyMqttUrl: undefined, + envKeyMqttUsername: undefined, + envKeyMqttPassword: undefined, + envKeyMqttTopicPrefix: "digitalstrom", + envKeyMqttTopicFormat: deprecated, + envKeyMqttNormalizeTopicName: true, + envKeyMqttRetain: true, + envKeyRefreshAtStart: true, + envKeyLogLevel: "INFO", + envKeyInvertBlindsPosition: false, + envKeyHomeAssistantDiscoveryEnabled: true, + envKeyHomeAssistantDiscoveryPrefix: "homeassistant", + envKeyHomeAssistantRemoveRegexpFromName: "", +} + +// FromEnv returns a Config from env variables +func ReadConfig() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + // Set the current directory where the binary is being run. + viper.AddConfigPath(".") + viper.AutomaticEnv() + for key, value := range defaultConfig { + if value != undefined && value != deprecated { + viper.SetDefault(key, value) + } + } + + err := viper.ReadInConfig() + if err != nil { + return nil, fmt.Errorf("ReadInConfig error: %w", err) + } + + // Check for deprecated and undefined fields. + for fieldName, defaultValue := range defaultConfig { + if defaultValue == deprecated && viper.IsSet(fieldName) { + return nil, fmt.Errorf("deprecated field found in config: %s", fieldName) + } + if defaultValue == undefined && !viper.IsSet(fieldName) { + return nil, fmt.Errorf("required field not found in config: %s", fieldName) + } + } + + config := &Config{ + Digitalstrom: ConfigDigitalstrom{ + Host: viper.GetString(envKeyDigitalstromHost), + Port: viper.GetInt(envKeyDigitalstromPort), + Username: viper.GetString(envKeyDigitalstromUsername), + Password: viper.GetString(envKeyDigitalstromPassword), + ApiKey: viper.GetString(envKeyDigitalstromApiKey), + }, + Mqtt: ConfigMqtt{ + MqttUrl: viper.GetString(envKeyMqttUrl), + Username: viper.GetString(envKeyMqttUsername), + Password: viper.GetString(envKeyMqttPassword), + TopicPrefix: viper.GetString(envKeyMqttTopicPrefix), + NormalizeDeviceName: viper.GetBool(envKeyMqttNormalizeTopicName), + Retain: viper.GetBool(envKeyMqttRetain), + }, + HomeAssistant: ConfigHomeAssistant{ + DiscoveryEnabled: viper.GetBool(envKeyHomeAssistantDiscoveryEnabled), + DiscoveryTopicPrefix: viper.GetString(envKeyHomeAssistantDiscoveryPrefix), + RemoveRegexpFromName: viper.GetString(envKeyHomeAssistantRemoveRegexpFromName), + DigitalStromHost: viper.GetString(envKeyDigitalstromHost), + Retain: viper.GetBool(envKeyMqttRetain), + }, + RefreshAtStart: viper.GetBool(envKeyRefreshAtStart), + LogLevel: viper.GetString(envKeyLogLevel), + InvertBlindsPosition: viper.GetBool(envKeyInvertBlindsPosition), + } + + return config, nil +} + +func (c *Config) String() string { + return fmt.Sprintf("%+v\n", c.Digitalstrom) +} diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml new file mode 100644 index 0000000..e84d40b --- /dev/null +++ b/pkg/config/config.yaml @@ -0,0 +1,7 @@ +--- +# For testing purposes. +digitalstrom_host: "192.168.1.1" +digitalstrom_api_key: "foo" +mqtt_url: "192.168.1.2" +mqtt_username: mqtt +mqtt_password: "12345678" diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..d328dbe --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadConfig(t *testing.T) { + os.Setenv("DIGITALSTROM_HOST", "test_ip") + os.Setenv("DIGITALSTROM_API_KEY", "foo") + + c, err := ReadConfig() + if err != nil { + t.Fail() + t.Logf("Error found: %s", err.Error()) + } + + assert.Equal(t, "test_ip", c.Digitalstrom.Host, "DigitalStrom host is wrong.") + assert.Equal(t, "foo", c.Digitalstrom.ApiKey, "DigitalStrom api key is wrong.") + assert.Equal(t, "mqtt", c.Mqtt.Username, "MQTT username is wrong.") + assert.Equal(t, "digitalstrom", c.Mqtt.TopicPrefix, "MQTT prefix is wrong.") +} + +func TestReadConfigWithDeprecatedFields(t *testing.T) { + os.Setenv("MQTT_TOPIC_FORMAT", "foo") + _, err := ReadConfig() + assert.EqualError(t, err, "deprecated field found in config: mqtt_topic_format") + os.Clearenv() + + os.Setenv("DIGITALSTROM_USERNAME", "foo") + _, err = ReadConfig() + assert.EqualError(t, err, "deprecated field found in config: digitalstrom_username") + os.Clearenv() + + os.Setenv("DIGITALSTROM_PASSWORD", "foo") + _, err = ReadConfig() + assert.EqualError(t, err, "deprecated field found in config: digitalstrom_password") + os.Clearenv() +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go new file mode 100644 index 0000000..11e630f --- /dev/null +++ b/pkg/controller/controller.go @@ -0,0 +1,121 @@ +package controller + +import ( + "fmt" + + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/controller/modules" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/digitalstrom" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/homeassistant" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/mqtt" + "github.com/rs/zerolog/log" +) + +type Controller struct { + dsClient digitalstrom.Client + dsRegistry digitalstrom.Registry + mqttClient mqtt.Client + hassDiscovery *homeassistant.HomeAssistantDiscovery + + modules map[string]modules.Module +} + +func NewController(config *config.Config) *Controller { + // Create Digitalstrom client. + dsOptions := digitalstrom.NewClientOptions(). + SetHost(config.Digitalstrom.Host). + SetPort(config.Digitalstrom.Port). + SetApiKey(config.Digitalstrom.ApiKey) + dsClient := digitalstrom.NewClient(dsOptions) + + dsRegistry := digitalstrom.NewRegistry(dsClient) + + mqttOptions := mqtt.NewClientOptions(). + SetMqttUrl(config.Mqtt.MqttUrl). + SetUsername(config.Mqtt.Username). + SetPassword(config.Mqtt.Password). + SetTopicPrefix(config.Mqtt.TopicPrefix). + SetRetain(config.Mqtt.Retain) + mqttClient := mqtt.NewClient(mqttOptions) + + hass := homeassistant.NewHomeAssistantDiscovery( + mqttClient, + &config.HomeAssistant) + + controller := Controller{ + dsClient: dsClient, + dsRegistry: dsRegistry, + mqttClient: mqttClient, + hassDiscovery: hass, + modules: map[string]modules.Module{}, + } + + for name, builder := range modules.Modules { + module := builder(mqttClient, dsClient, dsRegistry, config) + controller.modules[name] = module + } + + return &controller +} + +func (c *Controller) Start() error { + log.Info().Msg("Starting controller.") + if err := c.mqttClient.Connect(); err != nil { + return fmt.Errorf("error connecting to MQTT client: %w", err) + } + if err := c.dsClient.Connect(); err != nil { + return fmt.Errorf("error connecting to DigitalStrom client: %w", err) + } + if err := c.dsRegistry.Start(); err != nil { + return fmt.Errorf("error starting DigitalStrom registry: %w", err) + } + + for name, module := range c.modules { + log.Info().Str("module", name).Msg("Starting module.") + if err := module.Start(); err != nil { + return fmt.Errorf("error starting module '%s': %w", name, err) + } + } + + // Retrieve from all the modules the discovery configs to be exported. + for name, module := range c.modules { + m, ok := module.(homeassistant.HomeAssistantDiscoveryInterface) + if !ok { + continue + } + configs, err := m.GetHomeAssistantEntities() + if err != nil { + return fmt.Errorf("error getting discovery configs from module '%s': %w", name, err) + } + c.hassDiscovery.AddConfigs(configs) + } + // Publishes Home Assistant Discovery messages. + if err := c.hassDiscovery.PublishDiscoveryMessages(); err != nil { + return err + } + + return nil +} + +func (c *Controller) Stop() error { + log.Info().Msg("Stopping controller.") + + for name, module := range c.modules { + log.Info().Str("module", name).Msg("Stopping module.") + if err := module.Stop(); err != nil { + return fmt.Errorf("error stopping module '%s': %w", name, err) + } + } + + if err := c.mqttClient.Disconnect(); err != nil { + return fmt.Errorf("error disconnecting to MQTT client: %w", err) + } + if err := c.dsRegistry.Stop(); err != nil { + return fmt.Errorf("error stoping DigitalStrom registry: %w", err) + } + if err := c.dsClient.Disconnect(); err != nil { + return fmt.Errorf("error disconnecting to DigitalStrom client: %w", err) + } + + return nil +} diff --git a/pkg/controller/modules/devices.go b/pkg/controller/modules/devices.go new file mode 100644 index 0000000..dce0805 --- /dev/null +++ b/pkg/controller/modules/devices.go @@ -0,0 +1,347 @@ +package modules + +import ( + "fmt" + mqtt_base "github.com/eclipse/paho.mqtt.golang" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/digitalstrom" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/homeassistant" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/mqtt" + "github.com/rs/zerolog/log" + "path" + "strconv" + "strings" +) + +const ( + devices string = "devices" + stop string = "stop" +) + +// Device Module encapsulates all the logic regarding the devices. The logic +// is the following: devices output values can be changed from mqtt and forwarded to digitalstrom on the opposite +// side, when an event is received from digitalstrom, the new value is pushed to mqtt. +type DeviceModule struct { + mqttClient mqtt.Client + dsClient digitalstrom.Client + dsRegistry digitalstrom.Registry + + normalizeDeviceName bool + refreshAtStart bool + invertBlindsPosition bool +} + +func (c *DeviceModule) Start() error { + devices, err := c.dsRegistry.GetDevices() + + for _, device := range devices { + err := c.dsRegistry.DeviceChangeSubscribe(device.DeviceId, func(deviceId string, outputId string, oldValue float64, newValue float64) { + err := c.updateDevice(deviceId) + if err != nil { + log.Error().Err(err).Str("deviceid", deviceId).Msg("Error updating device ") + } + }) + if err != nil { + return err + } + } + + if err != nil { + // Refresh devices values. + if c.refreshAtStart { + go func() { + for _, device := range devices { + if err := c.updateDevice(device.DeviceId); err != nil { + log.Error().Err(err).Msgf("Error updating device '%s'", device.Attributes.Name) + } + } + }() + } + } + + // Subscribe to MQTT events. + for _, device := range devices { + outputs, err := c.dsRegistry.GetOutputsOfDevice(device.DeviceId) + if err == nil { + for _, output := range outputs { + deviceId := device.DeviceId // deep copy + deviceName := device.Attributes.Name // deep copy + outputName := output.OutputId // deep copy + topic := c.deviceCommandTopic(deviceName, outputName) + log.Trace(). + Str("topic", topic). + Str("deviceName", deviceName). + Str("outputName", outputName). + Msg("Subscribing for topic.") + err := c.mqttClient.Subscribe(topic, func(client mqtt_base.Client, message mqtt_base.Message) { + payload := string(message.Payload()) + log.Trace(). + Str("topic", topic). + Str("deviceName", deviceName). + Str("outputName", outputName). + Str("payload", payload). + Msg("Message Received.") + if err := c.onMqttMessage(deviceId, outputName, payload); err != nil { + log.Error(). + Str("topic", topic). + Err(err). + Msg("Error handling MQTT Message.") + } + }) + if err != nil { + return err + } + } + } + } + return nil +} + +func (c *DeviceModule) Stop() error { + if devices, err := c.dsRegistry.GetDevices(); err != nil { + for _, device := range devices { + _ = c.dsRegistry.DeviceChangeUnsubscribe(device.DeviceId) + } + } + + return nil +} + +func (c *DeviceModule) onMqttMessage(deviceId string, outputId string, message string) error { + device, err := c.dsRegistry.GetDevice(deviceId) + if err != nil { + return err + } + + value, err := strconv.ParseFloat(strings.TrimSpace(message), 64) + if err != nil { + return fmt.Errorf("error parsing message as float value: %w", err) + } + value = c.invertValueIfNeeded(outputId, value) + log.Info(). + Str("device", device.Attributes.Name). + Str("outputId", outputId). + Float64("value", value). + Msg("Setting value.") + + functionBlock, err := c.dsRegistry.GetFunctionBlockForDevice(deviceId) + if err != nil { + return fmt.Errorf("no function block found for device %s: %w", deviceId, err) + } + + err = c.dsClient.DeviceSetOutputValue(deviceId, functionBlock.FunctionBlockId, outputId, value) + if err != nil { + return err + } + + // for fast deliveries we confirm the state + if err := c.publishDeviceValue(&device, outputId, value); err != nil { + return err + } + + return nil +} + +func (c *DeviceModule) updateDevice(deviceId string) error { + device, err := c.dsRegistry.GetDevice(deviceId) + if err != nil { + return err + } + outputs, err := c.dsRegistry.GetOutputsOfDevice(deviceId) + if err != nil { + return err + } + if len(outputs) == 0 { + log.Debug().Str("device", device.Attributes.Name).Msg("Skipping update. No output channels.") + return nil + } + + channels := []string{} + for _, output := range outputs { + channels = append(channels, output.Attributes.TechnicalName) + } + log.Debug(). + Str("device", device.Attributes.Name). + Str("outputChannels", strings.Join(channels, ";")). + Msg("Updating device") + + outputValues, err := c.dsRegistry.GetOutputValuesOfDevice(deviceId) + if err != nil { + return err + } + outputValuesLookup := map[string]digitalstrom.OutputValue{} + for _, outputValue := range outputValues { + outputValuesLookup[outputValue.OutputId] = outputValue + } + + for _, output := range outputs { + outputValue := outputValuesLookup[output.OutputId] + value := c.invertValueIfNeeded(output.OutputId, outputValue.TargetValue) + if err := c.publishDeviceValue(&device, output.OutputId, value); err != nil { + return fmt.Errorf("error publishing device '%s' value: %w", device.Attributes.Name, err) + } + } + + return nil +} + +func (c *DeviceModule) publishDeviceValue(device *digitalstrom.Device, outputId string, value float64) error { + return c.mqttClient.Publish(c.deviceStateTopic(device.Attributes.Name, outputId), fmt.Sprintf("%.2f", value)) +} + +func (c *DeviceModule) invertValueIfNeeded(channel string, value float64) float64 { + if c.invertBlindsPosition { + if strings.HasPrefix(strings.ToLower(channel), "shadeposition") { + return 100 - value + } + } + + // nothing to invert + return value +} + +func (c *DeviceModule) deviceStateTopic(deviceName string, channel string) string { + if c.normalizeDeviceName { + deviceName = normalizeForTopicName(deviceName) + } + return path.Join(devices, deviceName, channel, mqtt.State) +} + +func (c *DeviceModule) deviceCommandTopic(deviceName string, channel string) string { + if c.normalizeDeviceName { + deviceName = normalizeForTopicName(deviceName) + } + return path.Join(devices, deviceName, channel, mqtt.Command) +} + +func (c *DeviceModule) GetHomeAssistantEntities() ([]homeassistant.DiscoveryConfig, error) { + configs := []homeassistant.DiscoveryConfig{} + + devices, err := c.dsRegistry.GetDevices() + if err != nil { + return nil, err + } + + for _, device := range devices { + functionBlock, err := c.dsRegistry.GetFunctionBlockForDevice(device.DeviceId) + if err != nil { + return nil, err + } + properties := functionBlock.Properties() + deviceType := functionBlock.DeviceType() + var cfg homeassistant.DiscoveryConfig + if deviceType == digitalstrom.DeviceTypeLight { + + outputs, err := c.dsRegistry.GetOutputsOfDevice(device.DeviceId) + if err != nil || len(outputs) == 0 { + log.Info().Str("deviceId", device.DeviceId).Msg("Skipping device without output channels.") + continue + } + lightOutput := outputs[0] + + entityConfig := &homeassistant.LightConfig{ + BaseConfig: homeassistant.BaseConfig{ + Device: homeassistant.Device{ + Identifiers: []string{ + device.DeviceId, + }, + Model: functionBlock.Attributes.TechnicalName, + Name: device.Attributes.Name, + }, + Name: "light", + UniqueId: device.DeviceId + "_light", + }, + CommandTopic: c.mqttClient.GetFullTopic( + c.deviceCommandTopic(device.Attributes.Name, lightOutput.OutputId)), + StateTopic: c.mqttClient.GetFullTopic( + c.deviceStateTopic(device.Attributes.Name, lightOutput.OutputId)), + PayloadOn: "100.00", + PayloadOff: "0.00", + } + if properties.Dimmable { + entityConfig.OnCommandType = "brightness" + entityConfig.BrightnessScale = 100 + entityConfig.BrightnessStateTopic = c.mqttClient.GetFullTopic( + c.deviceStateTopic(device.Attributes.Name, lightOutput.OutputId)) + entityConfig.BrightnessCommandTopic = c.mqttClient.GetFullTopic( + c.deviceCommandTopic(device.Attributes.Name, lightOutput.OutputId)) + } + cfg = homeassistant.DiscoveryConfig{ + Domain: homeassistant.Light, + DeviceId: device.DeviceId, + ObjectId: "light", + Config: entityConfig, + } + configs = append(configs, cfg) + } else if deviceType == digitalstrom.DeviceTypeBlind { + entityConfig := &homeassistant.CoverConfig{ + BaseConfig: homeassistant.BaseConfig{ + Device: homeassistant.Device{ + Identifiers: []string{ + device.DeviceId, + }, + Model: functionBlock.Attributes.TechnicalName, + Name: device.Attributes.Name, + }, + Name: "cover", + UniqueId: device.DeviceId + "_cover", + }, + CommandTopic: c.mqttClient.GetFullTopic( + c.deviceCommandTopic(device.Attributes.Name, properties.PositionChannel)), + PayloadOpen: "100.00", + PayloadClose: "0.00", + PayloadStop: "STOP", + StateTopic: c.mqttClient.GetFullTopic( + c.deviceStateTopic(device.Attributes.Name, properties.PositionChannel)), + StateOpen: "100.00", + StateClosed: "0.00", + PositionTopic: c.mqttClient.GetFullTopic(c.deviceStateTopic(device.Attributes.Name, properties.PositionChannel)), + SetPositionTopic: c.mqttClient.GetFullTopic(c.deviceCommandTopic(device.Attributes.Name, properties.PositionChannel)), + PositionTemplate: "{{ value | int }}", + } + if properties.TiltChannel != "" { + entityConfig.TiltStatusTemplate = "{{ value | int }}" + entityConfig.TiltStatusTopic = c.mqttClient.GetFullTopic( + c.deviceStateTopic(device.Attributes.Name, properties.TiltChannel)) + entityConfig.TiltCommandTopic = c.mqttClient.GetFullTopic( + c.deviceCommandTopic(device.Attributes.Name, properties.TiltChannel)) + } + cfg = homeassistant.DiscoveryConfig{ + Domain: homeassistant.Cover, + DeviceId: device.DeviceId, + ObjectId: "cover", + Config: entityConfig, + } + configs = append(configs, cfg) + } + } + return configs, nil +} + +func normalizeForTopicName(item string) string { + output := "" + for i := 0; i < len(item); i++ { + c := item[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { + output += string(c) + } else if c == ' ' || c == '/' { + output += "_" + } + } + return output +} + +func NewDeviceModule(mqttClient mqtt.Client, dsClient digitalstrom.Client, dsRegistry digitalstrom.Registry, config *config.Config) Module { + return &DeviceModule{ + mqttClient: mqttClient, + dsClient: dsClient, + dsRegistry: dsRegistry, + normalizeDeviceName: config.Mqtt.NormalizeDeviceName, + refreshAtStart: config.RefreshAtStart, + invertBlindsPosition: config.InvertBlindsPosition, + } +} + +func init() { + Register("devices", NewDeviceModule) +} diff --git a/pkg/controller/modules/meterings.go b/pkg/controller/modules/meterings.go new file mode 100644 index 0000000..8c0918b --- /dev/null +++ b/pkg/controller/modules/meterings.go @@ -0,0 +1,201 @@ +package modules + +import ( + "fmt" + "path" + "time" + + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/digitalstrom" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/homeassistant" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/mqtt" + "github.com/rs/zerolog/log" +) + +const ( + meterings string = "meterings" + powerConsumption string = "consumptionW" + energyMeter string = "energyWh" +) + +// Meterings Module encapsulates all the logic regarding the meterings of the controllers. The logic +// is the following: every 10 seconds the meterings values are being checked and +// pushed to the corresponding topic in the MQTT server. +type MeteringsModule struct { + mqttClient mqtt.Client + dsClient digitalstrom.Client + dsRegistry digitalstrom.Registry + + ticker *time.Ticker + tickerDone chan struct{} +} + +func (c *MeteringsModule) Start() error { + c.ticker = time.NewTicker(10 * time.Second) + c.tickerDone = make(chan struct{}) + + go func() { + for { + select { + case <-c.tickerDone: + return + case <-c.ticker.C: + c.updateMeteringValues() + } + } + }() + return nil +} + +func (c *MeteringsModule) Stop() error { + c.ticker.Stop() + c.tickerDone <- struct{}{} + c.ticker = nil + return nil +} + +func (c *MeteringsModule) updateMeteringValues() { + log.Debug().Msg("Updating metering values.") + + meterings, err := c.dsRegistry.GetMeterings() + if err != nil { + log.Panic().Err(err).Msg("Error fetching the meterings in the apartment.") + } + + meteringStatus, err := c.dsClient.GetMeteringStatus() + if err != nil { + log.Error().Err(err).Msg("Error fetching metering status") + return + } + + meteringStatusLookup := make(map[string]digitalstrom.MeteringValue) + for _, value := range meteringStatus.Values { + meteringStatusLookup[value.Id] = value + } + + for _, metering := range meterings { + + var itemName string + + if metering.Attributes.Origin.Type == digitalstrom.MeteringTypeController { + controller, err := c.dsRegistry.GetControllerById(metering.Attributes.Origin.MeteringOriginId) + if err != nil { + log.Error(). + Err(err). + Str("controllerId", metering.Attributes.Origin.MeteringOriginId). + Str("meteringId", metering.MeteringId). + Msg("No controller found for metering ") + continue + } + itemName = controller.Attributes.Name + } else { + // apartment + itemName = "apartment" + } + + meteringValue := meteringStatusLookup[metering.MeteringId] + + var measurement = "" + if metering.Attributes.Unit == "W" { + measurement = powerConsumption + } else if metering.Attributes.Unit == "Wh" { + measurement = energyMeter + } else { + log.Warn().Str("unit", metering.Attributes.Unit).Msg("Unknown unit") + } + + valueStr := fmt.Sprintf("%.0f", meteringValue.Attributes.Value) + if err := c.mqttClient.Publish(meteringTopic(itemName, measurement), valueStr); err != nil { + log.Error(). + Err(err). + Str("itemName", itemName). + Str("unit", metering.Attributes.Unit). + Msg("Error updating metering") + continue + } + } +} + +func meteringTopic(itemName string, measurement string) string { + return path.Join(meterings, itemName, measurement, mqtt.State) +} + +func (c *MeteringsModule) GetHomeAssistantEntities() ([]homeassistant.DiscoveryConfig, error) { + configs := []homeassistant.DiscoveryConfig{} + + controllers, err := c.dsRegistry.GetControllers() + if err != nil { + return nil, err + } + + // manually add apartment + controllers = append(controllers, digitalstrom.Controller{ + ControllerId: "apartment", + Attributes: digitalstrom.ControllerAttributes{ + Name: "apartment", + TechName: "apartment", + }, + }) + + for _, controller := range controllers { + powerConfig := homeassistant.DiscoveryConfig{ + Domain: homeassistant.Sensor, + DeviceId: controller.ControllerId, + ObjectId: "power", + Config: &homeassistant.SensorConfig{ + BaseConfig: homeassistant.BaseConfig{ + Device: homeassistant.Device{ + Identifiers: []string{controller.ControllerId}, + Model: controller.Attributes.TechName, + Name: controller.Attributes.Name, + }, + Name: "Power " + controller.Attributes.Name, + UniqueId: controller.ControllerId + "_power", + }, + StateTopic: c.mqttClient.GetFullTopic( + meteringTopic(controller.Attributes.Name, powerConsumption)), + UnitOfMeasurement: "W", + DeviceClass: "power", + Icon: "mdi:flash", + }, + } + configs = append(configs, powerConfig) + energyConfig := homeassistant.DiscoveryConfig{ + Domain: homeassistant.Sensor, + DeviceId: controller.ControllerId, + ObjectId: "energy", + Config: &homeassistant.SensorConfig{ + BaseConfig: homeassistant.BaseConfig{ + Device: homeassistant.Device{ + Identifiers: []string{controller.ControllerId}, + Model: controller.Attributes.TechName, + Name: controller.Attributes.Name, + }, + Name: "Energy " + controller.Attributes.Name, + UniqueId: controller.ControllerId + "_energy", + }, + StateTopic: c.mqttClient.GetFullTopic( + meteringTopic(controller.Attributes.Name, energyMeter)), + UnitOfMeasurement: "kWh", + DeviceClass: "energy", + StateClass: "total_increasing", + ValueTemplate: "{{ (value | float / (3600*1000)) | round(3) }}", + Icon: "mdi:lightning-bolt", + }, + } + configs = append(configs, energyConfig) + } + return configs, nil +} + +func NewMeteringsModule(mqttClient mqtt.Client, dsClient digitalstrom.Client, dsRegistry digitalstrom.Registry, config *config.Config) Module { + return &MeteringsModule{ + mqttClient: mqttClient, + dsClient: dsClient, + dsRegistry: dsRegistry, + } +} + +func init() { + Register("meterings", NewMeteringsModule) +} diff --git a/pkg/controller/modules/registry.go b/pkg/controller/modules/registry.go new file mode 100644 index 0000000..3956b6c --- /dev/null +++ b/pkg/controller/modules/registry.go @@ -0,0 +1,24 @@ +package modules + +import ( + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/digitalstrom" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/mqtt" +) + +// Interface for the different modules being +type Module interface { + Start() error + Stop() error +} + +type ModuleBuilder func(mqtt.Client, digitalstrom.Client, digitalstrom.Registry, *config.Config) Module + +// Register stores a builder function into the registry for external access. +// Register() can be called from init() on a module in this package and will +// automatically register a module. +func Register(name string, builder ModuleBuilder) { + Modules[name] = builder +} + +var Modules = map[string]ModuleBuilder{} diff --git a/pkg/digitalstrom/api-key.go b/pkg/digitalstrom/api-key.go new file mode 100644 index 0000000..bb95d68 --- /dev/null +++ b/pkg/digitalstrom/api-key.go @@ -0,0 +1,167 @@ +package digitalstrom + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "github.com/rs/zerolog/log" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +/** + * This file is here only to generate a new API key for the integration. It should never be used for another purpose. + */ + +type NewApiKeyRequest struct { + Data NewApiKeyRequestData `json:"data"` +} + +type NewApiKeyRequestData struct { + Type string `json:"type"` + Attributes NewApiKeyRequestAttributes `json:"attributes"` +} +type NewApiKeyRequestAttributes struct { + Name string `json:"name"` +} + +func GetApiKey(host string, port int, user string, password string, integrationName string) (string, error) { + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + token, err := getToken(httpClient, host, port, user, password) + if err != nil { + return "", err + } + + apiKey, err := getApiKey(httpClient, host, port, token, integrationName) + if err != nil { + return "", err + } + + return apiKey, nil +} + +func getToken(httpClient http.Client, host string, port int, user string, password string) (string, error) { + params := url.Values{} + params.Set("user", user) + params.Set("password", password) + + tokenResponse, _, err := doRequest(httpClient, http.MethodGet, host, port, "json/system/login", params, nil) + if err != nil { + return "", fmt.Errorf("error when loging in: %w", err) + } + + if val, ok := tokenResponse["ok"]; ok { + if !val.(bool) { + return "", errors.New("error with DigitalStrom API: " + tokenResponse["message"].(string)) + } + } else { + return "", errors.New("no 'ok' field present, cannot check request") + } + + var token string + if val, ok := tokenResponse["result"]; ok { + result := val.(map[string]interface{}) + if t, ok := result["token"]; ok { + token = t.(string) + } else { + return "", errors.New("no 'token' field present, cannot get token from request") + } + } else { + return "", errors.New("no 'token' field present, cannot get token from request") + } + return token, nil +} + +func getApiKey(httpClient http.Client, host string, port int, token string, integrationName string) (string, error) { + params := url.Values{} + params.Set("token", token) + + requestBody := &NewApiKeyRequest{ + Data: NewApiKeyRequestData{ + Type: "applicationToken", + Attributes: NewApiKeyRequestAttributes{ + Name: integrationName, + }, + }, + } + + _, apiKeyResponse, err := doRequest(httpClient, http.MethodPost, host, port, "/api/v1/apartment/applicationTokens", params, requestBody) + if err != nil { + return "", fmt.Errorf("error when getting api key: %w", err) + } + + var apiKey string + if apiKeyResponse.StatusCode == 201 { + apiKey = apiKeyResponse.Header.Get("Location") + } else { + return "", errors.New(fmt.Sprintf("error creating api key, status was: %d", apiKeyResponse.StatusCode)) + } + + return apiKey, nil +} + +func doRequest(httpClient http.Client, method string, host string, port int, path string, params url.Values, body interface{}) (map[string]interface{}, *http.Response, error) { + var bodyReader io.Reader = nil + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + bodyReader = strings.NewReader(string(jsonBody)) + } + callUrl := "https://" + host + ":" + strconv.Itoa(port) + "/" + path + if params != nil && len(params) > 0 { + callUrl = callUrl + "?" + params.Encode() + } + + request, err := http.NewRequest(method, callUrl, bodyReader) + if err != nil { + return nil, nil, fmt.Errorf("error building the request: %w", err) + } + resp, err := httpClient.Do(request) + if err != nil { + return nil, nil, fmt.Errorf("error doing the request: %w", err) + } + if resp.Body != nil { + defer resp.Body.Close() + } + + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, nil, fmt.Errorf("error reading the request: %w", err) + } + + if resp.StatusCode >= 300 { + return nil, nil, fmt.Errorf("error response from server, httpStatus=%d: %s", resp.StatusCode, responseBody) + } + + log.Debug(). + Str("url", callUrl). + Str("status", resp.Status). + Msg("Response received") + log.Trace(). + Str("body", string(responseBody)). + Msg("Response body") + + if len(responseBody) > 0 { + var jsonResponse map[string]interface{} + err = json.Unmarshal(responseBody, &jsonResponse) + if err != nil { + return nil, nil, fmt.Errorf("error parsing response for token: %w", err) + } + return jsonResponse, resp, nil + } + + return nil, resp, nil +} diff --git a/pkg/digitalstrom/api.go b/pkg/digitalstrom/api.go new file mode 100644 index 0000000..978ad59 --- /dev/null +++ b/pkg/digitalstrom/api.go @@ -0,0 +1,287 @@ +package digitalstrom + +// Appartment structure +type Apartment struct { + ApartmentId string `mapstructure:"id"` + Attributes ApartmentAttributes `mapstructure:"attributes"` + Included ApartmentIncluded `mapstructure:"included"` +} + +type ApartmentAttributes struct { + Name string `mapstructure:"name"` + Zones []string `mapstructure:"zones"` + Devices []string `mapstructure:"dsDevices"` + Clusters []string `mapstructure:"clusters"` +} + +type ApartmentIncluded struct { + Installation Installation `mapstructure:"installation"` + Devices []Device `mapstructure:"dsDevices"` + Submodules []Submodule `mapstructure:"submodules"` + FunctionBlocks []FunctionBlock `mapstructure:"functionBlocks"` + Zones []Zone `mapstructure:"zones"` + //Scenarios []Scenarios `mapstructure:"scenarios"` + // floors + // clusters + // dsServer + Controllers []Controller `mapstructure:"controllers"` + // apiRevision + Meterings []Metering `mapstructure:"meterings"` + // userDefinedStates + // applications +} + +type Installation struct { + InstallationId string `mapstructure:"id"` + Type string `mapstructure:"type"` + Attributes InstallationAttribute `mapstructure:"attributes"` +} + +type InstallationAttribute struct { + CountryCode string `mapstructure:"countryCode"` + City string `mapstructure:"city"` + Timezone string `mapstructure:"timezone"` +} + +type Device struct { + DeviceId string `mapstructure:"id"` + Attributes DeviceAttributes `mapstructure:"attributes"` +} + +type DeviceAttributes struct { + Name string `mapstructure:"name"` + Dsid string `mapstructure:"dsid"` + DisplayId string `mapstructure:"displayId"` + Present bool `mapstructure:"present"` + Submodules []string `mapstructure:"submodules"` + Zone string `mapstructure:"zone"` + Scenarios []string `mapstructure:"scenarios"` + Controller string `mapstructure:"controller"` +} + +type Submodule struct { + SubmoduleId string `mapstructure:"id"` + Attributes SubmoduleAttributes `mapstructure:"attributes"` +} + +type SubmoduleAttributes struct { + Name string `mapstructure:"name"` + TechnicalName string `mapstructure:"technicalName"` + DeviceId string `mapstructure:"dsDevice"` + FunctionBlocks []string `mapstructure:"functionBlocks"` + Zone string `mapstructure:"zone"` + Application SubmoduleApplication `mapstructure:"application"` + Scenarios []string `mapstructure:"scenarios"` + Controller string `mapstructure:"controller"` +} + +type FunctionBlock struct { + FunctionBlockId string `mapstructure:"id"` + Attributes FunctionBlockAttributes `mapstructure:"attributes"` +} + +type FunctionBlockAttributes struct { + Name string `mapstructure:"name"` + TechnicalName string `mapstructure:"technicalName"` + Active bool `mapstructure:"active"` + Outputs []Output `mapstructure:"outputs"` + ButtonInputs []ButtonInputs `mapstructure:"buttonInputs"` + SensorInputs []SensorInputs `mapstructure:"sensorInputs"` + Submodule string `mapstructure:"submodule"` + DeviceAdapter string `mapstructure:"deviceAdapter"` +} + +type Output struct { + OutputId string `mapstructure:"id"` + Attributes OutputAttributes `mapstructure:"attributes"` +} + +type OutputAttributes struct { + TechnicalName string `mapstructure:"technicalName"` + Type OutputType `mapstructure:"type"` + Function string `mapstructure:"function"` + Mode OutputMode `mapstructure:"mode"` + Min float64 `mapstructure:"min"` + Max float64 `mapstructure:"max"` + Resolution float64 `mapstructure:"resolution"` +} + +type ButtonInputs struct { + ButtonInputId string `mapstructure:"id"` + Attributes ButtonInputsAttributes `mapstructure:"attributes"` +} + +type ButtonInputsAttributes struct { + TechnicalName string `mapstructure:"technicalName"` + Type ButtonInputType `mapstructure:"type"` + Mode ButtonInputMode `mapstructure:"mode"` +} + +type SensorInputs struct { + SensorInputId string `mapstructure:"id"` + Attributes SensorInputsAttributes `mapstructure:"attributes"` +} + +type SensorInputsAttributes struct { + TechnicalName string `mapstructure:"technicalName"` + Type SensorInputType `mapstructure:"type"` + Mode SensorInputUsage `mapstructure:"usage"` + Min float64 `mapstructure:"min"` + Max float64 `mapstructure:"max"` + Resolution float64 `mapstructure:"resolution"` +} + +// Zone representation. +type Zone struct { + ZoneId string `mapstructure:"id"` + Attributes ZoneAttributes `mapstructure:"attributes"` +} + +type ZoneAttributes struct { + Name string `mapstructure:"name"` + Floor string `mapstructure:"floor"` + OrderId float64 `mapstructure:"orderId"` + Submodules []string `mapstructure:"submodules"` + Applications []string `mapstructure:"applications"` + ApplicationTypes []string `mapstructure:"applicationTypes"` + ApplicationDetails []ApplicationDetail `mapstructure:"applicationDetails"` +} + +type ApplicationDetail struct { + ApplicationDetailId string `mapstructure:"id"` + Areas []Area `mapstructure:"areas"` +} + +type Area struct { + AreaId string `mapstructure:"id"` + Name string `mapstructure:"name"` +} + +type Scenarios struct { + ScenarioId string `mapstructure:"id"` + Type ScenarioType `mapstructure:"type"` + Attributes ScenarioAttributes `mapstructure:"attributes"` +} + +type ScenarioAttributes struct { + Name string `mapstructure:"name"` + ActionId string `mapstructure:"actionId"` + Context string `mapstructure:"context"` + Submodules []string `mapstructure:"submodules"` + Devices []string `mapstructure:"dsDevices"` + Zone string `mapstructure:"zone"` + Application ScenarioApplication `mapstructure:"application"` +} + +type Controller struct { + ControllerId string `mapstructure:"id"` + Attributes ControllerAttributes `mapstructure:"attributes"` +} + +type ControllerAttributes struct { + Name string `mapstructure:"name"` + TechName string `mapstructure:"technicalName"` +} + +type Metering struct { + MeteringId string `mapstructure:"id"` + Attributes MeteringAttributes `mapstructure:"attributes"` +} + +type MeteringAttributes struct { + Unit string `mapstructure:"unit"` + TechnicalName string `mapstructure:"technicalName"` + Origin MeteringOrigin `mapstructure:"origin"` +} + +type MeteringOrigin struct { + MeteringOriginId string `mapstructure:"id"` + Type MeteringType `mapstructure:"type"` +} + +// Meterings +type Meterings struct { + Meterings []Metering `mapstructure:"meterings"` +} + +type MeteringValues struct { + Values []MeteringValue `mapstructure:"values"` +} +type MeteringValue struct { + Id string `mapstructure:"id"` + Attributes MeteringValueAttributes `mapstructure:"attributes"` +} + +type MeteringValueAttributes struct { + Value float64 `json:"value"` +} + +// Status + +type ApartmentStatus struct { + ApartmentId string `mapstructure:"id"` + Included ApartmentStatusIncluded `mapstructure:"included"` +} + +type ApartmentStatusIncluded struct { + Devices []DeviceStatus `mapstructure:"dsDevices"` +} + +type DeviceStatus struct { + DeviceId string `mapstructure:"id"` + Type string `mapstructure:"type"` + Attributes DeviceStatusAttributes `mapstructure:"attributes"` +} + +type DeviceStatusAttributes struct { + FunctionBlocks []struct { + FunctionBlockId string `mapstructure:"id"` + Outputs []OutputValue `mapstructure:"outputs,omitempty"` + } `mapstructure:"functionBlocks"` + Submodules []struct { + SubmoduleId string `mapstructure:"id"` + OperationsLocked bool `mapstructure:"operationsLocked"` + } `mapstructure:"submodules"` + States []struct { + StateId string `mapstructure:"id"` + Value string `mapstructure:"value"` + } `mapstructure:"states,omitempty"` +} + +type OutputValue struct { + OutputId string `mapstructure:"id"` + Value float64 `mapstructure:"value"` + Status OutputValueStatus `mapstructure:"status"` + TargetValue float64 `mapstructure:"targetValue"` + Level int `mapstructure:"level,omitempty"` +} + +type SetOutputValue struct { + Op SetOutputValueOperation `json:"op"` + Path string `json:"path"` + Value string `json:"value"` +} + +// Websocket + +type NotificationType string + +const ( + NotificationTypeApartmentStructureChanged NotificationType = "apartmentStructureChanged" + NotificationTypeApartmentStatusChanged NotificationType = "apartmentStatusChanged" +) + +type WebsocketInitMessage struct { + Protocol string `json:"protocol"` + Version uint32 `json:"version"` +} + +type WebsocketNotification struct { + Type int `json:"type"` + Target string `json:"target"` + Arguments []WebsocketNotificationArgument `json:"arguments"` +} + +type WebsocketNotificationArgument struct { + Type NotificationType `json:"type"` +} diff --git a/pkg/digitalstrom/client.go b/pkg/digitalstrom/client.go new file mode 100644 index 0000000..c49447b --- /dev/null +++ b/pkg/digitalstrom/client.go @@ -0,0 +1,273 @@ +package digitalstrom + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "github.com/gorilla/websocket" + "github.com/mitchellh/mapstructure" + "github.com/rs/zerolog/log" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +type NotificationCallback func(notification WebsocketNotification) + +// Client is the interface definition as used by this library, the +// interface is primarily to allow mocking tests. +type Client interface { + // Connect will perform login on the DigitalStrom server. + Connect() error + // Disconnect from the server by closing all idle connections, closing the + // event loop if running and unsubscribing from the server. + Disconnect() error + + // Start of the API calls to DigitalStrom. + + GetApartment() (*Apartment, error) + GetApartmentStatus() (*ApartmentStatus, error) + GetMeterings() (*Meterings, error) + GetMeteringStatus() (*MeteringValues, error) + + // DeviceSetOutputValue Sets a list of outputs to a give values + DeviceSetOutputValue(deviceId string, functionBlockId string, outputId string, value float64) error + + NotificationSubscribe(id string, callback NotificationCallback) error + NotificationUnsubscribe(id string) error +} + +// client implements the DigitalStrom interface. +// Clients are safe for concurrent use by multiple goroutines. +type client struct { + httpClient *http.Client + options ClientOptions + websocketConnection *websocket.Conn + + notificationCallbacks map[string]NotificationCallback +} + +// NewClient will create a DigitalStrom client with all the options specified in +// the provided ClientOptions. The client must have the Connect() method called +// on it before it may be used. +func NewClient(options *ClientOptions) Client { + return &client{ + httpClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + options: *options, + notificationCallbacks: map[string]NotificationCallback{}, + } +} + +func (c *client) Connect() error { + // TODO handle reconnection + websocketHost := "ws://" + c.options.Host + ":8090/api/v1/apartment/notifications" + log.Trace().Str("host", websocketHost).Msg("Connecting to websocket") + headers := http.Header{} + headers.Add("Authorization", "Bearer "+c.options.ApiKey) + ws, _, err := websocket.DefaultDialer.Dial(websocketHost, headers) + if err != nil { + return fmt.Errorf("unable to connecting to notification websocket: %w", err) + } + c.websocketConnection = ws + // initiate event stream + err = c.websocketConnection.WriteJSON(WebsocketInitMessage{ + Protocol: "json", + Version: 1, + }) + if err != nil { + return fmt.Errorf("error writing to websocket: %w", err) + } + + go func() { + firstMessage := true + for { + var notification WebsocketNotification + err := c.websocketConnection.ReadJSON(¬ification) + if err != nil { + log.Error().Err(err).Msg("Websocket reading error") + break + } else if notification.Arguments == nil || len(notification.Arguments) == 0 { + if !firstMessage { + log.Warn().Msg("No argument received in notification") + } + } else { + for _, callback := range c.notificationCallbacks { + callback(notification) + } + log.Trace().Str("target", notification.Target).Str("type", string(notification.Arguments[0].Type)).Msg("Websocket received") + } + firstMessage = false + } + log.Warn().Msg("Closing websocket reader") + }() + + return nil +} + +// Disconnect stops all the ongoing calls and unsubscribe from the notification websocket +func (c *client) Disconnect() error { + + c.httpClient.CloseIdleConnections() + c.websocketConnection.Close() + + return nil +} + +func (c *client) GetApartment() (*Apartment, error) { + response, err := c.getRequest("api/v1/apartment", nil) + return wrapApiResponse[Apartment](response, err) +} + +func (c *client) GetApartmentStatus() (*ApartmentStatus, error) { + response, err := c.getRequest("api/v1/apartment/status", nil) + return wrapApiResponse[ApartmentStatus](response, err) +} + +func (c *client) GetMeterings() (*Meterings, error) { + response, err := c.getRequest("api/v1/apartment/meterings", nil) + return wrapApiResponse[Meterings](response, err) +} + +func (c *client) GetMeteringStatus() (*MeteringValues, error) { + response, err := c.getRequest("api/v1/apartment/meterings/values", nil) + return wrapApiResponse[MeteringValues](response, err) +} + +func (c *client) DeviceSetOutputValue(deviceId string, functionBlockId string, outputId string, value float64) error { + var contents []SetOutputValue + contents = append(contents, SetOutputValue{ + Op: SetOutputValueOperationReplace, + Path: fmt.Sprintf("/functionBlocks/%s/outputs/%s/value", functionBlockId, outputId), + Value: fmt.Sprintf("%.0f", value), + }) + + path := fmt.Sprintf("api/v1/apartment/dsDevices/%s/status", deviceId) + return c.patchRequest(path, contents) +} + +func (c *client) NotificationSubscribe(id string, callback NotificationCallback) error { + _, exists := c.notificationCallbacks[id] + if exists { + return errors.New("Notification callback with id " + id + " already exists") + } + c.notificationCallbacks[id] = callback + return nil +} + +func (c *client) NotificationUnsubscribe(id string) error { + _, exists := c.notificationCallbacks[id] + if !exists { + return errors.New("Notification callback with id " + id + " does not exist") + } + delete(c.notificationCallbacks, id) + return nil +} + +func (c *client) doRequest(method string, path string, params url.Values, body interface{}) ([]byte, error) { + var bodyReader io.Reader = nil + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(string(jsonBody)) + } + callUrl := "https://" + c.options.Host + ":" + strconv.Itoa(c.options.Port) + "/" + path + if params != nil && len(params) > 0 { + callUrl = callUrl + "?" + params.Encode() + } + + request, err := http.NewRequest(method, callUrl, bodyReader) + request.Header.Set("Authorization", "Bearer "+c.options.ApiKey) + if err != nil { + return nil, fmt.Errorf("error building the request: %w", err) + } + resp, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("error doing the request: %w", err) + } + if resp.Body != nil { + defer resp.Body.Close() + } + + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("error reading the request: %w", err) + } + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("error response from server, httpStatus=%d: %s", resp.StatusCode, responseBody) + } + + log.Debug(). + Str("url", callUrl). + Str("status", resp.Status). + Msg("Response received") + log.Trace(). + Str("body", string(responseBody)). + Msg("Response body") + + return responseBody, nil +} + +func (c *client) patchRequest(path string, body interface{}) error { + _, err := c.doRequest(http.MethodPatch, path, nil, body) + return err +} + +func (c *client) getRequest(path string, params url.Values) (interface{}, error) { + body, err := c.doRequest(http.MethodGet, path, params, nil) + if err != nil { + return nil, err + } + + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return nil, fmt.Errorf("error parsing response for path %s: %w", path, err) + } + + if data, ok := jsonResponse["data"]; ok { + return data, nil + } else { + // TODO maybe handle error + log.Panic().Str("response", string(body)).Msg("no 'data' field present, cannot get data from request") + return nil, errors.New("no 'data' field present, cannot get data from request") + } +} + +// wrapApiResponse takes a generic response interface and maps it to the given +// structure. This is used to decode the responses from the apiCall responses +// into explicit structs. +func wrapApiResponse[T any](response interface{}, err error) (*T, error) { + // Handle original error coming from the response. + if err != nil { + return nil, err + } + + // Decode the response into the given struct type. + res := new(T) + config := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: res, + WeaklyTypedInput: true, + ErrorUnset: false, + } + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return nil, fmt.Errorf("error building decored: %w", err) + } + if err = decoder.Decode(response); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + return res, nil +} diff --git a/pkg/digitalstrom/helpers.go b/pkg/digitalstrom/helpers.go new file mode 100644 index 0000000..2b5e223 --- /dev/null +++ b/pkg/digitalstrom/helpers.go @@ -0,0 +1,50 @@ +package digitalstrom + +import "strings" + +// Returns the device type given its hardware technicalName +func (functionBlock *FunctionBlock) DeviceType() DeviceType { + switch { + case strings.HasPrefix(functionBlock.Attributes.TechnicalName, "GE"): + return DeviceTypeLight + case strings.HasPrefix(functionBlock.Attributes.TechnicalName, "GR"): + return DeviceTypeBlind + case strings.HasPrefix(functionBlock.Attributes.TechnicalName, "SW"): + return DeviceTypeJoker + default: + return DeviceTypeUnknown + } +} + +// Properties a device can have and helps us better understand how it works. +// Note that all these properties are inferred from the attributes in the Device +// structure. +type DeviceProperties struct { + Dimmable bool + PositionChannel string + TiltChannel string +} + +// Returns some inferred properties from the device. +func (functionBlock *FunctionBlock) Properties() DeviceProperties { + positionChannel := "" + tiltChannel := "" + dimmable := false + for _, outputs := range functionBlock.Attributes.Outputs { + if strings.Contains(outputs.OutputId, "Angle") { + tiltChannel = outputs.OutputId + } + if strings.Contains(outputs.OutputId, "Position") { + positionChannel = outputs.OutputId + } + if outputs.Attributes.Mode == OutputModeGradual { + dimmable = true + } + } + + return DeviceProperties{ + Dimmable: dimmable, + PositionChannel: positionChannel, + TiltChannel: tiltChannel, + } +} diff --git a/pkg/digitalstrom/options.go b/pkg/digitalstrom/options.go new file mode 100644 index 0000000..302cca4 --- /dev/null +++ b/pkg/digitalstrom/options.go @@ -0,0 +1,49 @@ +package digitalstrom + +import ( + "math/rand" + "time" +) + +// ClientOptions contains configurable options for a Digitalstrom Client. +type ClientOptions struct { + Host string + Port int + ApiKey string +} + +// NewClientOptions will create a new ClientClientOptions type with some +// default values. +// +// Host: dss.local +// Port: 8080 +func NewClientOptions() *ClientOptions { + // Random generate subscriptionId in order to not have collisions of + // multiple instances running at the same time. + rand.Seed(time.Now().UnixNano()) + + return &ClientOptions{ + Host: "dss.local", + Port: 8080, + ApiKey: "", + } +} + +// SetHost will set the address for the DigitalStrom server to connect. +func (o *ClientOptions) SetHost(host string) *ClientOptions { + o.Host = host + return o +} + +// SetPort will set the port for the DigitalStrom server to connect. +func (o *ClientOptions) SetPort(port int) *ClientOptions { + o.Port = port + return o +} + +// SetUsername will set the username to be used by this client when connecting +// to the DigitalStrom server. +func (o *ClientOptions) SetApiKey(u string) *ClientOptions { + o.ApiKey = u + return o +} diff --git a/pkg/digitalstrom/registry.go b/pkg/digitalstrom/registry.go new file mode 100644 index 0000000..be34638 --- /dev/null +++ b/pkg/digitalstrom/registry.go @@ -0,0 +1,282 @@ +package digitalstrom + +import ( + "errors" + "github.com/rs/zerolog/log" + "sync" +) + +type DeviceChangeCallback func(deviceId string, outputId string, oldValue float64, newValue float64) + +// Registry The registry hold the current structure of the appartement and the latest known state +type Registry interface { + Start() error + + Stop() error + + GetDevices() ([]Device, error) + + GetDevice(deviceId string) (Device, error) + + GetFunctionBlockForDevice(deviceId string) (FunctionBlock, error) + + GetOutputsOfDevice(deviceId string) ([]Output, error) + GetOutputValuesOfDevice(deviceId string) ([]OutputValue, error) + + GetControllers() ([]Controller, error) + GetControllerById(controllerId string) (Controller, error) + GetMeterings() ([]Metering, error) + + DeviceChangeSubscribe(deviceId string, callback DeviceChangeCallback) error + DeviceChangeUnsubscribe(deviceId string) error +} + +type registry struct { + digitalstromClient Client + + apartment *Apartment + apartmentStatus *ApartmentStatus + meterings *Meterings + + controllersLookup map[string]Controller + devicesLookup map[string]Device + submoduleLookup map[string]Submodule + functionBlocksLookup map[string]FunctionBlock + + deviceChangeCallbacks map[string]DeviceChangeCallback + + registryLoading sync.Mutex +} + +func NewRegistry(digitalstromClient Client) Registry { + return ®istry{ + digitalstromClient: digitalstromClient, + deviceChangeCallbacks: make(map[string]DeviceChangeCallback), + } +} + +func (r *registry) Start() error { + if err := r.updateApartment(); err != nil { + return err + } + if err := r.updateMeterings(); err != nil { + return err + } + err := r.updateApartmentStatusAndFireChangeEvents() + if err != nil { + return err + } + callback := func(notification WebsocketNotification) { + // TODO handle structure changes + if err := r.updateApartmentStatusAndFireChangeEvents(); err != nil { + log.Err(err).Msg("Error updating apartment status") + } + } + if err := r.digitalstromClient.NotificationSubscribe("registry", callback); err != nil { + return err + } + return nil +} + +func (r *registry) Stop() error { + return nil +} + +func (r *registry) GetDevices() ([]Device, error) { + return r.apartment.Included.Devices, nil +} + +func (r *registry) GetDevice(deviceId string) (Device, error) { + device, ok := r.devicesLookup[deviceId] + if ok { + return device, nil + } + return Device{}, errors.New("No device found with id " + deviceId) +} + +func (r *registry) GetOutputsOfDevice(deviceId string) ([]Output, error) { + device, err := r.GetDevice(deviceId) + if err != nil { + return nil, err + } + + outputs := []Output{} + for _, submoduleId := range device.Attributes.Submodules { + submodule := r.submoduleLookup[submoduleId] + for _, functionBlockId := range submodule.Attributes.FunctionBlocks { + functionBlock := r.functionBlocksLookup[functionBlockId] + for _, output := range functionBlock.Attributes.Outputs { + outputs = append(outputs, output) + } + } + } + + return outputs, nil +} + +func (r *registry) GetOutputValuesOfDevice(deviceId string) ([]OutputValue, error) { + outputs := []OutputValue{} + for _, device := range r.apartmentStatus.Included.Devices { + if device.DeviceId == deviceId { + for _, functionBlockValue := range device.Attributes.FunctionBlocks { + for _, outputValue := range functionBlockValue.Outputs { + outputs = append(outputs, outputValue) + } + } + } + } + + return outputs, nil +} + +func (r *registry) GetFunctionBlockForDevice(deviceId string) (FunctionBlock, error) { + device, err := r.GetDevice(deviceId) + if err != nil { + return FunctionBlock{}, err + } + + var functionBlocks []FunctionBlock + + for _, submoduleId := range device.Attributes.Submodules { + submodule := r.submoduleLookup[submoduleId] + for _, functionBlockId := range submodule.Attributes.FunctionBlocks { + functionBlock := r.functionBlocksLookup[functionBlockId] + functionBlocks = append(functionBlocks, functionBlock) + } + } + + length := len(functionBlocks) + if length == 0 { + return FunctionBlock{}, errors.New("Multiple function blocks found for device " + deviceId) + } + if length > 1 { + return FunctionBlock{}, errors.New("No function block found for device " + deviceId) + } + return functionBlocks[0], nil +} + +func (r *registry) GetControllers() ([]Controller, error) { + return r.apartment.Included.Controllers, nil +} + +func (r *registry) GetControllerById(controllerId string) (Controller, error) { + controller, ok := r.controllersLookup[controllerId] + if ok { + return controller, nil + } + return Controller{}, errors.New("No controller found with id " + controllerId) +} + +func (r *registry) GetMeterings() ([]Metering, error) { + return r.meterings.Meterings, nil +} + +func (r *registry) updateApartment() error { + r.registryLoading.Lock() + defer r.registryLoading.Unlock() + + apartment, err := r.digitalstromClient.GetApartment() + if err != nil { + return err + } + + r.apartment = apartment + + r.controllersLookup = make(map[string]Controller) + r.devicesLookup = make(map[string]Device) + r.submoduleLookup = make(map[string]Submodule) + r.functionBlocksLookup = make(map[string]FunctionBlock) + + // Create lookup tables for fast access. + for _, controller := range apartment.Included.Controllers { + r.controllersLookup[controller.ControllerId] = controller + } + for _, device := range apartment.Included.Devices { + r.devicesLookup[device.DeviceId] = device + } + for _, submodule := range apartment.Included.Submodules { + r.submoduleLookup[submodule.SubmoduleId] = submodule + } + for _, functionBlock := range apartment.Included.FunctionBlocks { + r.functionBlocksLookup[functionBlock.FunctionBlockId] = functionBlock + } + + return nil +} + +func (r *registry) updateMeterings() error { + r.registryLoading.Lock() + defer r.registryLoading.Unlock() + + meterings, err := r.digitalstromClient.GetMeterings() + if err != nil { + return err + } + + r.meterings = meterings + + return nil +} + +func (r *registry) DeviceChangeSubscribe(deviceId string, callback DeviceChangeCallback) error { + _, exists := r.deviceChangeCallbacks[deviceId] + if exists { + return errors.New("Callback already registered for device " + deviceId) + } + r.deviceChangeCallbacks[deviceId] = callback + return nil +} + +func (r *registry) DeviceChangeUnsubscribe(deviceId string) error { + _, exists := r.deviceChangeCallbacks[deviceId] + if !exists { + return errors.New("No callback registered for device " + deviceId) + } + delete(r.deviceChangeCallbacks, deviceId) + return nil +} + +func (r *registry) updateApartmentStatusAndFireChangeEvents() error { + oldStatus := r.apartmentStatus + newStatus, err := r.digitalstromClient.GetApartmentStatus() + if err != nil { + return err + } + r.apartmentStatus = newStatus + + if oldStatus != nil { + // Check diff and broadcast events + + oldStatusLookup := make(map[string]map[string]OutputValue) + for _, device := range oldStatus.Included.Devices { + oldStatusLookup[device.DeviceId] = make(map[string]OutputValue) + for _, functionBlock := range device.Attributes.FunctionBlocks { + for _, output := range functionBlock.Outputs { + oldStatusLookup[device.DeviceId][output.OutputId] = output + } + } + } + + for _, device := range newStatus.Included.Devices { + for _, functionBlock := range device.Attributes.FunctionBlocks { + for _, newOutput := range functionBlock.Outputs { + oldOutput := oldStatusLookup[device.DeviceId][newOutput.OutputId] + if oldOutput.TargetValue != newOutput.TargetValue { + log.Info(). + Str("DeviceId", device.DeviceId). + Str("Output", newOutput.OutputId). + Float64("oldValue", oldOutput.TargetValue). + Float64("newValue", newOutput.TargetValue). + Msg("Output value changed") + + callback, exists := r.deviceChangeCallbacks[device.DeviceId] + if exists { + callback(device.DeviceId, newOutput.OutputId, oldOutput.TargetValue, newOutput.TargetValue) + } + } + } + } + } + } + return nil +} diff --git a/pkg/digitalstrom/types.go b/pkg/digitalstrom/types.go new file mode 100644 index 0000000..0405eec --- /dev/null +++ b/pkg/digitalstrom/types.go @@ -0,0 +1,212 @@ +package digitalstrom + +type DeviceType string + +const ( + DeviceTypeLight DeviceType = "GE" + DeviceTypeBlind DeviceType = "GR" + DeviceTypeJoker DeviceType = "SW" + DeviceTypeUnknown DeviceType = "Unknown" +) + +type Action string + +const ( + ActionMoveUp Action = "app.moveUp" + ActionMoveDown Action = "app.moveDown" + ActionStepUp Action = "app.stepUp" + ActionStepDown Action = "app.stepDown" + ActionSunProtection Action = "app.sunProtection" + ActionStop Action = "app.stop" +) + +type ChannelType string + +const ( + ChannelTypeBrightness ChannelType = "brightness" + ChannelTypeHue ChannelType = "hue" +) + +// Deprecated: use new API instead +type EventType string + +const ( + EventTypeCallScene EventType = "callScene" + EventTypeUndoScene EventType = "undoScene" + EventTypeButtonClick EventType = "buttonClick" + EventTypeDeviceSensor EventType = "deviceSensorEvent" + EventTypeRunning EventType = "running" + EventTypeModelReady EventType = "model_ready" + EventTypeDsMeterReady EventType = "dsMeter_ready" +) + +type SubmoduleApplication string + +const ( + SubmoduleTypeLights SubmoduleApplication = "lights" + SubmoduleTypeShades SubmoduleApplication = "shades" + SubmoduleTypeAwnings SubmoduleApplication = "awnings" + SubmoduleTypeAudio SubmoduleApplication = "audio" + SubmoduleTypeVideo SubmoduleApplication = "video" + SubmoduleTypeSecurity SubmoduleApplication = "security" + SubmoduleTypeAccess SubmoduleApplication = "access" + SubmoduleTypeHeating SubmoduleApplication = "heating" + SubmoduleTypeCooling SubmoduleApplication = "cooling" + SubmoduleTypeTemperature SubmoduleApplication = "temperature" + SubmoduleTypeVentilation SubmoduleApplication = "ventilation" + SubmoduleTypeRecirculation SubmoduleApplication = "recirculation" + SubmoduleTypeWindow SubmoduleApplication = "window" + SubmoduleTypeJoker SubmoduleApplication = "joker" +) + +type OutputType string + +const ( + OutputTypeLightBrightness OutputType = "lightBrightness" + OutputTypeLightHue OutputType = " lightHue" + OutputTypeLightSaturation OutputType = " lightSaturation" + OutputTypeLightTemperature OutputType = " lightTemperature" + OutputTypeLightCieX OutputType = " lightCieX" + OutputTypeLightCieY OutputType = " lightCieY" + OutputTypeShadePositionOutside OutputType = " shadePositionOutside" + OutputTypeShadePositionIndoor OutputType = " shadePositionIndoor" + OutputTypeShadeOpeningAngleOutside OutputType = " shadeOpeningAngleOutside" + OutputTypeShadeOpeningAngleIndoor OutputType = " shadeOpeningAngleIndoor" + OutputTypeShadeTransparency OutputType = " shadeTransparency" + OutputTypeAirFlowIntensity OutputType = " airFlowIntensity" + OutputTypeAirFlowDirection OutputType = " airFlowDirection" + OutputTypeAirFlapOpeningAngle OutputType = " airFlapOpeningAngle" + OutputTypeVentilationLouverPosition OutputType = " ventilationLouverPosition" + OutputTypeHeatingPower OutputType = " heatingPower" + OutputTypeCoolingCapacity OutputType = " coolingCapacity" + OutputTypeAudioVolume OutputType = " audioVolume" + OutputTypePowerState OutputType = " powerState" + OutputTypeVentilationSwingMode OutputType = " ventilationSwingMode" + OutputTypeVentilationAutoIntensity OutputType = " ventilationAutoIntensity" + OutputTypeWaterTemperature OutputType = " waterTemperature" + OutputTypeWaterFlowRate OutputType = " waterFlowRate" + OutputTypePowerLevel OutputType = " powerLevel" + OutputTypeVideoStation OutputType = " videoStation" + OutputTypeVideoInputSource OutputType = " videoInputSource" +) + +type OutputMode string + +const ( + OutputModeDisabled OutputMode = "disabled" + OutputModeSwitched OutputMode = "switched" + OutputModeGradual OutputMode = "gradual" + OutputModePositional OutputMode = "positional" + OutputModeInternal OutputMode = "internal" +) + +type ButtonInputType string + +const ( + ButtonInputTypeDevice ButtonInputType = "device" + ButtonInputTypeArea1 ButtonInputType = "area1" + ButtonInputTypeArea2 ButtonInputType = "area2" + ButtonInputTypeArea3 ButtonInputType = "area3" + ButtonInputTypeArea4 ButtonInputType = "area4" + ButtonInputTypeZone ButtonInputType = "zone" + ButtonInputTypeZone1 ButtonInputType = "zone1" + ButtonInputTypeZone2 ButtonInputType = "zone2" + ButtonInputTypeZone3 ButtonInputType = "zone3" + ButtonInputTypeZone4 ButtonInputType = "zone4" + ButtonInputTypeZonex1 ButtonInputType = "zonex1" + ButtonInputTypeZonex2 ButtonInputType = "zonex2" + ButtonInputTypeZonex3 ButtonInputType = "zonex3" + ButtonInputTypeZonex4 ButtonInputType = "zonex4" + ButtonInputTypeApplication ButtonInputType = "application" + ButtonInputTypeGroup ButtonInputType = "group" + ButtonInputTypeAppmode ButtonInputType = "appmode" +) + +type ButtonInputMode string + +const ( + ButtonInputModeDisabled ButtonInputMode = "disabled" + ButtonInputModeButton1way ButtonInputMode = "button1way" + ButtonInputModeButton2way ButtonInputMode = "button2way" +) + +type SensorInputType string + +const ( + SensorInputTypeTemperature SensorInputType = "temperature" + SensorInputTypeBrightness SensorInputType = "brightness" + SensorInputTypeHumidity SensorInputType = "humidity" + SensorInputTypeCarbonDioxide SensorInputType = "carbonDioxide" + SensorInputTypeTemperatureSetpoint SensorInputType = "temperatureSetpoint" + SensorInputTypeTemperatureControlValue SensorInputType = "temperatureControlValue" + SensorInputTypeEnergy SensorInputType = "energy" + SensorInputTypeEnergyCounter SensorInputType = "energyCounter" +) + +type SensorInputUsage string + +const ( + SensorInputUsageZone SensorInputUsage = "zone" + SensorInputUsageOutdoor SensorInputUsage = "outdoor" + SensorInputUsageSettings SensorInputUsage = "settings" + SensorInputUsageDevice SensorInputUsage = "device" + SensorInputUsageDeviceLastRun SensorInputUsage = "deviceLastRun" + SensorInputUsageDeviceAverage SensorInputUsage = "deviceAverage" +) + +type ScenarioType string + +const ( + ScenarioTypeApplicationZoneScenario ScenarioType = "applicationZoneScenario" + ScenarioTypeDeviceScenario ScenarioType = "deviceScenario" + ScenarioTypeUserDefinedAction ScenarioType = "userDefinedAction" +) + +type ScenarioApplication string + +const ( + ScenarioApplicationLights ScenarioApplication = "lights" + ScenarioApplicationShades ScenarioApplication = "shades" + ScenarioApplicationAwnings ScenarioApplication = "awnings" + ScenarioApplicationAudio ScenarioApplication = "audio" + ScenarioApplicationVideo ScenarioApplication = "video" + ScenarioApplicationSecurity ScenarioApplication = "security" + ScenarioApplicationAccess ScenarioApplication = "access" + ScenarioApplicationHeating ScenarioApplication = "heating" + ScenarioApplicationCooling ScenarioApplication = "cooling" + ScenarioApplicationTemperature ScenarioApplication = "temperature" + ScenarioApplicationVentilation ScenarioApplication = "ventilation" + ScenarioApplicationRecirculation ScenarioApplication = "recirculation" + ScenarioApplicationWindow ScenarioApplication = "window" + ScenarioApplicationJoker ScenarioApplication = "joker" +) + +type MeteringType string + +const ( + MeteringTypeApartment MeteringType = "apartment" + MeteringTypeController MeteringType = "controller" +) + +type OutputValueStatus string + +const ( + OutputValueStatusOk OutputValueStatus = "ok" + OutputValueStatusMoving OutputValueStatus = "moving" + OutputValueStatusDimming OutputValueStatus = "dimming" + OutputValueStatusOverload OutputValueStatus = "overload" + OutputValueStatusBlocked OutputValueStatus = "blocked" + OutputValueStatusError OutputValueStatus = "error" + OutputValueStatusStandby OutputValueStatus = "standby" +) + +type SetOutputValueOperation string + +const ( + SetOutputValueOperationAdd SetOutputValueOperation = "add" + SetOutputValueOperationRemove SetOutputValueOperation = "remove" + SetOutputValueOperationReplace SetOutputValueOperation = "replace" + SetOutputValueOperationMove SetOutputValueOperation = "move" + SetOutputValueOperationCopy SetOutputValueOperation = "copy" + SetOutputValueOperationTest SetOutputValueOperation = "test" +) diff --git a/pkg/homeassistant/config.go b/pkg/homeassistant/config.go new file mode 100644 index 0000000..c2934b1 --- /dev/null +++ b/pkg/homeassistant/config.go @@ -0,0 +1,147 @@ +package homeassistant + +// Interface to expose the endpoints to update any MQTT config needed by the +// Home Assistant discovery package. +type MqttConfig interface { + // Returns a pointer to the device object for any modification required. + GetDevice() *Device + // Adds a new entry on the list of Availability topics. + AddAvailability(Availability) MqttConfig + // Get name of the entity. + GetName() string + // Set name for the entity. + SetName(string) MqttConfig + // Set retain value. + SetRetain(bool) MqttConfig + // Set availability mode. + SetAvailabilityMode(string) MqttConfig +} + +// Structure that encapsulates the information for the device exposed in +// Home Assistant. +type Device struct { + ConfigurationUrl string `json:"configuration_url"` + Identifiers []string `json:"identifiers"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model,omitempty"` + Name string `json:"name"` +} + +// Structure that encapsulates the information to retrieve availability of +// devices and entities. +type Availability struct { + Topic string `json:"topic"` + PayloadAvailable string `json:"payload_available,omitempty"` + PayloadNotAvailable string `json:"payload_not_available,omitempty"` +} + +// Base config for all MQTT discovery configs. +type BaseConfig struct { + Device Device `json:"device"` + Name string `json:"name,omitempty"` + UniqueId string `json:"unique_id,omitempty"` + Retain bool `json:"retain"` + Availability []Availability `json:"availability,omitempty"` + AvailabilityMode string `json:"availability_mode,omitempty"` + QoS int `json:"qos"` +} + +// Returns a pointer to the device object. +func (c *BaseConfig) GetDevice() *Device { + return &c.Device +} + +// Adds a new entry on the list of Availability topics. +func (c *BaseConfig) AddAvailability(availability Availability) MqttConfig { + c.Availability = append(c.Availability, availability) + return c +} + +// Get the name of the entity in the configuration. +func (c *BaseConfig) GetName() string { + return c.Name +} + +// Set the name for the entity in the configuration. +func (c *BaseConfig) SetName(name string) MqttConfig { + c.Name = name + return c +} + +// Set retain value. +func (c *BaseConfig) SetRetain(retain bool) MqttConfig { + c.Retain = retain + return c +} + +// Set availability mode. +func (c *BaseConfig) SetAvailabilityMode(mode string) MqttConfig { + c.AvailabilityMode = mode + return c +} + +// Light configuration: +// https://www.home-assistant.io/integrations/light.mqtt/ +type LightConfig struct { + BaseConfig + CommandTopic string `json:"command_topic,omitempty"` + StateTopic string `json:"state_topic,omitempty"` + PayloadOn string `json:"payload_on,omitempty"` + PayloadOff string `json:"payload_off,omitempty"` + OnCommandType string `json:"on_command_type,omitempty"` + BrightnessScale int `json:"brightness_scale,omitempty"` + BrightnessStateTopic string `json:"brightness_state_topic,omitempty"` + BrightnessCommandTopic string `json:"brightness_command_topic,omitempty"` +} + +// Cover configuration: +// https://www.home-assistant.io/integrations/cover.mqtt/ +type CoverConfig struct { + BaseConfig + StateTopic string `json:"state_topic,omitempty"` + StateClosed string `json:"state_closed,omitempty"` + StateOpen string `json:"state_open,omitempty"` + CommandTopic string `json:"command_topic,omitempty"` + PayloadClose string `json:"payload_close,omitempty"` + PayloadOpen string `json:"payload_open,omitempty"` + PayloadStop string `json:"payload_stop,omitempty"` + PositionTopic string `json:"position_topic,omitempty"` + SetPositionTopic string `json:"set_position_topic,omitempty"` + PositionTemplate string `json:"position_template,omitempty"` + TiltStatusTopic string `json:"tilt_status_topic,omitempty"` + TiltCommandTopic string `json:"tilt_command_topic,omitempty"` + TiltStatusTemplate string `json:"tilt_status_template,omitempty"` +} + +// Sensor configuration: +// https://www.home-assistant.io/integrations/sensor.mqtt/ +type SensorConfig struct { + BaseConfig + StateTopic string `json:"state_topic,omitempty"` + UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` + DeviceClass string `json:"device_class,omitempty"` + StateClass string `json:"state_class,omitempty"` + Icon string `json:"icon,omitempty"` + ValueTemplate string `json:"value_template,omitempty"` +} + +// Scene configuration: +// https://www.home-assistant.io/integrations/scene.mqtt/ +type SceneConfig struct { + BaseConfig + CommandTopic string `json:"command_topic,omitempty"` + PayloadOn string `json:"payload_on,omitempty"` + Icon string `json:"icon,omitempty"` + EnabledByDefault bool `json:"enabled_by_default"` +} + +// Device Trigger configuration: +// https://www.home-assistant.io/integrations/device_trigger.mqtt/ +type DeviceTriggerConfig struct { + BaseConfig + AutomationType string `json:"automation_type"` + Payload string `json:"payload,omitempty"` + Topic string `json:"topic"` + Type string `json:"type"` + Subtype string `json:"subtype"` +} diff --git a/pkg/homeassistant/discovery.go b/pkg/homeassistant/discovery.go new file mode 100644 index 0000000..7b5d493 --- /dev/null +++ b/pkg/homeassistant/discovery.go @@ -0,0 +1,103 @@ +package homeassistant + +import ( + "encoding/json" + "fmt" + "path" + + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/config" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/mqtt" + "github.com/gaetancollaud/digitalstrom-mqtt/pkg/utils" +) + +type Domain string + +const ( + Sensor Domain = "sensor" + Light Domain = "light" + DeviceAutomation Domain = "device_automation" + Cover Domain = "cover" + Scene Domain = "scene" + DeviceTrigger Domain = "device_automation" +) + +type DiscoveryConfig struct { + Domain Domain + DeviceId string + ObjectId string + Config MqttConfig +} + +type HomeAssistantDiscoveryInterface interface { + // Returns the list of Home Assitant MQTT entities that each module would + // be exporting for discovery. + // This will be run after the method Start is called and therefore it can + // assume that the logic there will be run. + GetHomeAssistantEntities() ([]DiscoveryConfig, error) +} + +type HomeAssistantDiscovery struct { + mqttClient mqtt.Client + config *config.ConfigHomeAssistant + + discoveryConfigs []DiscoveryConfig +} + +func NewHomeAssistantDiscovery(mqttClient mqtt.Client, config *config.ConfigHomeAssistant) *HomeAssistantDiscovery { + return &HomeAssistantDiscovery{ + mqttClient: mqttClient, + config: config, + discoveryConfigs: []DiscoveryConfig{}, + } +} + +func (hass *HomeAssistantDiscovery) AddConfigs(configs []DiscoveryConfig) { + systemAvailability := Availability{ + Topic: hass.mqttClient.ServerStatusTopic(), + PayloadAvailable: mqtt.Online, + PayloadNotAvailable: mqtt.Offline, + } + for _, config := range configs { + entityName := config.Config.GetName() + config.Config. + SetName( + utils.RemoveRegexp( + entityName, + hass.config.RemoveRegexpFromName)). + SetRetain(hass.config.Retain). + AddAvailability(systemAvailability). + SetAvailabilityMode("all") + // Update the config with some generic attributes for all + // configurations. + device := config.Config.GetDevice() + device.Manufacturer = "DigitalStrom" + device.ConfigurationUrl = "https://" + hass.config.DigitalStromHost + + hass.discoveryConfigs = append(hass.discoveryConfigs, config) + } +} + +func (hass *HomeAssistantDiscovery) PublishDiscoveryMessages() error { + if !hass.config.DiscoveryEnabled { + return nil + } + + for _, config := range hass.discoveryConfigs { + topic := path.Join( + hass.config.DiscoveryTopicPrefix, + string(config.Domain), + config.DeviceId, + config.ObjectId, + "config") + json, err := json.Marshal(config.Config) + if err != nil { + return fmt.Errorf("error serializing dicovery config to JSON: %w", err) + } + t := hass.mqttClient.RawClient().Publish(topic, 0, true, json) + <-t.Done() + if t.Error() != nil { + return fmt.Errorf("error publishing discovery message to MQTT: %w", err) + } + } + return nil +} diff --git a/pkg/mqtt/client.go b/pkg/mqtt/client.go new file mode 100644 index 0000000..65d6cb8 --- /dev/null +++ b/pkg/mqtt/client.go @@ -0,0 +1,145 @@ +package mqtt + +import ( + "fmt" + "path" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +const ( + Online string = "online" + Offline string = "offline" +) + +// Topics. +const ( + State string = "state" + Command string = "command" + Event string = "event" + serverStatus string = "server/status" +) + +type Client interface { + // Connect to the MQTT server. + Connect() error + // Disconnect from the MQTT server. + Disconnect() error + + // Publishes a message under the prefix topic of DigitalStrom. + Publish(topic string, message interface{}) error + // Same as publish but force the retain flag regardless of what is in the config + PublishAndRetain(topic string, message interface{}) error + // Subscribe to a topic and calls the given handler when a message is + // received. + Subscribe(topic string, messageHandler mqtt.MessageHandler) error + + // Return the full topic for a given subpath. + GetFullTopic(topic string) string + // Returns the topic used to publish the server status. + ServerStatusTopic() string + + RawClient() mqtt.Client +} + +type client struct { + mqttClient mqtt.Client + options ClientOptions +} + +func NewClient(options *ClientOptions) Client { + mqttOptions := mqtt.NewClientOptions(). + AddBroker(options.MqttUrl). + SetClientID("digitalstrom-mqtt-" + uuid.New().String()). + SetOrderMatters(false). + SetUsername(options.Username). + SetPassword(options.Password) + + return &client{ + mqttClient: mqtt.NewClient(mqttOptions), + options: *options, + } +} + +func (c *client) Connect() error { + t := c.mqttClient.Connect() + <-t.Done() + if t.Error() != nil { + return fmt.Errorf("error connecting to MQTT broker: %w", t.Error()) + } + + if err := c.publishServerStatus(Online); err != nil { + return err + } + return nil +} + +func (c *client) Disconnect() error { + log.Info().Msg("Publishing Offline status to MQTT server.") + if err := c.publishServerStatus(Offline); err != nil { + return err + } + c.mqttClient.Disconnect(uint(c.options.DisconnectTimeout.Milliseconds())) + log.Info().Msg("Disconnected from MQTT server.") + return nil +} + +func (c *client) publish(topic string, message interface{}, forceRetain bool) error { + t := c.mqttClient.Publish( + path.Join(c.options.TopicPrefix, topic), + c.options.QoS, + c.options.Retain || forceRetain, + message) + <-t.Done() + return t.Error() +} + +func (c *client) Publish(topic string, message interface{}) error { + return c.publish(topic, message, false) +} + +func (c *client) PublishAndRetain(topic string, message interface{}) error { + return c.publish(topic, message, true) +} + +func (c *client) Subscribe(topic string, messageHandler mqtt.MessageHandler) error { + t := c.mqttClient.Subscribe( + path.Join(c.options.TopicPrefix, topic), + c.options.QoS, + messageHandler) + <-t.Done() + return t.Error() +} + +// Publish the current binary status into the MQTT topic. +func (c *client) publishServerStatus(message string) error { + log.Info().Str("status", message).Str("topic", serverStatus).Msg("Updating server status topic") + return c.PublishAndRetain(serverStatus, message) +} + +func (c *client) ServerStatusTopic() string { + return path.Join(c.options.TopicPrefix, serverStatus) +} + +func (c *client) GetFullTopic(topic string) string { + return path.Join(c.options.TopicPrefix, topic) +} + +func (c *client) RawClient() mqtt.Client { + return c.mqttClient +} + +func normalizeForTopicName(item string) string { + output := "" + for i := 0; i < len(item); i++ { + c := item[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { + output += string(c) + } else if c == ' ' || c == '/' { + output += "_" + } + } + return output +} diff --git a/digitalstrom_mqtt/mqtt_test.go b/pkg/mqtt/mqtt_test.go similarity index 65% rename from digitalstrom_mqtt/mqtt_test.go rename to pkg/mqtt/mqtt_test.go index 910a653..1ed5021 100644 --- a/digitalstrom_mqtt/mqtt_test.go +++ b/pkg/mqtt/mqtt_test.go @@ -1,24 +1,9 @@ -package digitalstrom_mqtt +package mqtt import ( "testing" - - "github.com/gaetancollaud/digitalstrom-mqtt/config" ) -func TestTopicGeneration(t *testing.T) { - - config := config.ConfigMqtt{ - TopicFormat: "digitalstrom/{deviceType}/{deviceName}/{deviceId}/{channel}/{commandState}", - } - - mqtt := DigitalstromMqtt{ - config: &config, - } - - expect(t, mqtt.getTopic("circuits", "id", "abc", "chan", "test"), "digitalstrom/circuits/abc/id/chan/test", "wrong topic") -} - func TestNormalize(t *testing.T) { expect(t, normalizeForTopicName("test"), "test", "Error with normalize") expect(t, normalizeForTopicName("test_test-test"), "test_test-test", "Error with normalize") diff --git a/pkg/mqtt/options.go b/pkg/mqtt/options.go new file mode 100644 index 0000000..6c3605c --- /dev/null +++ b/pkg/mqtt/options.go @@ -0,0 +1,71 @@ +package mqtt + +import ( + "time" +) + +// ClientOptions contains configurable options for the MQTT client responsible +// to communicate with DigitalStrom data. +type ClientOptions struct { + MqttUrl string + Username string + Password string + TopicPrefix string + NormalizeDeviceName bool + Retain bool + QoS byte + DisconnectTimeout time.Duration +} + +// NewClientOptions will create a new ClientOptions type with some default +// values. +// TopicPrefix: "digitalstrom" +// NormalizeDeviceName: true +// Retain: true +// QoS: 0 +// DisconnectTimeout: 1 second +func NewClientOptions() *ClientOptions { + return &ClientOptions{ + MqttUrl: "", + Username: "", + Password: "", + TopicPrefix: "digitalstrom", + Retain: true, + QoS: 0, + DisconnectTimeout: 1 * time.Second, + } +} + +// SetMqttUrl will set the address for the DigitalStrom server to connect. +func (o *ClientOptions) SetMqttUrl(server string) *ClientOptions { + o.MqttUrl = server + return o +} + +// SetUsername will set the username to be used by this client when connecting +// to the MQTT server. +func (o *ClientOptions) SetUsername(u string) *ClientOptions { + o.Username = u + return o +} + +// SetPassword will set the password to be used by this client when connecting +// to the MQTT server. +func (o *ClientOptions) SetPassword(p string) *ClientOptions { + o.Password = p + return o +} + +// SetTopicPrefix will set the prefix that will be prepended to all the +// published messages. +func (o *ClientOptions) SetTopicPrefix(prefix string) *ClientOptions { + o.TopicPrefix = prefix + return o +} + +// SetRetain will define the value for the retain flag for all published +// messages. +func (o *ClientOptions) SetRetain(retain bool) *ClientOptions { + o.Retain = retain + return o +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..b4a3d09 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,14 @@ +package utils + +import ( + "regexp" + "strings" +) + +func RemoveRegexp(value string, expression string) string { + if expression == "" { + return value + } + regex := regexp.MustCompile("(?i)" + expression) + return strings.TrimSpace(regex.ReplaceAllString(value, "")) +} diff --git a/utils/utils_test.go b/pkg/utils/utils_test.go similarity index 100% rename from utils/utils_test.go rename to pkg/utils/utils_test.go diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index c057df9..0000000 --- a/utils/utils.go +++ /dev/null @@ -1,40 +0,0 @@ -package utils - -import ( - "encoding/json" - "regexp" - "strings" - - "github.com/rs/zerolog/log" -) - -func CheckNoErrorAndPrint(e error) bool { - if e != nil { - log.Info().Err(e).Msg("Error") - } - return e == nil -} - -func PrettyPrintMap(value map[string]interface{}) string { - b, err := json.Marshal(value) - if err != nil { - log.Info().Err(err).Msg("Cannot pretty print") - } - return string(b) -} - -func PrettyPrintArray(value interface{}) string { - b, err := json.Marshal(value) - if err != nil { - log.Info().Err(err).Msg("Cannot pretty print") - } - return string(b) -} - -func RemoveRegexp(value string, expression string) string { - if expression == "" { - return value - } - regex := regexp.MustCompile("(?i)" + expression) - return strings.TrimSpace(regex.ReplaceAllString(value, "")) -}