diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 0aae3e8..0b7fb68 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -6,13 +6,15 @@ import ( "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 - mqttClient mqtt.Client + dsClient digitalstrom.Client + mqttClient mqtt.Client + hassDiscovery *homeassistant.HomeAssistantDiscovery modules map[string]modules.Module } @@ -33,10 +35,16 @@ func NewController(config *config.Config) *Controller { SetTopicPrefix(config.Mqtt.TopicPrefix). SetRetain(config.Mqtt.Retain) mqttClient := mqtt.NewClient(mqttOptions) + + hass := homeassistant.NewHomeAssistantDiscovery( + mqttClient, + &config.HomeAssistant) + controller := Controller{ - dsClient: dsClient, - mqttClient: mqttClient, - modules: map[string]modules.Module{}, + dsClient: dsClient, + mqttClient: mqttClient, + hassDiscovery: hass, + modules: map[string]modules.Module{}, } for name, builder := range modules.Modules { @@ -63,6 +71,23 @@ func (c *Controller) Start() error { } } + // 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 } diff --git a/pkg/homeassistant/config.go b/pkg/homeassistant/config.go new file mode 100644 index 0000000..aea998f --- /dev/null +++ b/pkg/homeassistant/config.go @@ -0,0 +1,126 @@ +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 +} + +// 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"` + 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"` + UniqueId string `json:"unique_id"` + Retain bool `json:"retain"` + Availability []Availability `json:"availability"` + AvailabilityMode string `json:"availability_mode"` + 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 +} + +// Light configuration: +// https://www.home-assistant.io/integrations/light.mqtt/ +type LightMqttConfig 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:"brigthness_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"` +} + +// 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"` + Icon string `json:"icon,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,omitempty"` +} + +// 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..6305bad --- /dev/null +++ b/pkg/homeassistant/discovery.go @@ -0,0 +1,97 @@ +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" +) + +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)). + AddAvailability(systemAvailability) + // 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) + } + if err := hass.mqttClient.Publish(topic, json); err != 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 index bc4780a..1d7b8f5 100644 --- a/pkg/mqtt/client.go +++ b/pkg/mqtt/client.go @@ -30,8 +30,11 @@ type Client interface { // Publishes a message under the prefix topic of DigitalStrom. Publish(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 + // Returns the topic used to publish the server status. + ServerStatusTopic() string } type client struct { @@ -101,6 +104,10 @@ func (c *client) publishServerStatus(message string) error { return c.Publish(serverStatus, message) } +func (c *client) ServerStatusTopic() string { + return path.Join(c.options.TopicPrefix, serverStatus) +} + func normalizeForTopicName(item string) string { output := "" for i := 0; i < len(item); i++ {