// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2023 mysubarumq // SPDX-FileContributor: alex-savin package workers import ( "context" "fmt" "log/slog" "strconv" "strings" "sync" "sync/atomic" "time" "git.savin.nyc/alex/mysubaru" "git.savin.nyc/alex/mysubaru-mq/bus" "git.savin.nyc/alex/mysubaru-mq/config" msc "git.savin.nyc/alex/mysubaru/config" ) // MySubaruClient is a client that connects to the MySubaru server by establishing tcp connection type MySubaruClient struct { sync.RWMutex id string // the internal id of the listener bus *bus.Bus // the internal bus for the interapp communication config *config.Config // configuration values for the listener mysubaru *mysubaru.Client // cancel context.CancelFunc // end uint32 // ensure the close methods are only called once log *slog.Logger // server logger } // NewMySubaruClient initialises and returns a MySubaru client func NewMySubaruClient(id string, bus *bus.Bus, config *config.Config) *MySubaruClient { s := &MySubaruClient{ id: id, bus: bus, config: config, } return s } // ID returns the id of the listener. func (s *MySubaruClient) ID() string { return s.id } // ID returns the id of the listener. func (s *MySubaruClient) Type() string { return "mysubaru-client" } // Init . func (s *MySubaruClient) Init(log *slog.Logger) error { s.log = log mys, err := mysubaru.New(&msc.Config{MySubaru: s.config.MySubaru, TimeZone: s.config.Timezone, Logger: s.log}) if err != nil { s.log.Error("couldn't connect to MySubaru server", "error", err.Error()) } s.mysubaru = mys return nil } // OneTime . func (s *MySubaruClient) OneTime() error { if s.config.Hassio.AutoDiscovery { time.Sleep(3 * time.Second) vehicles := s.mysubaru.GetVehicles() for _, vehicle := range vehicles { err := s.bus.Publish("mqtt:publish", s.mySubaruConfigToMQTTHassioConfig(vehicle)) if err != nil { s.log.Error("got an error from bus", "error", err.Error()) return err } err = s.bus.Publish("mqtt:publish", s.mySubaruStatusToMQTTMessage(vehicle)) if err != nil { s.log.Error("got an error from bus", "error", err.Error()) return err } } } return nil } // Serve starts waiting for new TCP connections, and calls the establish // connection callback for any received. func (s *MySubaruClient) Serve() { if atomic.LoadUint32(&s.end) == 1 { return } var subs []*bus.Message // Subscribing for the topic to resend auto discovery topics after Home Assistant restarted and got a status "online" if s.config.Hassio.AutoDiscovery { subs = append(subs, &bus.Message{ Topic: s.config.Hassio.Topics.Status, QOS: 0, }) } // TODO: Go over MySubaru devices with switch and lock options subs = append(subs, &bus.Message{ Topic: "mysubarumq/4S4BTGPD0P3199198/lock/set", QOS: 0, }) subs = append(subs, &bus.Message{ Topic: "mysubarumq/4S4BTGPD0P3199198/ignition/set", QOS: 0, }) s.bus.Publish("mqtt:subscribe", subs) tickerS := time.NewTicker(time.Duration(60) * time.Second) tickerM := time.NewTicker(time.Duration(60) * time.Minute) chMQTTLockStatus, err := s.bus.Subscribe("mysubarumq:4S4BTGPD0P3199198:lock", "mysubarumq:4S4BTGPD0P3199198:lock", s.config.SubscriptionSize["device:95452:status"]) if err != nil { s.log.Error("couldn't subscribe to a channel", "channel", "mysubarumq:4S4BTGPD0P3199198:lock", "error", err.Error()) } chMQTTIgnitionStatus, err := s.bus.Subscribe("mysubarumq:4S4BTGPD0P3199198:ignition", "mysubarumq:4S4BTGPD0P3199198:ignition", s.config.SubscriptionSize["device:95452:status"]) if err != nil { s.log.Error("couldn't subscribe to a channel", "channel", "mysubarumq:4S4BTGPD0P3199198:ignition", "error", err.Error()) } chMQTTHassioStatus, err := s.bus.Subscribe("hassio:status", s.ID(), s.config.SubscriptionSize["hassio:status"]) if err != nil { s.log.Error("couldn't subscribe to a channel", "channel", "hassio:status", "error", err.Error()) } // ctx is used only by tests. // ctx, ctxCancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background()) s.cancel = cancel go s.eventLoop(ctx, chMQTTLockStatus, chMQTTIgnitionStatus, chMQTTHassioStatus) if atomic.LoadUint32(&s.end) == 0 { go func() { for { select { case <-tickerS.C: vehicles := s.mysubaru.GetVehicles() for _, vehicle := range vehicles { err := s.bus.Publish("mqtt:publish", s.mySubaruStatusToMQTTMessage(vehicle)) if err != nil { s.log.Error("got an error from bus", "error", err.Error()) } } case <-tickerM.C: vehicles := s.mysubaru.GetVehicles() for _, vehicle := range vehicles { vehicle.GetLocation(true) err := s.bus.Publish("mqtt:publish", s.mySubaruStatusToMQTTMessage(vehicle)) if err != nil { s.log.Error("got an error from bus", "error", err.Error()) } } case <-ctx.Done(): s.log.Info("stopping communication eventloop", "type", s.Type()) return } } }() } } // Close closes the listener and any client connections. func (s *MySubaruClient) Close() { s.Lock() defer s.Unlock() if atomic.CompareAndSwapUint32(&s.end, 0, 1) { s.cancel() s.log.Info("disconnected from mysubaru server", "type", s.Type()) } } // eventLoop loops forever func (s *MySubaruClient) eventLoop(ctx context.Context, chMQTTLockStatus, chMQTTIgnitionStatus, chMQTTHassioStatus chan bus.Event) { s.log.Debug("mysubaru communication event loop started") defer s.log.Debug("mysubaru communication event loop halted") for { select { case event := <-chMQTTLockStatus: for _, message := range event.Data.([]*bus.Message) { s.log.Debug("received a message with mysubary lock status", "topic", message.Topic, "payload", message.Payload) if message.Payload == "LOCK" { v := s.mysubaru.GetVehicleByVIN("4S4BTGPD0P3199198") v.Lock() var msgs []*bus.Message msgs = s.messages("mysubarumq/4S4BTGPD0P3199198/lock", 0, true, "LOCK", msgs) s.bus.Publish("mqtt:publish", msgs) } if message.Payload == "UNLOCK" { v := s.mysubaru.GetVehicleByVIN("4S4BTGPD0P3199198") v.Unlock() var msgs []*bus.Message msgs = s.messages("mysubarumq/4S4BTGPD0P3199198/lock", 0, true, "UNLOCK", msgs) s.bus.Publish("mqtt:publish", msgs) } } case event := <-chMQTTIgnitionStatus: for _, message := range event.Data.([]*bus.Message) { s.log.Debug("received a message with mysubary ignition status", "topic", message.Topic, "payload", message.Payload) if message.Payload == "ON" { v := s.mysubaru.GetVehicleByVIN("4S4BTGPD0P3199198") v.EngineStart() var msgs []*bus.Message msgs = s.messages("mysubarumq/4S4BTGPD0P3199198/ignition", 0, true, "ON", msgs) s.bus.Publish("mqtt:publish", msgs) } if message.Payload == "OFF" { v := s.mysubaru.GetVehicleByVIN("4S4BTGPD0P3199198") v.EngineStop() var msgs []*bus.Message msgs = s.messages("mysubarumq/4S4BTGPD0P3199198/ignition", 0, true, "OFF", msgs) s.bus.Publish("mqtt:publish", msgs) } } case event := <-chMQTTHassioStatus: for _, message := range event.Data.([]*bus.Message) { s.log.Info("received a message with hassio instance status", "topic", message.Topic, "payload", message.Payload) if message.Payload == "online" { s.OneTime() } } case <-ctx.Done(): s.log.Info("stopping mqtt communication event loop") return } } } // mySubaruConfigToMQTTHassioConfig . func (s *MySubaruClient) mySubaruConfigToMQTTHassioConfig(v *mysubaru.Vehicle) []*bus.Message { // { // "~": "homeassistant/sensor/VIN_SENSOR_NAME", // "name": null, // "uniq_id": "4S4BTGPD0P3199198_SENSOR_NAME", // "obj_id": "", // "ic": "", // "stat_t": "~/state", // "json_attr_t": "~/state", // "val_tpl": "{{value_json.value}}", // "dev_cla": "", // "stat_cla": "", // "unit_of_meas": "", // "en": true, // "ent_cat": "", // "ent_pic": "", // "dev": { // "ids": [ // "4S4BTGPD0P3199198" // ], // "name": "Subaru Outback Touring TX (2023)", // "mf": "Subaru", // "mdl": "Outback Touring TX", // "sw": "1.0", // "hw": "PDL" // }, // "o": { // "name": "MySubaruMQ", // "sw": "1.0.0", // "url": "https://www.github.com/alex-savin/" // }, // "avty": [ // { // "topic": "" // } // ] // } var hassioConfig = map[string]string{} // availability := `{"avty":["{"topic":"mysubaru/` + v.Vin + `"}"]}` origin := `"o":{"name":"MySubaruMQ","sw":"1.0.1","url":"https://git.savin.nyc/alex/mysubaru-mq"},` // availability := `"avty":[{"t":"musubarymq/status"}],"avty_t":"{{value_json.value}}",` device := `"dev":{"ids":["` + v.Vin + `"],"name":"` + v.CarNickname + `","mf":"Subaru Corp.","mdl":"` + v.CarNickname + `","hw":"` + v.ModelCode + `"},` // TODO chnage model to the proper one obj_id_prefix := strings.Replace(strings.ToLower(v.CarNickname), " ", "_", -1) // topic := `"~":"mysubaru/` + v.Vin + `",` topic := "" // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/odometer_km/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/odometer_km/config`] = `{` + device + origin + topic + `"name":"Odometer (km)","uniq_id":"` + v.Vin + `_odometer_km","obj_id":"` + obj_id_prefix + `_odometer_km","ic":"mdi:counter","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.odometer_km}}","unit_of_meas":"km"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/odometer_mi/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/odometer_mi/config`] = `{` + device + origin + topic + `"name":"Odometer (mi)","uniq_id":"` + v.Vin + `_odometer_mi","obj_id":"` + obj_id_prefix + `_odometer_mi","ic":"mdi:counter","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.odometer_mi}}","unit_of_meas":"mi"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/dist_to_empty_km/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/dist_to_empty_km/config`] = `{` + device + origin + topic + `"name":"Distance to Empty (km)","uniq_id":"` + v.Vin + `_dist_to_empty_km","obj_id":"` + obj_id_prefix + `_dist_to_empty_km","ic":"mdi:map-marker-distance","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.dist_to_empty_km}}","unit_of_meas":"km"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/dist_to_empty_mi/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/dist_to_empty_mi/config`] = `{` + device + origin + topic + `"name":"Distance to Empty (mi)","uniq_id":"` + v.Vin + `_dist_to_empty_mi","obj_id":"` + obj_id_prefix + `_dist_to_empty_mi","ic":"mdi:map-marker-distance","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.dist_to_empty_mi}}","unit_of_meas":"mi"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/dist_to_empty_pc/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/dist_to_empty_pc/config`] = `{` + device + origin + topic + `"name":"Gas Tank (%)","uniq_id":"` + v.Vin + `_dist_to_empty_pc","obj_id":"` + obj_id_prefix + `_dist_to_empty_pc","ic":"mdi:gauge","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.dist_to_empty_pc}}","unit_of_meas":"%"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/consumption_us/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/consumption_us/config`] = `{` + device + origin + topic + `"name":"Consumption (MPG)","uniq_id":"` + v.Vin + `_consumption_us","obj_id":"` + obj_id_prefix + `_consumption_us","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.consumption_us}}","unit_of_meas":"MPG"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/consumption_eu/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/consumption_eu/config`] = `{` + device + origin + topic + `"name":"Consumption (L/100km)","uniq_id":"` + v.Vin + `_consumption_eu","obj_id":"` + obj_id_prefix + `_consumption_eu","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.consumption_eu}}","unit_of_meas":"L100km"}` // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/engine_state/consumption_eu/config hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/engine_state/config`] = `{` + device + origin + topic + `"name":"Engine State","uniq_id":"` + v.Vin + `_engine_state","obj_id":"` + obj_id_prefix + `_engine_state","ic":"mdi:engine","stat_t":"mysubarumq/` + v.Vin + `/state","val_tpl":"{{value_json.engine_state}}"}` hassioConfig[s.config.Hassio.Topics.Discovery+`/device_tracker/`+v.Vin+`/config`] = `{` + device + origin + `"name":"` + v.CarNickname + `","uniq_id":"` + v.Vin + `_device_tracker","obj_id":"` + obj_id_prefix + `","ic":"mdi:car-connected","json_attr_t":"mysubarumq/` + v.Vin + `/attr"}` for n, d := range v.Doors { // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/door_frontleft_status/config position := d.Position if len(d.SubPosition) > 0 { position = position + ` ` + d.SubPosition } hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/door_`+n+`_status/config`] = `{` + device + origin + topic + `"name":"Door ` + position + ` Status","uniq_id":"` + v.Vin + `_door_` + n + `_status","obj_id":"` + obj_id_prefix + `_door_` + n + `_status","ic":"mdi:car-door","stat_t":"mysubarumq/` + v.Vin + `/doors/state","val_tpl":"{{value_json.` + n + `}}"}` } for n, w := range v.Windows { // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/window_frontleft/config position := w.Position if len(w.SubPosition) > 0 { position = position + ` ` + w.SubPosition } hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/window_`+n+`/config`] = `{` + device + origin + topic + `"name":"Window ` + position + `","uniq_id":"` + v.Vin + `_window_` + n + `","obj_id":"` + obj_id_prefix + `_window_` + n + `","ic":"mdi:car-door","stat_t":"mysubarumq/` + v.Vin + `/windows/state","val_tpl":"{{value_json.window_` + n + `}}"}` } for n, t := range v.Tires { // homeassistant/sensor/mysubaru/VIN-NUMBER-HERE/tire_frontleft_psi/config position := t.Position + ` ` + t.SubPosition hassioConfig[s.config.Hassio.Topics.Discovery+`/sensor/`+v.Vin+`/tire_`+n+`_psi/config`] = `{` + device + origin + topic + `"name":"Tire ` + position + ` (Psi)","uniq_id":"` + v.Vin + `_tire_` + n + `_psi","obj_id":"` + obj_id_prefix + `_tire_` + n + `_psi","ic":"mdi:car-tire-alert","stat_t":"mysubarumq/` + v.Vin + `/tires/state","val_tpl":"{{value_json.tire_` + n + `}}"}` } topicState := `mysubarumq/` + v.Vin + `/ignition` topicSet := `mysubarumq/` + v.Vin + `/ignition/set` hassioConfig[s.config.Hassio.Topics.Discovery+`/switch/`+v.Vin+`/ignition/config`] = `{` + origin + device + `"name":"Ignition","cmd_t":"` + topicSet + `","stat_t":"` + topicState + `","name":null,"obj_id":"` + obj_id_prefix + `_ignition","ic":"mdi:engine","pl_off":"OFF","pl_on":"ON","uniq_id":"` + v.Vin + `_ignition"}` topicState = `mysubarumq/` + v.Vin + `/lock` topicSet = `mysubarumq/` + v.Vin + `/lock/set` hassioConfig[s.config.Hassio.Topics.Discovery+`/lock/`+v.Vin+`/config`] = `{` + origin + device + `"name":"Lock","cmd_t":"` + topicSet + `","stat_t":"` + topicState + `","name":null,"obj_id":"` + obj_id_prefix + `_lock","ic":"mdi:car-key","pl_unlk":"UNLOCK","pl_lock":"LOCK","stat_locked":"LOCK","stat_unlocked":"UNLOCK","uniq_id":"` + v.Vin + `_lock"}` hassioConfig[s.config.Hassio.Topics.Discovery+`/event/`+v.Vin+`/engine/config`] = `{` + device + origin + `"name":"Ignition Events","dev_cla":"button","evt_typ":["start","stop"],"uniq_id":"` + v.Vin + `_event_ignition","obj_id":"` + obj_id_prefix + `_event_ignition","ic":"mdi:engine","stat_t":"mysubarumq/` + v.Vin + `/event/ignition"}` hassioConfig[s.config.Hassio.Topics.Discovery+`/event/`+v.Vin+`/lock/config`] = `{` + device + origin + `"name":"Lock Events","dev_cla":"button","evt_typ":["lock","unlock"],"uniq_id":"` + v.Vin + `_event_lock","obj_id":"` + obj_id_prefix + `_event_lock","ic":"mdi:car-key","stat_t":"mysubarumq/` + v.Vin + `/event/lock"}` // {"availability":[{"topic":"zigbee02/bridge/state"}],"availability_mode":"all","command_topic":"zigbee02/bridge/request/restart","device":{"hw_version":"zStack3x0 20230507","identifiers":["zigbee2mqtt_bridge_0x00124b00237e0682"],"manufacturer":"Zigbee2MQTT","model":"Bridge","name":"Zigbee2MQTT Bridge","sw_version":"1.35.1"},"device_class":"restart","name":"Restart","object_id":"zigbee2mqtt_bridge_restart","origin":{"name":"Zigbee2MQTT","sw":"1.35.1","url":"https://www.zigbee2mqtt.io"},"payload_press":"","unique_id":"bridge_0x00124b00237e0682_restart_zigbee02"} // LOCK // state_topic: "home-assistant/frontdoor/state" // code_format: "^\\d{4}$" // command_topic: "home-assistant/frontdoor/set" // command_template: '{ "action": "{{ value }}", "code":"{{ code }}" }' // payload_lock: "LOCK" // payload_unlock: "UNLOCK" // state_locked: "LOCK" // state_unlocked: "UNLOCK" // state_locking: "LOCKING" // state_unlocking: "UNLOCKING" // state_jammed: "MOTOR_JAMMED" // state_ok: "MOTOR_OK" // optimistic: false // qos: 1 // retain: true // value_template: "{{ value.x }}" // mdi:tire // mdi:car-door // mdi:car-door-lock | mdi:car-door-lock-open | mdi:car-key var msgs []*bus.Message for topic, payload := range hassioConfig { msgs = s.messages(topic, 1, true, payload, msgs) s.log.Debug("hassio mqtt configuration", "topic", string(topic), "payload", string(payload)) } return msgs } // mySubaruStatusToMQTTMessage . func (s *MySubaruClient) mySubaruStatusToMQTTMessage(v *mysubaru.Vehicle) []*bus.Message { var state = map[string]string{} tank := "" if v.DistanceToEmpty.Percentage > 0 && 101 >= v.DistanceToEmpty.Percentage { tank = `"dist_to_empty_pc":` + strconv.Itoa(v.DistanceToEmpty.Percentage) + `,` } state[`mysubarumq/`+v.Vin+`/state`] = `{` + tank + `"odometer_km":` + strconv.Itoa(v.Odometer.Kilometers) + `,"odometer_mi":` + strconv.Itoa(v.Odometer.Miles) + `,"dist_to_empty_km":` + strconv.Itoa(v.DistanceToEmpty.Kilometers) + `,"dist_to_empty_mi":` + strconv.Itoa(v.DistanceToEmpty.Miles) + `,"consumption_us":` + fmt.Sprintf("%.2f", v.FuelConsumptionAvg.MPG) + `,"consumption_eu":` + fmt.Sprintf("%.2f", v.FuelConsumptionAvg.LP100Km) + `,"engine_state":"` + v.EngineState + `"}` state[`mysubarumq/`+v.Vin+`/attr`] = `{"source_type":"gps","latitude":` + fmt.Sprintf("%.6f", v.GeoLocation.Latitude) + `,"longitude":` + fmt.Sprintf("%.6f", v.GeoLocation.Longitude) + `,"course":` + strconv.Itoa(v.GeoLocation.Heading) + `,"speed":` + fmt.Sprintf("%.2f", v.GeoLocation.Speed) + `,"friendly_name":"` + v.CarNickname + `"}` windows := `{` wq := len(v.Windows) wc := 1 for n, w := range v.Windows { windows = `"` + n + `":"` + w.Status + `"` if wc != wq { windows = windows + `,` } wc++ } windows = windows + `}` state[`mysubarumq/`+v.Vin+`/windows/state`] = windows tires := `{` tq := len(v.Tires) tc := 1 for n, t := range v.Tires { tires = `"` + n + `":` + strconv.Itoa(t.Pressure) if tc != tq { tires = tires + `,` } tc++ } tires = tires + `}` state[`mysubarumq/`+v.Vin+`/tires/state`] = tires var msgs []*bus.Message for topic, payload := range state { msgs = s.messages(topic, 0, false, payload, msgs) s.log.Debug("hassio mqtt configuration", "topic", string(topic), "payload", string(payload)) } return msgs } // messages . func (s *MySubaruClient) messages(t string, q byte, r bool, p string, l []*bus.Message) []*bus.Message { s.log.Debug("hassio mqtt configuration", "topic", string(t), "qos", q, "retained", r, "payload", string(p)) m := bus.Message{ Topic: t, QOS: q, Retained: r, Payload: p, } if l != nil { l = append(l, &m) return l } else { var l []*bus.Message l = append(l, &m) return l } }