Last active
April 23, 2025 22:45
-
-
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.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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