Skip to content

Instantly share code, notes, and snippets.

@awstanley
Last active April 23, 2025 22:45
Show Gist options
  • Save awstanley/dbeb7909f3f5ffceb073ccf0be67f2b2 to your computer and use it in GitHub Desktop.
Save awstanley/dbeb7909f3f5ffceb073ccf0be67f2b2 to your computer and use it in GitHub Desktop.
ENode data to MQTT. Quick and dirty build being fleshed out to do more work via Home Assistant and other MQTT-linkable services. (I use localhost mqtt so this is missing some best-practices.)
package main
// TODO: individual vehicle by GUID (requires config update);
// TODO: non-vehicle support (I don't need this)
// TODO: Home Assistant add-on repository?
// TODO: Command-and-control interface
// TODO: vehicle refresh
// TODO: better rate control
// TODO: MQTTS mode (move functionality to library, build a proper package)
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"reflect"
"runtime"
"strings"
"syscall"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
var httpClient http.Client
var mqttClient mqtt.Client
var enodeToken string
var enodeExpired = true
var enodeExpiresAt time.Time = time.UnixMicro(0)
var cfg Config
var safeExit bool
var ENODE_AUTH_URL string = "https://oauth.production.enode.io/oauth2/token"
var ENODE_GET_VEHICLES string = "https://enode-api.production.enode.io/vehicles"
var ENODE_VEHICLE_REFRESH string = "https://enode-api.production.enode.io/vehicles/%s/refresh-hint"
type Config struct {
Broker string
Port uint16
ClientID string
Username string
Password string
ENodeClient string
ENodeSecret string
}
type AccessToken struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type ENodeVehicles struct {
Data []ENodeVehicle `json:"data"`
Pagination ENodePagination `json:"pagination"`
}
type ENodeVehicle struct {
ID string `json:"id"` // GUID
UserID string `json:"userId"`
Vendor string `json:"vendor"`
LastSeen string `json:"lastSeen"`
IsReachable bool `json:"isReachable"`
Information ENodeVehicleInformation `json:"information"`
ChargeState ENodeVehicleChargeState `json:"chargeState"`
Location ENodeVehicleLocation `json:"location"`
Odometer ENodeVehicleOdometer `json:"odometer"`
}
type ENodeVehicleInformation struct {
VIN string `json:"vin"`
Brand string `json:"brand"`
Model string `json:"model"`
Year uint16 `json:"year"`
DisplayName string `json:"displayName"`
}
type ENodeVehicleChargeState struct {
BatteryLevel float32 `json:"batteryLevel"`
Range float32 `json:"range"`
IsPluggedIn bool `json:"isPluggedIn"`
IsCharging bool `json:"isCharging"`
IsFullyCharged bool `json:"isFullyCharged"`
BatteryCapacity float32 `json:"batteryCapacity"`
ChargeLimit float32 `json:"chargeLimit"`
ChargeRate float32 `json:"chargeRate"`
ChargeTimeRemaining int `json:"chargeTimeRemaining"`
LastUpdated string `json:"lastUpdated"`
MaxCurrent float32 `json:"maxCurrent"`
PowerDeliveryState string `json:"powerDeliveryState"`
}
type ENodeVehicleLocation struct {
ID string `json:"id"` // GUID
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
LastUpdated string `json:"lastUpdated"`
}
type ENodeVehicleOdometer struct {
Distance float64 `json:"distance"`
LastUpdated string `json:"lastUpdated"`
}
type ENodePagination struct {
After string `json:"after"`
Before string `json:"before"`
}
type HomeAssistantLocation struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
BatteryLevel float64 `json:"battery_level"`
}
func processENodeVehicleValue(root string, value interface{}) {
switch value.(type) {
case string:
token := mqttClient.Publish(root, 0, false, value.(string))
token.Wait()
case bool:
svalue := "off"
if value.(bool) {
svalue = "on"
}
token := mqttClient.Publish(root, 0, false, svalue)
token.Wait()
case uint16:
svalue := fmt.Sprintf("%d", value.(uint16))
token := mqttClient.Publish(root, 0, false, svalue)
token.Wait()
case int:
svalue := fmt.Sprintf("%d", value.(int))
token := mqttClient.Publish(root, 0, false, svalue)
token.Wait()
case float32:
svalue := fmt.Sprintf("%f", value.(float32))
token := mqttClient.Publish(root, 0, false, svalue)
token.Wait()
case float64:
svalue := fmt.Sprintf("%f", value.(float64))
token := mqttClient.Publish(root, 0, false, svalue)
token.Wait()
case ENodeVehicleInformation:
processENodeVehicleFields(root, value)
case ENodeVehicleChargeState:
processENodeVehicleFields(root, value)
case ENodeVehicleLocation:
processENodeVehicleFields(root, value)
case ENodeVehicleOdometer:
processENodeVehicleFields(root, value)
}
}
func processENodeVehicleFields(root string, value interface{}) {
v := reflect.ValueOf(value)
typeOfS := v.Type()
for i := 0; i < v.NumField(); i++ {
inner := fmt.Sprintf("%s/%s", root, typeOfS.Field(i).Name)
processENodeVehicleValue(inner, v.Field(i).Interface())
}
}
func enodeGetVehicle() bool {
log.Println("ENode: Fetching vehicles.")
req, err := http.NewRequest("GET", ENODE_GET_VEHICLES, nil)
if err != nil {
panic("enodeAuth fail: creating the request")
}
req.Header.Set("Authorization", enodeToken)
res, err := httpClient.Do(req)
if err != nil {
log.Println("enodeAuth fail: doing request")
return false
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
log.Println("enodeAuth fail: reading body")
return false
}
var vehicles ENodeVehicles
err = json.Unmarshal(b, &vehicles)
if err != nil {
log.Fatalf("Failed unmarshal to interface")
}
for _, vehicle := range vehicles.Data {
root := fmt.Sprintf("enode/vehicle/%s", vehicle.ID)
processENodeVehicleFields(root, vehicle)
// Process the Home Assistant helper tracker.
var location HomeAssistantLocation
location.Latitude = vehicle.Location.Latitude
location.Longitude = vehicle.Location.Longitude
location.BatteryLevel = float64(vehicle.ChargeState.BatteryLevel)
byteData, err := json.Marshal(location)
if err == nil {
topic := fmt.Sprintf("%s/HomeAssistant/Location", root)
token := mqttClient.Publish(topic, 0, false, string(byteData))
token.Wait()
} else {
log.Printf("Location could not be marshalled: %v", err)
}
}
return true
}
func enodeAuth() {
reqBody := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", ENODE_AUTH_URL, reqBody)
if err != nil {
panic("enodeAuth fail: creating the request")
}
req.SetBasicAuth(cfg.ENodeClient, cfg.ENodeSecret)
req.Header.Add("cache-control", "no-cache")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err := httpClient.Do(req)
if err != nil {
panic("enodeAuth fail: doing request")
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
panic("enodeAuth fail: reading body")
}
var a AccessToken
err = json.Unmarshal(b, &a)
if err != nil {
panic("enodeAuth fail: unmarshal failure")
}
enodeExpiresAt = time.Now().Add(time.Duration(a.ExpiresIn-15) * time.Second)
enodeExpired = false
enodeToken = fmt.Sprintf("Bearer %s", a.AccessToken)
}
func enodeLooper() {
var current time.Time
var next time.Time
var offset time.Duration = time.Second * 60
log.Println("ENode looper started.")
for !safeExit {
current = time.Now()
next = current.Add(offset)
if enodeExpired || enodeExpiresAt.Compare(time.Now()) < 0 {
log.Println("ENode: Authenticating.")
enodeAuth()
log.Println("ENode: Authenticated.")
}
if !enodeGetVehicle() {
enodeExpired = true
}
time.Sleep(time.Until(next))
}
log.Println("ENode looper is closed.")
}
func connectMqtt() {
{
b, err := ioutil.ReadFile("config.json")
if err != nil {
log.Fatalf("Failed to read config.json")
}
err = json.Unmarshal(b, &cfg)
if err != nil {
log.Fatalf("Failed to process config.json")
}
}
opts := mqtt.NewClientOptions()
opts.AddBroker(fmt.Sprintf("tcp://%s:%d", cfg.Broker, cfg.Port))
opts.SetClientID(cfg.ClientID)
opts.SetUsername(cfg.Username)
opts.SetPassword(cfg.Password)
opts.SetDefaultPublishHandler(messagePubHandler)
opts.OnConnect = connectHandler
opts.OnConnectionLost = connectLostHandler
mqttClient = mqtt.NewClient(opts)
if token := mqttClient.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
}
func cleanup() {
safeExit = false
if mqttClient.IsConnected() {
mqttClient.Disconnect(5000)
}
}
var messagePubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) {
// Use payload and topic if subscribed...
// msg.Payload(), msg.Topic()
}
var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) {
//
log.Println("MQTT Connected")
}
var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) {
// Connection lost.
log.Printf("MQTT connection lost: %v\n", err)
// Attempt to reconnect.
if !safeExit {
for {
mqttClient.Connect()
if mqttClient.IsConnected() {
break
}
time.Sleep(5 * time.Second)
}
}
}
func main() {
safeExit = false
httpClient = http.Client{}
connectMqtt()
go enodeLooper()
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
cleanup()
os.Exit(1)
}()
for {
runtime.Gosched()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment