├── .gitignore ├── docs └── pictures │ └── mqtt-2025-06-30.png ├── internal ├── transmission │ ├── transmitter.go │ ├── abrp.go │ └── mqtt.go ├── config │ ├── defaults.go │ └── config.go ├── sensors │ ├── derived.go │ ├── sensor_ids.go │ ├── parser.go │ └── types.go ├── abrpapp │ └── checker.go ├── domain │ └── snapshot.go ├── bus │ └── bus.go ├── location │ └── location.go ├── app │ └── app.go ├── api │ └── diplus.go └── mqtt │ └── client.go ├── go.mod ├── go.sum ├── .github └── workflows │ └── build-and-release.yml ├── cmd └── byd-hass │ └── main.go ├── README.md └── install.sh /.gitignore: -------------------------------------------------------------------------------- 1 | build/** 2 | deploy.sh 3 | byd-hass 4 | .cursorrules -------------------------------------------------------------------------------- /docs/pictures/mqtt-2025-06-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkaberg/byd-hass/HEAD/docs/pictures/mqtt-2025-06-30.png -------------------------------------------------------------------------------- /internal/transmission/transmitter.go: -------------------------------------------------------------------------------- 1 | package transmission 2 | 3 | import "github.com/jkaberg/byd-hass/internal/sensors" 4 | 5 | // Transmitter defines the interface for transmitting sensor data 6 | type Transmitter interface { 7 | Transmit(data *sensors.SensorData) error 8 | IsConnected() bool 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jkaberg/byd-hass 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/eclipse/paho.mqtt.golang v1.4.3 7 | github.com/sirupsen/logrus v1.9.3 8 | ) 9 | 10 | require ( 11 | github.com/gorilla/websocket v1.5.0 // indirect 12 | golang.org/x/net v0.8.0 // indirect 13 | golang.org/x/sync v0.1.0 // indirect 14 | golang.org/x/sys v0.6.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /internal/config/defaults.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | const ( 6 | // Polling / transmission intervals 7 | DiplusPollInterval = 8 * time.Second // Poll local DiPlus API 8 | ABRPTransmitInterval = 10 * time.Second // Push data to ABRP (HTTP) 9 | MQTTTransmitInterval = 60 * time.Second // Publish data to MQTT 10 | 11 | // Operation time-outs (to avoid blocking goroutines) 12 | DiplusTimeout = 3 * time.Second // DiPlus API call 13 | MQTTTimeout = 5 * time.Second // MQTT publish 14 | ABRPTimeout = 4 * time.Second // ABRP HTTP call 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /internal/sensors/derived.go: -------------------------------------------------------------------------------- 1 | package sensors 2 | 3 | // DeriveChargingStatus derives a human-readable charging state from the raw 4 | // Diplus metrics. The logic is as follows: 5 | // 1. If ChargeGunState is nil or not equal to 2 → "disconnected". 6 | // 2. If ChargeGunState == 2 *and* EnginePower < -1 → "charging". 7 | // 3. Otherwise (gun connected but power >= -1) → "connected". 8 | // 9 | // This helper lives in the sensors package so that other components (MQTT 10 | // transmitter, ABRP, etc.) can reuse the logic without duplicating it. 11 | func DeriveChargingStatus(data *SensorData) string { 12 | if data == nil || data.ChargeGunState == nil || *data.ChargeGunState != 2 { 13 | return "disconnected" 14 | } 15 | 16 | // At this point the charge gun is physically connected. A negative engine power 17 | // (i.e. battery being charged) indicates active charging. A value near zero 18 | // means the gun is plugged in but no current is flowing. 19 | if data.EnginePower != nil && *data.EnginePower < -1 { 20 | return "charging" 21 | } 22 | 23 | return "connected" 24 | } 25 | -------------------------------------------------------------------------------- /internal/abrpapp/checker.go: -------------------------------------------------------------------------------- 1 | package abrpapp 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Checker struct { 13 | logger *logrus.Logger 14 | 15 | cacheTTL time.Duration 16 | lastChecked time.Time 17 | lastResult bool 18 | } 19 | 20 | // NewChecker returns a new instance with a small cache TTL. 21 | func NewChecker(logger *logrus.Logger) *Checker { 22 | return &Checker{ 23 | logger: logger, 24 | cacheTTL: 5 * time.Second, 25 | } 26 | } 27 | 28 | // IsRunning executes the adb pidof check unless the cached value is still 29 | // fresh. 30 | func (c *Checker) IsRunning() bool { 31 | if time.Since(c.lastChecked) < c.cacheTTL { 32 | return c.lastResult 33 | } 34 | 35 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 36 | defer cancel() 37 | out, err := exec.CommandContext(ctx, 38 | "/system/bin/pgrep", "-fx", "com.iternio.abrpapp").Output() 39 | 40 | running := false 41 | if err == nil && len(strings.TrimSpace(string(out))) > 0 { 42 | running = true 43 | } else if err != nil { 44 | c.logger.WithError(err).Debug("ADB pidof failed or ABRP app not running") 45 | } 46 | 47 | // cache 48 | c.lastChecked = time.Now() 49 | c.lastResult = running 50 | return running 51 | } 52 | -------------------------------------------------------------------------------- /internal/domain/snapshot.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "math" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/jkaberg/byd-hass/internal/sensors" 9 | ) 10 | 11 | // Changed returns true if *cur* differs from *prev* beyond tolerated jitter. 12 | // It zeroes the Timestamp field and ignores small GPS noise so that minor 13 | // location updates don't trigger a transmit. 14 | func Changed(prev, cur *sensors.SensorData) bool { 15 | if prev == nil && cur == nil { 16 | return false 17 | } 18 | if prev == nil || cur == nil { 19 | return true 20 | } 21 | 22 | p, c := *prev, *cur // copy 23 | p.Timestamp = time.Time{} 24 | c.Timestamp = time.Time{} 25 | 26 | // Ignore wall-clock date/time fields that naturally change every minute 27 | p.Year, p.Month, p.Day, p.Hour, p.Minute = nil, nil, nil, nil, nil 28 | c.Year, c.Month, c.Day, c.Hour, c.Minute = nil, nil, nil, nil, nil 29 | 30 | if p.Location != nil && c.Location != nil { 31 | const distThr = 10.0 // metres 32 | const bearThr = 5.0 // degrees 33 | dist := haversineMeters(p.Location.Latitude, p.Location.Longitude, 34 | c.Location.Latitude, c.Location.Longitude) 35 | bearingDiff := math.Abs(p.Location.Bearing - c.Location.Bearing) 36 | if bearingDiff > 180 { 37 | bearingDiff = 360 - bearingDiff 38 | } 39 | if dist < distThr && bearingDiff < bearThr { 40 | p.Location = nil 41 | c.Location = nil 42 | } 43 | } 44 | 45 | return !reflect.DeepEqual(p, c) 46 | } 47 | 48 | func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 { 49 | const r = 6371000.0 // Earth radius in metres 50 | dLat := toRad(lat2 - lat1) 51 | dLon := toRad(lon2 - lon1) 52 | lat1Rad := toRad(lat1) 53 | lat2Rad := toRad(lat2) 54 | 55 | a := math.Sin(dLat/2)*math.Sin(dLat/2) + 56 | math.Cos(lat1Rad)*math.Cos(lat2Rad)* 57 | math.Sin(dLon/2)*math.Sin(dLon/2) 58 | c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) 59 | return r * c 60 | } 61 | 62 | func toRad(deg float64) float64 { return deg * math.Pi / 180 } 63 | -------------------------------------------------------------------------------- /internal/bus/bus.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/jkaberg/byd-hass/internal/sensors" 7 | ) 8 | 9 | // Bus provides fan-out pub/sub semantics for *sensors.SensorData* messages. 10 | // Each Subscribe call gets its own channel that receives every future 11 | // publication. Past messages are not replayed. The implementation is safe for 12 | // concurrent publishers and subscribers. 13 | type Bus struct { 14 | mu sync.RWMutex 15 | subscribers []chan *sensors.SensorData 16 | } 17 | 18 | // New creates a ready-to-use Bus. 19 | func New() *Bus { return &Bus{} } 20 | 21 | // Subscribe returns a read-only channel that will receive all future 22 | // SensorData snapshots. 23 | func (b *Bus) Subscribe() <-chan *sensors.SensorData { 24 | ch := make(chan *sensors.SensorData, 1) // small buffer avoids blocking 25 | b.mu.Lock() 26 | b.subscribers = append(b.subscribers, ch) 27 | b.mu.Unlock() 28 | return ch 29 | } 30 | 31 | // Publish delivers the snapshot to all subscribers in a best-effort, non-blocking 32 | // way. If a subscriber's buffer is full, the subscriber is dropped to keep the 33 | // producer quick and the overall system from stalling. 34 | func (b *Bus) Publish(s *sensors.SensorData) { 35 | b.mu.RLock() 36 | subs := make([]chan *sensors.SensorData, len(b.subscribers)) 37 | copy(subs, b.subscribers) 38 | b.mu.RUnlock() 39 | 40 | for _, ch := range subs { 41 | select { 42 | case ch <- s: 43 | default: 44 | // Subscriber is currently busy; skip this snapshot instead of dropping the 45 | // subscriber entirely. The consumer will receive the next snapshot once 46 | // it has processed the current one. 47 | continue 48 | } 49 | } 50 | } 51 | 52 | func (b *Bus) dropSubscriber(ch chan *sensors.SensorData) { 53 | b.mu.Lock() 54 | for i, sub := range b.subscribers { 55 | if sub == ch { 56 | // remove without preserving order 57 | b.subscribers[i] = b.subscribers[len(b.subscribers)-1] 58 | b.subscribers = b.subscribers[:len(b.subscribers)-1] 59 | close(ch) 60 | break 61 | } 62 | } 63 | b.mu.Unlock() 64 | } 65 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= 5 | github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= 6 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 7 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 11 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 14 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 16 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 17 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 18 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 20 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 21 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 24 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | # Only run the workflow when Go source or build-related files change. 7 | paths: 8 | - '**/*.go' 9 | - '**/go.mod' 10 | - '**/go.sum' 11 | - 'build.sh' 12 | - '.github/workflows/build-and-release.yml' 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build-and-release: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v4 29 | with: 30 | go-version: '1.22.x' 31 | 32 | - name: Get short commit SHA 33 | id: vars 34 | run: | 35 | echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 36 | echo "timestamp=$(date -u +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT 37 | 38 | - name: Build ARM64 binary 39 | run: | 40 | chmod +x build.sh 41 | ./build.sh 42 | # The build script outputs build/byd-hass; rename for release artifact naming 43 | mv build/byd-hass byd-hass-arm64 44 | 45 | - name: Create build info 46 | run: | 47 | echo "Build Information" > build-info.txt 48 | echo "=================" >> build-info.txt 49 | echo "Version: ${{ steps.vars.outputs.short_sha }}" >> build-info.txt 50 | echo "Built: ${{ steps.vars.outputs.timestamp }}" >> build-info.txt 51 | echo "Target: linux/arm64" >> build-info.txt 52 | echo "Commit: ${{ github.sha }}" >> build-info.txt 53 | echo "" >> build-info.txt 54 | echo "Installation:" >> build-info.txt 55 | echo 'curl -L -o byd-hass https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.short_sha }}/byd-hass-arm64' >> build-info.txt 56 | echo "chmod +x byd-hass" >> build-info.txt 57 | echo "./byd-hass --help" >> build-info.txt 58 | 59 | - name: Delete existing release 60 | run: | 61 | gh release delete ${{ steps.vars.outputs.short_sha }} --yes || true 62 | git tag -d ${{ steps.vars.outputs.short_sha }} || true 63 | git push origin :refs/tags/${{ steps.vars.outputs.short_sha }} || true 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | 67 | - name: Create Release 68 | uses: softprops/action-gh-release@v1 69 | with: 70 | tag_name: ${{ steps.vars.outputs.short_sha }} 71 | name: "byd-hass build for commit ${{ steps.vars.outputs.short_sha }}" 72 | body: | 73 | ## byd-hass Binary Release 74 | 75 | **Version:** `${{ steps.vars.outputs.short_sha }}` 76 | **Built:** `${{ steps.vars.outputs.timestamp }}` 77 | **Target:** `linux/arm64` (Android/Termux compatible) 78 | **Commit:** `${{ github.sha }}` 79 | 80 | ### Installation 81 | ```bash 82 | # Download and make executable 83 | curl -L -o byd-hass https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.short_sha }}/byd-hass-arm64 84 | chmod +x byd-hass 85 | 86 | # Run with help to see options 87 | ./byd-hass --help 88 | ``` 89 | 90 | ### Usage Examples 91 | ```bash 92 | # Basic MQTT only 93 | ./byd-hass -mqtt-url "ws://user:pass@mqtt-broker:9001/mqtt" 94 | 95 | # With ABRP integration 96 | ./byd-hass \ 97 | -mqtt-url "ws://user:pass@mqtt-broker:9001/mqtt" \ 98 | -abrp-api-key "your-api-key" \ 99 | -abrp-vehicle-key "your-vehicle-key" 100 | 101 | # Verbose logging 102 | ./byd-hass -verbose -mqtt-url "ws://user:pass@mqtt-broker:9001/mqtt" 103 | ``` 104 | 105 | ### Changes in this build 106 | - Built from commit: ${{ github.sha }} 107 | files: | 108 | byd-hass-arm64 109 | build-info.txt 110 | draft: false 111 | prerelease: false 112 | env: 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // Config holds all configuration options for the BYD-HASS application 10 | type Config struct { 11 | // MQTT Configuration 12 | MQTTUrl string `json:"mqtt_url"` // MQTT URL (supports both WebSocket and standard MQTT) 13 | DiscoveryPrefix string `json:"discovery_prefix"` // Home Assistant discovery prefix 14 | 15 | // ABRP Configuration 16 | ABRPAPIKey string `json:"abrp_api_key"` // ABRP API key 17 | ABRPToken string `json:"abrp_token"` // ABRP user token 18 | 19 | // Device Configuration 20 | DeviceID string `json:"device_id"` // Unique device identifier 21 | 22 | // Application Configuration 23 | Verbose bool `json:"verbose"` // Enable verbose logging 24 | 25 | // ABRP Application Requirement 26 | // When true, telemetry will only be transmitted to ABRP when the Android 27 | // application "com.iternio.abrpapp" is detected to be running via ADB. 28 | // This can be toggled at runtime through the "-require-abrp-app" CLI flag 29 | // or the "BYD_HASS_REQUIRE_ABRP_APP" environment variable. 30 | RequireABRPApp bool `json:"require_abrp_app"` 31 | 32 | // API Configuration 33 | DiplusURL string `json:"diplus_url"` // Di-Plus API URL 34 | ExtendedPolling bool `json:"extended_polling"` // Use extended sensor polling for more data 35 | APITimeout int `json:"api_timeout"` // API request timeout in seconds (default: 10) 36 | 37 | // ABRP Configuration 38 | ABRPEnhanced bool `json:"abrp_enhanced"` // Use enhanced ABRP telemetry data 39 | ABRPLocation bool `json:"abrp_location"` // Include GPS location in ABRP data (if available) 40 | ABRPVehicleType string `json:"abrp_vehicle_type"` // ABRP vehicle type for better range estimation 41 | 42 | // Timing intervals (overridable via CLI flags / env vars) 43 | MQTTInterval time.Duration `json:"mqtt_interval"` // Interval between MQTT transmissions 44 | ABRPInterval time.Duration `json:"abrp_interval"` // Interval between ABRP transmissions 45 | } 46 | 47 | // GetDefaultConfig returns a configuration with sensible defaults 48 | func GetDefaultConfig() *Config { 49 | return &Config{ 50 | DiscoveryPrefix: "homeassistant", 51 | DeviceID: "", // Will be auto-generated 52 | Verbose: false, 53 | DiplusURL: "localhost:8988", 54 | 55 | ExtendedPolling: true, // Enable extended polling by default 56 | APITimeout: 10, // 10 second API timeout 57 | ABRPEnhanced: true, // Use enhanced ABRP data by default 58 | ABRPLocation: true, // Location ENABLED by default 59 | ABRPVehicleType: "byd:*", // Generic BYD vehicle type 60 | 61 | // Default intervals (can be overridden) 62 | MQTTInterval: MQTTTransmitInterval, 63 | ABRPInterval: ABRPTransmitInterval, 64 | RequireABRPApp: true, 65 | } 66 | } 67 | 68 | // Validate checks if the configuration is valid 69 | func (c *Config) Validate() error { 70 | // Basic validation 71 | if c.DeviceID == "" { 72 | return fmt.Errorf("device ID is required") 73 | } 74 | 75 | // MQTT validation - support both WebSocket and standard MQTT protocols 76 | if c.MQTTUrl != "" { 77 | if !strings.HasPrefix(c.MQTTUrl, "ws://") && 78 | !strings.HasPrefix(c.MQTTUrl, "wss://") && 79 | !strings.HasPrefix(c.MQTTUrl, "mqtt://") && 80 | !strings.HasPrefix(c.MQTTUrl, "mqtts://") { 81 | return fmt.Errorf("MQTT URL must use supported protocol (ws://, wss://, mqtt://, or mqtts://)") 82 | } 83 | } 84 | 85 | // ABRP validation 86 | if c.ABRPAPIKey != "" && c.ABRPToken == "" { 87 | return fmt.Errorf("ABRP token is required when API key is provided") 88 | } 89 | if c.ABRPToken != "" && c.ABRPAPIKey == "" { 90 | return fmt.Errorf("ABRP API key is required when token is provided") 91 | } 92 | 93 | // Set defaults for invalid values 94 | if c.APITimeout <= 0 { 95 | c.APITimeout = 10 // Set default 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // HasMQTT returns true if MQTT is configured 102 | func (c *Config) HasMQTT() bool { 103 | return c.MQTTUrl != "" 104 | } 105 | 106 | // HasABRP returns true if ABRP is configured 107 | func (c *Config) HasABRP() bool { 108 | return c.ABRPAPIKey != "" && c.ABRPToken != "" 109 | } 110 | 111 | // GetAPITimeout returns the API timeout as a duration 112 | func (c *Config) GetAPITimeout() time.Duration { 113 | return time.Duration(c.APITimeout) * time.Second 114 | } 115 | -------------------------------------------------------------------------------- /internal/location/location.go: -------------------------------------------------------------------------------- 1 | package location 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // LocationData from the JSON file 15 | type LocationData struct { 16 | Latitude float64 `json:"latitude"` 17 | Longitude float64 `json:"longitude"` 18 | Altitude float64 `json:"altitude"` 19 | Accuracy float64 `json:"accuracy"` 20 | VerticalAccuracy float64 `json:"vertical_accuracy"` 21 | Bearing float64 `json:"bearing"` 22 | Speed float64 `json:"speed"` 23 | ElapsedMs int64 `json:"elapsed_ms"` 24 | Provider string `json:"provider"` 25 | Timestamp time.Time `json:"-"` 26 | } 27 | 28 | type TermuxLocationProvider struct { 29 | logger *logrus.Logger 30 | mu sync.RWMutex 31 | cachedData *LocationData 32 | lastFetch time.Time 33 | cacheTTL time.Duration 34 | fetchTimeout time.Duration 35 | ctx context.Context 36 | cancel context.CancelFunc 37 | } 38 | 39 | // Create provider with background goroutine 40 | func NewTermuxLocationProvider(logger *logrus.Logger) *TermuxLocationProvider { 41 | ctx, cancel := context.WithCancel(context.Background()) 42 | 43 | p := &TermuxLocationProvider{ 44 | logger: logger, 45 | cacheTTL: 2 * time.Minute, 46 | fetchTimeout: 15 * time.Second, 47 | ctx: ctx, 48 | cancel: cancel, 49 | } 50 | 51 | go p.backgroundLocationFetcher() 52 | return p 53 | } 54 | 55 | // Read from /storage/emulated/0/bydhass/gps 56 | func (p *TermuxLocationProvider) fetchFromFile() (*LocationData, error) { 57 | const filePath = "/storage/emulated/0/bydhass/gps" 58 | 59 | data, err := os.ReadFile(filePath) 60 | if err != nil { 61 | return nil, fmt.Errorf("cannot read gps file: %w", err) 62 | } 63 | 64 | var raw struct { 65 | Latitude float64 `json:"latitude"` 66 | Longitude float64 `json:"longitude"` 67 | Speed float64 `json:"speed"` 68 | Accuracy float64 `json:"accuracy"` 69 | Battery float64 `json:"battery"` 70 | } 71 | 72 | if err := json.Unmarshal(data, &raw); err != nil { 73 | return nil, fmt.Errorf("invalid gps json: %w", err) 74 | } 75 | 76 | return &LocationData{ 77 | Latitude: raw.Latitude, 78 | Longitude: raw.Longitude, 79 | Speed: raw.Speed, 80 | Accuracy: raw.Accuracy, 81 | Provider: "termux-file", 82 | Timestamp: time.Now(), 83 | }, nil 84 | } 85 | 86 | func (p *TermuxLocationProvider) GetLocation() (*LocationData, error) { 87 | p.mu.RLock() 88 | defer p.mu.RUnlock() 89 | 90 | if p.cachedData == nil { 91 | return nil, fmt.Errorf("no location data available yet") 92 | } 93 | 94 | return &(*p.cachedData), nil 95 | } 96 | 97 | func (p *TermuxLocationProvider) backgroundLocationFetcher() { 98 | p.fetchLocationData() 99 | 100 | ticker := time.NewTicker(10 * time.Second) 101 | defer ticker.Stop() 102 | 103 | for { 104 | select { 105 | case <-p.ctx.Done(): 106 | return 107 | case <-ticker.C: 108 | p.fetchLocationData() 109 | } 110 | } 111 | } 112 | 113 | func (p *TermuxLocationProvider) fetchLocationData() { 114 | loc, err := p.fetchFromFile() 115 | if err != nil { 116 | p.logger.WithError(err).Warn("Failed reading GPS file; using default") 117 | p.setDefaultLocation() 118 | return 119 | } 120 | 121 | p.mu.Lock() 122 | p.cachedData = loc 123 | p.lastFetch = time.Now() 124 | p.mu.Unlock() 125 | 126 | p.logger.WithFields(logrus.Fields{ 127 | "latitude": loc.Latitude, 128 | "longitude": loc.Longitude, 129 | "speed": loc.Speed, 130 | "accuracy": loc.Accuracy, 131 | "provider": loc.Provider, 132 | }).Debug("Loaded GPS location from file") 133 | } 134 | 135 | func (p *TermuxLocationProvider) setDefaultLocation() { 136 | p.mu.Lock() 137 | defer p.mu.Unlock() 138 | 139 | if p.cachedData == nil { 140 | p.cachedData = &LocationData{ 141 | Latitude: 0, 142 | Longitude: 0, 143 | Provider: "default", 144 | Timestamp: time.Now(), 145 | } 146 | p.lastFetch = time.Now() 147 | } 148 | } 149 | 150 | func (p *TermuxLocationProvider) Stop() { 151 | p.cancel() 152 | } 153 | 154 | func (p *TermuxLocationProvider) IsLocationAvailable() bool { 155 | p.mu.RLock() 156 | defer p.mu.RUnlock() 157 | return p.cachedData != nil 158 | } 159 | 160 | func (p *TermuxLocationProvider) GetLastFetchTime() time.Time { 161 | p.mu.RLock() 162 | defer p.mu.RUnlock() 163 | return p.lastFetch 164 | } 165 | 166 | func (p *TermuxLocationProvider) SetCacheTTL(ttl time.Duration) { 167 | p.mu.Lock() 168 | defer p.mu.Unlock() 169 | p.cacheTTL = ttl 170 | } 171 | 172 | func (p *TermuxLocationProvider) SetFetchTimeout(timeout time.Duration) { 173 | p.mu.Lock() 174 | defer p.mu.Unlock() 175 | p.fetchTimeout = timeout 176 | } 177 | 178 | -------------------------------------------------------------------------------- /internal/sensors/sensor_ids.go: -------------------------------------------------------------------------------- 1 | package sensors 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "strconv" 7 | ) 8 | 9 | // MonitoredSensor represents a sensor that we (a) poll from Diplus and (b) 10 | // may expose to downstream integrations such as MQTT / ABRP / REST. 11 | // 12 | // • Every entry is included in each Diplus request (see PollSensorIDs). 13 | // • If Publish == true the raw value is allowed to leave the application – 14 | // currently that means it will appear in MQTT discovery/state payloads. 15 | // When we add other outputs (Prometheus, REST, etc.) they will consult the 16 | // same PublishedSensorIDs helper. 17 | // • Entries with Publish == false stay internal – useful for building derived 18 | // sensors or for future features we do not want to expose yet. 19 | // 20 | // To add a new sensor: 21 | // 1. Make sure it exists in sensors.AllSensors with a unique ID. 22 | // 2. Append its ID to "BYD_HASS_SENSOR_IDS" env, choosing Publish=true/false 23 | // in such manner: "ID:publish" for example "33:0,34:1", this will publish 24 | // id 34, and read but not publish id 33, you can omit ":1" as publish is 25 | // the default, so you can write use "33,34:1" with the same effect 26 | // 3. No other lists need editing. 27 | 28 | type MonitoredSensor struct { 29 | ID int // sensors.SensorDefinition.ID 30 | Publish bool // true → value may be published externally 31 | } 32 | 33 | // MonitoredSensors enumerates the subset of sensors our app currently cares 34 | // about. Keep this list tidy; polling *all* 100-ish sensors every 15 seconds 35 | // would waste bandwidth and CPU on the head-unit. 36 | // loadMonitoredSensorsFromEnv overrides the default MonitoredSensors 37 | 38 | // Default monitors 39 | var defaultMonitoredSensors = []MonitoredSensor{ 40 | {ID: 33, Publish: true}, // BatteryPercentage 41 | {ID: 34, Publish: true}, // FuelPercentage 42 | {ID: 2, Publish: true}, // Speed 43 | {ID: 3, Publish: true}, // Mileage 44 | {ID: 53, Publish: true}, // LF tire 45 | {ID: 54, Publish: true}, // RF tire 46 | {ID: 55, Publish: true}, // LR tire 47 | {ID: 56, Publish: true}, // RR tire 48 | {ID: 10, Publish: true}, // EnginePower 49 | {ID: 26, Publish: true}, // OutsideTemp 50 | {ID: 25, Publish: true}, // CabinTemp 51 | 52 | // Internal-only 53 | {ID: 12, Publish: false}, 54 | } 55 | 56 | // Global value initialized at startup 57 | var MonitoredSensors = loadMonitoredSensorsFromEnv() 58 | 59 | // --------------------------------------------------------- 60 | 61 | func loadMonitoredSensorsFromEnv() []MonitoredSensor { 62 | raw := os.Getenv("BYD_HASS_SENSOR_IDS") 63 | if raw == "" { 64 | return defaultMonitoredSensors 65 | } 66 | 67 | parts := strings.Split(raw, ",") 68 | sensorsList := make([]MonitoredSensor, 0, len(parts)) 69 | 70 | for _, p := range parts { 71 | p = strings.TrimSpace(p) 72 | if p == "" { 73 | continue 74 | } 75 | 76 | publish := true 77 | 78 | // Format supports: "33" or "12:0" or "53:1" 79 | idStr := p 80 | if strings.Contains(p, ":") { 81 | pieces := strings.SplitN(p, ":", 2) 82 | idStr = pieces[0] 83 | if pieces[1] == "0" { 84 | publish = false 85 | } 86 | } 87 | 88 | id, err := strconv.Atoi(idStr) 89 | if err != nil { 90 | continue 91 | } 92 | 93 | sensorsList = append(sensorsList, MonitoredSensor{ 94 | ID: id, 95 | Publish: publish, 96 | }) 97 | } 98 | 99 | if len(sensorsList) == 0 { 100 | return defaultMonitoredSensors 101 | } 102 | 103 | return sensorsList 104 | } 105 | 106 | // PollSensorIDs returns every sensor ID we must include in the Diplus API 107 | // template. 108 | func PollSensorIDs() []int { 109 | ids := make([]int, 0, len(MonitoredSensors)) 110 | for _, s := range MonitoredSensors { 111 | ids = append(ids, s.ID) 112 | } 113 | return ids 114 | } 115 | 116 | // PublishedSensorIDs returns only the IDs whose Publish flag is true. 117 | func PublishedSensorIDs() []int { 118 | ids := make([]int, 0, len(MonitoredSensors)) 119 | for _, s := range MonitoredSensors { 120 | if s.Publish { 121 | ids = append(ids, s.ID) 122 | } 123 | } 124 | return ids 125 | } 126 | 127 | // ----------------------------------------------------------------------------- 128 | // Integration Notes 129 | // ----------------------------------------------------------------------------- 130 | // A Better Route Planner (ABRP) consumes the following SensorDefinition IDs via 131 | // internal/transmission/abrp.go. Make sure they remain present in 132 | // MonitoredSensors – they can be Publish=false if you don’t want them in other 133 | // outputs. 134 | // 135 | // 33 BatteryPercentage (soc) 136 | // 2 Speed (speed / is_parked) 137 | // 3 Mileage (odometer) 138 | // 10 EnginePower (power, is_charging, is_dcfc) 139 | // 12 ChargeGunState (is_charging, is_dcfc) 140 | // 15 AvgBatteryTemp (batt_temp) 141 | // 17 MaxBatteryVoltage (voltage / current) 142 | // 25 CabinTemperature (cabin_temp) 143 | // 26 OutsideTemperature (ext_temp) 144 | // 29 BatteryCapacity (capacity, soe) 145 | // 53-56 TirePressures LF/RF/LR/RR (tire_pressure_* – converted to kPa) 146 | // 77 ACStatus (hvac_power) 147 | // 78 FanSpeedLevel (hvac_power) 148 | // ----------------------------------------------------------------------------- 149 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jkaberg/byd-hass/internal/api" 9 | "github.com/jkaberg/byd-hass/internal/bus" 10 | "github.com/jkaberg/byd-hass/internal/config" 11 | "github.com/jkaberg/byd-hass/internal/domain" 12 | "github.com/jkaberg/byd-hass/internal/location" 13 | "github.com/jkaberg/byd-hass/internal/sensors" 14 | "github.com/jkaberg/byd-hass/internal/transmission" 15 | "github.com/sirupsen/logrus" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | // Adaptive ABRP intervals ------------------------------------------------ 20 | const ( 21 | abrpDrivingInterval = 10 * time.Second // default while moving / charging 22 | abrpIdleInterval = 120 * time.Second // when parked & not charging 23 | ) 24 | 25 | func computeABRPInterval(data *sensors.SensorData) time.Duration { 26 | if data == nil { 27 | return abrpDrivingInterval 28 | } 29 | // Fast cadence when speed > 0 km/h 30 | if data.Speed != nil && *data.Speed > 0 { 31 | return abrpDrivingInterval 32 | } 33 | // Fast cadence when actively charging 34 | if sensors.DeriveChargingStatus(data) == "charging" { 35 | return abrpDrivingInterval 36 | } 37 | // Otherwise we're parked / idle 38 | return abrpIdleInterval 39 | } 40 | 41 | // Run launches the hexagonal architecture and blocks until ctx is cancelled. 42 | func Run( 43 | parentCtx context.Context, 44 | cfg *config.Config, 45 | diplusClient *api.DiplusClient, 46 | locationProvider *location.TermuxLocationProvider, 47 | mqttTx *transmission.MQTTTransmitter, 48 | abrpTx *transmission.ABRPTransmitter, 49 | logger *logrus.Logger, 50 | ) { 51 | ctx, cancel := context.WithCancel(parentCtx) 52 | go func() { 53 | <-parentCtx.Done() 54 | cancel() 55 | }() 56 | 57 | messageBus := bus.New() 58 | grp, ctx := errgroup.WithContext(ctx) 59 | 60 | // Collector ----------------------------------------------------------- 61 | grp.Go(func() error { 62 | ticker := time.NewTicker(config.DiplusPollInterval) 63 | defer ticker.Stop() 64 | for { 65 | select { 66 | case <-ctx.Done(): 67 | return ctx.Err() 68 | case <-ticker.C: 69 | sensorData, err := diplusClient.Poll() 70 | if err != nil { 71 | logger.WithError(err).Warn("collector: poll failed") 72 | continue 73 | } 74 | if cfg.ABRPLocation && locationProvider != nil { 75 | if loc, err := locationProvider.GetLocation(); err == nil { 76 | sensorData.Location = loc 77 | } 78 | } 79 | messageBus.Publish(sensorData) 80 | } 81 | } 82 | }) 83 | 84 | // Central scheduler ---------------------------------------------------- 85 | 86 | sub := messageBus.Subscribe() 87 | 88 | type txState struct { 89 | interval time.Duration 90 | lastSent time.Time 91 | lastSnap *sensors.SensorData 92 | sendFn func(context.Context, *sensors.SensorData, *logrus.Logger) error 93 | name string 94 | } 95 | 96 | var states []txState 97 | if mqttTx != nil { 98 | states = append(states, txState{ 99 | interval: cfg.MQTTInterval, 100 | lastSent: time.Now().Add(-cfg.MQTTInterval), 101 | sendFn: func(c context.Context, s *sensors.SensorData, l *logrus.Logger) error { 102 | return transmitToMQTTAsync(c, mqttTx, s, l) 103 | }, 104 | name: "MQTT", 105 | }) 106 | } 107 | if abrpTx != nil { 108 | states = append(states, txState{ 109 | interval: cfg.ABRPInterval, 110 | lastSent: time.Now().Add(-cfg.ABRPInterval), 111 | sendFn: func(c context.Context, s *sensors.SensorData, l *logrus.Logger) error { 112 | return transmitToABRPAsync(c, abrpTx, s, l) 113 | }, 114 | name: "ABRP", 115 | }) 116 | } 117 | 118 | grp.Go(func() error { 119 | var latest *sensors.SensorData 120 | ticker := time.NewTicker(1 * time.Second) 121 | defer ticker.Stop() 122 | for { 123 | select { 124 | case <-ctx.Done(): 125 | return ctx.Err() 126 | case snap, ok := <-sub: 127 | if !ok { 128 | return nil 129 | } 130 | latest = snap 131 | case <-ticker.C: 132 | if latest == nil { 133 | continue 134 | } 135 | now := time.Now() 136 | for i := range states { 137 | st := &states[i] 138 | // Dynamic interval for ABRP depending on vehicle state. 139 | interval := st.interval 140 | if st.name == "ABRP" { 141 | interval = computeABRPInterval(latest) 142 | } 143 | 144 | if now.Sub(st.lastSent) < interval { 145 | continue 146 | } 147 | if !domain.Changed(st.lastSnap, latest) { 148 | continue 149 | } 150 | if err := st.sendFn(ctx, latest, logger); err != nil { 151 | logger.WithError(err).Warn(st.name + " transmit failed") 152 | // Ensure we retry even if no data change. 153 | // Reset lastSnap so Changed() will evaluate to true on the next 154 | // scheduler tick, and bump lastSent so we still respect the 155 | // configured transmission interval. 156 | st.lastSnap = nil 157 | st.lastSent = now 158 | } else { 159 | st.lastSnap = latest 160 | st.lastSent = now 161 | } 162 | } 163 | } 164 | } 165 | }) 166 | 167 | if err := grp.Wait(); err != nil && err != context.Canceled { 168 | logger.WithError(err).Warn("app: background group exited") 169 | } 170 | } 171 | 172 | func transmitToABRPAsync(ctx context.Context, tx *transmission.ABRPTransmitter, data *sensors.SensorData, logger *logrus.Logger) error { 173 | if tx == nil || data == nil { 174 | return nil 175 | } 176 | // Bound the transmission time so that a prolonged network outage does not 177 | // block the central scheduler indefinitely. 178 | ctxTx, cancel := context.WithTimeout(ctx, 60*time.Second) 179 | defer cancel() 180 | 181 | if err := tx.TransmitWithContext(ctxTx, data); err != nil { 182 | return fmt.Errorf("ABRP transmit failed: %w", err) 183 | } 184 | return nil 185 | } 186 | 187 | func transmitToMQTTAsync(ctx context.Context, tx *transmission.MQTTTransmitter, data *sensors.SensorData, logger *logrus.Logger) error { 188 | if tx == nil || data == nil { 189 | return nil 190 | } 191 | _ = ctx 192 | if err := tx.Transmit(data); err != nil { 193 | return fmt.Errorf("MQTT transmit failed: %w", err) 194 | } 195 | return nil 196 | } 197 | -------------------------------------------------------------------------------- /cmd/byd-hass/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net" 8 | "os" 9 | "os/signal" 10 | "strconv" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/jkaberg/byd-hass/internal/api" 15 | "github.com/jkaberg/byd-hass/internal/app" 16 | "github.com/jkaberg/byd-hass/internal/config" 17 | "github.com/jkaberg/byd-hass/internal/location" 18 | "github.com/jkaberg/byd-hass/internal/mqtt" 19 | "github.com/jkaberg/byd-hass/internal/transmission" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | // version is injected at build time via ldflags 24 | var version = "dev" 25 | 26 | func main() { 27 | cfg, debugMode := parseFlags() 28 | 29 | // Debug path ------------------------------------------------------------------ 30 | if debugMode { 31 | runDebugMode(cfg) 32 | return 33 | } 34 | 35 | logger := setupLogger(cfg.Verbose) 36 | setupCustomDNSResolver(logger) 37 | 38 | logger.WithFields(logrus.Fields{ 39 | "version": version, 40 | "device_id": cfg.DeviceID, 41 | "poll": config.DiplusPollInterval, 42 | "abrp_int": cfg.ABRPInterval, 43 | "mqtt_int": cfg.MQTTInterval, 44 | }).Info("Starting BYD-HASS v2") 45 | 46 | ctx, cancel := context.WithCancel(context.Background()) 47 | defer cancel() 48 | 49 | sig := make(chan os.Signal, 1) 50 | signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) 51 | go func() { 52 | <-sig 53 | logger.Info("Shutdown signal received") 54 | cancel() 55 | }() 56 | 57 | // Core clients --------------------------------------------------------------- 58 | diplusURL := fmt.Sprintf("http://%s/api/getDiPars", cfg.DiplusURL) 59 | diplusClient := api.NewDiplusClient(diplusURL, logger) 60 | 61 | var locProvider *location.TermuxLocationProvider 62 | if cfg.ABRPLocation { 63 | locProvider = location.NewTermuxLocationProvider(logger) 64 | defer locProvider.Stop() 65 | } 66 | 67 | // Transmitters --------------------------------------------------------------- 68 | var mqttTx *transmission.MQTTTransmitter 69 | if cfg.MQTTUrl != "" { 70 | mqttClient, err := mqtt.NewClient(cfg.MQTTUrl, cfg.DeviceID, logger) 71 | if err != nil { 72 | logger.WithError(err).Fatal("Failed to create MQTT client") 73 | } 74 | mqttTx = transmission.NewMQTTTransmitter(mqttClient, cfg.DeviceID, cfg.DiscoveryPrefix, logger) 75 | logger.Info("MQTT transmitter ready") 76 | } 77 | 78 | var abrpTx *transmission.ABRPTransmitter 79 | if cfg.ABRPAPIKey != "" && cfg.ABRPToken != "" { 80 | abrpTx = transmission.NewABRPTransmitter(cfg.ABRPAPIKey, cfg.ABRPToken, logger) 81 | logger.WithField("abrp_status", abrpTx.GetConnectionStatus()).Info("ABRP transmitter ready") 82 | } 83 | 84 | if mqttTx == nil && abrpTx == nil { 85 | logger.Warn("No transmitters configured; data will only be logged") 86 | } 87 | 88 | // Run application ------------------------------------------------------------ 89 | app.Run(ctx, cfg, diplusClient, locProvider, mqttTx, abrpTx, logger) 90 | 91 | <-ctx.Done() 92 | logger.Info("BYD-HASS stopped") 93 | } 94 | 95 | // ----------------------------------------------------------------------------- 96 | // Helpers & Flags 97 | // ----------------------------------------------------------------------------- 98 | 99 | func parseFlags() (*config.Config, bool) { 100 | cfg := config.GetDefaultConfig() 101 | 102 | showVersion := flag.Bool("version", false, "Show version and exit") 103 | debug := flag.Bool("debug", false, "Run comprehensive sensor debugging and exit") 104 | 105 | flag.StringVar(&cfg.MQTTUrl, "mqtt-url", getEnv("BYD_HASS_MQTT_URL", cfg.MQTTUrl), "MQTT URL") 106 | flag.StringVar(&cfg.DiplusURL, "diplus-url", getEnv("BYD_HASS_DIPLUS_URL", cfg.DiplusURL), "Di-Plus host:port") 107 | flag.StringVar(&cfg.ABRPAPIKey, "abrp-api-key", getEnv("BYD_HASS_ABRP_API_KEY", cfg.ABRPAPIKey), "ABRP API key") 108 | flag.StringVar(&cfg.ABRPToken, "abrp-token", getEnv("BYD_HASS_ABRP_TOKEN", cfg.ABRPToken), "ABRP user token") 109 | flag.StringVar(&cfg.DeviceID, "device-id", getEnv("BYD_HASS_DEVICE_ID", generateDeviceID()), "Device identifier") 110 | flag.BoolVar(&cfg.Verbose, "verbose", getEnv("BYD_HASS_VERBOSE", "false") == "true", "Verbose logging") 111 | flag.StringVar(&cfg.DiscoveryPrefix, "discovery-prefix", getEnv("BYD_HASS_DISCOVERY_PREFIX", cfg.DiscoveryPrefix), "HA discovery prefix") 112 | 113 | mqttIntervalStr := flag.String("mqtt-interval", getEnv("BYD_HASS_MQTT_INTERVAL", ""), "MQTT interval (e.g. 60s)") 114 | abrpIntervalStr := flag.String("abrp-interval", getEnv("BYD_HASS_ABRP_INTERVAL", ""), "ABRP interval (e.g. 10s)") 115 | 116 | flag.Parse() 117 | 118 | if *showVersion { 119 | fmt.Printf("byd-hass %s\n", version) 120 | os.Exit(0) 121 | } 122 | 123 | // Duration overrides 124 | if *mqttIntervalStr != "" { 125 | if d, err := time.ParseDuration(*mqttIntervalStr); err == nil && d > 0 { 126 | cfg.MQTTInterval = d 127 | } else if v, err2 := strconv.Atoi(*mqttIntervalStr); err2 == nil && v > 0 { 128 | cfg.MQTTInterval = time.Duration(v) * time.Second 129 | } 130 | } 131 | if *abrpIntervalStr != "" { 132 | if d, err := time.ParseDuration(*abrpIntervalStr); err == nil && d > 0 { 133 | cfg.ABRPInterval = d 134 | } else if v, err2 := strconv.Atoi(*abrpIntervalStr); err2 == nil && v > 0 { 135 | cfg.ABRPInterval = time.Duration(v) * time.Second 136 | } 137 | } 138 | 139 | return cfg, *debug 140 | } 141 | 142 | func getEnv(key, def string) string { 143 | if v := os.Getenv(key); v != "" { 144 | return v 145 | } 146 | return def 147 | } 148 | 149 | func generateDeviceID() string { return "byd_car" } 150 | 151 | func setupLogger(verbose bool) *logrus.Logger { 152 | l := logrus.New() 153 | l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true, TimestampFormat: time.RFC3339}) 154 | if verbose { 155 | l.SetLevel(logrus.DebugLevel) 156 | } else { 157 | l.SetLevel(logrus.InfoLevel) 158 | } 159 | return l 160 | } 161 | 162 | func setupCustomDNSResolver(logger *logrus.Logger) { 163 | net.DefaultResolver = &net.Resolver{ 164 | PreferGo: true, 165 | Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 166 | d := net.Dialer{Timeout: time.Second} 167 | return d.DialContext(ctx, network, "1.1.1.1:53") 168 | }, 169 | } 170 | logger.Debug("Custom DNS resolver installed (1.1.1.1)") 171 | } 172 | 173 | func runDebugMode(cfg *config.Config) { 174 | logger := setupLogger(true) 175 | diplusURL := fmt.Sprintf("http://%s/api/getDiPars", cfg.DiplusURL) 176 | client := api.NewDiplusClient(diplusURL, logger) 177 | if err := client.CompareAllSensors(); err != nil { 178 | logger.WithError(err).Fatal("Debug mode failed") 179 | } 180 | os.Exit(0) 181 | } 182 | -------------------------------------------------------------------------------- /internal/api/diplus.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/jkaberg/byd-hass/internal/sensors" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // DiplusClient handles communication with the local Diplus API 15 | type DiplusClient struct { 16 | baseURL string 17 | httpClient *http.Client 18 | logger *logrus.Logger 19 | } 20 | 21 | // NewDiplusClient creates a new Diplus API client 22 | func NewDiplusClient(baseURL string, logger *logrus.Logger) *DiplusClient { 23 | return &DiplusClient{ 24 | baseURL: baseURL, 25 | httpClient: &http.Client{ 26 | Timeout: 10 * time.Second, 27 | }, 28 | logger: logger, 29 | } 30 | } 31 | 32 | // GetSensorData fetches sensor data for the specified sensor IDs 33 | func (c *DiplusClient) GetSensorData(sensorIDs []int) (*sensors.SensorData, error) { 34 | // Build the template string with Chinese sensor names 35 | template := c.buildAPITemplate(sensorIDs) 36 | if template == "" { 37 | return nil, fmt.Errorf("no valid sensors found for IDs: %v", sensorIDs) 38 | } 39 | 40 | //c.logger.WithField("template", template).Debug("Built API template") 41 | 42 | // Make the HTTP request 43 | responseBody, err := c.makeRequest(template) 44 | if err != nil { 45 | return nil, fmt.Errorf("API request failed: %w", err) 46 | } 47 | 48 | // Parse the response 49 | sensorData, err := sensors.ParseAPIResponse(responseBody) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to parse API response: %w", err) 52 | } 53 | 54 | // Validate the data 55 | if warnings := sensors.ValidateSensorData(sensorData); len(warnings) > 0 { 56 | for _, warning := range warnings { 57 | c.logger.Warn(warning) 58 | } 59 | } 60 | 61 | c.logger.WithField("active_sensors", len(sensors.GetNonNilFields(sensorData))).Debug("Successfully parsed sensor data") 62 | 63 | return sensorData, nil 64 | } 65 | 66 | // buildAPITemplate creates the API template string using Chinese sensor names 67 | func (c *DiplusClient) buildAPITemplate(sensorIDs []int) string { 68 | var parts []string 69 | 70 | for _, id := range sensorIDs { 71 | sensor := sensors.GetSensorByID(id) 72 | if sensor == nil { 73 | c.logger.WithField("sensor_id", id).Warn("Unknown sensor ID, skipping") 74 | continue 75 | } 76 | 77 | // Use the struct FieldName directly as the key (e.g. BatteryPercentage) 78 | // This ensures the same identifier is echoed back by Diplus, eliminating 79 | // any key-translation logic in the parser. 80 | key := sensor.FieldName 81 | 82 | // Create template part: key:{Chinese_name} 83 | part := fmt.Sprintf("%s:{%s}", key, sensor.ChineseName) 84 | parts = append(parts, part) 85 | 86 | //c.logger.WithFields(logrus.Fields{ 87 | // "sensor_id": id, 88 | // "chinese_name": sensor.ChineseName, 89 | // "field_name": sensor.FieldName, 90 | // "key": key, 91 | //}).Debug("Added sensor to template") 92 | } 93 | 94 | if len(parts) == 0 { 95 | return "" 96 | } 97 | 98 | template := fmt.Sprintf("%s", parts[0]) 99 | for i := 1; i < len(parts); i++ { 100 | template += "|" + parts[i] 101 | } 102 | 103 | return template 104 | } 105 | 106 | // makeRequest makes the HTTP request to the Diplus API 107 | func (c *DiplusClient) makeRequest(template string) ([]byte, error) { 108 | // URL encode the template 109 | encodedTemplate := url.QueryEscape(template) 110 | 111 | // Build the full URL 112 | fullURL := fmt.Sprintf("%s?text=%s", c.baseURL, encodedTemplate) 113 | 114 | //c.logger.WithField("url", fullURL).Debug("Making API request") 115 | 116 | // Make the request 117 | resp, err := c.httpClient.Get(fullURL) 118 | if err != nil { 119 | return nil, fmt.Errorf("HTTP request failed: %w", err) 120 | } 121 | defer resp.Body.Close() 122 | 123 | // Check status code 124 | if resp.StatusCode != http.StatusOK { 125 | return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, resp.Status) 126 | } 127 | 128 | // Read response body 129 | body, err := io.ReadAll(resp.Body) 130 | if err != nil { 131 | return nil, fmt.Errorf("failed to read response body: %w", err) 132 | } 133 | 134 | c.logger.WithFields(logrus.Fields{ 135 | "status_code": resp.StatusCode, 136 | "response_size": len(body), 137 | }).Debug("Received API response") 138 | 139 | return body, nil 140 | } 141 | 142 | // GetAllSensorData fetches data for all available sensors 143 | func (c *DiplusClient) GetAllSensorData() (*sensors.SensorData, error) { 144 | return c.GetSensorData(sensors.GetAllSensorIDs()) 145 | } 146 | 147 | // IsHealthy checks if the Diplus API is responding 148 | func (c *DiplusClient) IsHealthy() bool { 149 | // Try to fetch a minimal sensor set to test connectivity 150 | testSensorIDs := []int{33} // Just battery percentage 151 | _, err := c.GetSensorData(testSensorIDs) 152 | return err == nil 153 | } 154 | 155 | // GetSensorInfo returns information about a specific sensor 156 | func (c *DiplusClient) GetSensorInfo(sensorID int) *sensors.SensorDefinition { 157 | return sensors.GetSensorByID(sensorID) 158 | } 159 | 160 | // GetAllSensorInfo returns information about all available sensors 161 | func (c *DiplusClient) GetAllSensorInfo() []sensors.SensorDefinition { 162 | return sensors.AllSensors 163 | } 164 | 165 | // SetTimeout configures the HTTP client timeout 166 | func (c *DiplusClient) SetTimeout(timeout time.Duration) { 167 | c.httpClient.Timeout = timeout 168 | } 169 | 170 | // SetLogger updates the logger instance 171 | func (c *DiplusClient) SetLogger(logger *logrus.Logger) { 172 | c.logger = logger 173 | } 174 | 175 | // CompareAllSensors queries all sensors and compares raw vs parsed values 176 | func (c *DiplusClient) CompareAllSensors() error { 177 | c.logger.Debug("Diplus: querying all sensors for comparison") 178 | 179 | // Get all sensor data 180 | sensorData, err := c.GetAllSensorData() 181 | if err != nil { 182 | return fmt.Errorf("failed to get sensor data: %w", err) 183 | } 184 | 185 | // Also get the raw response for comparison 186 | allSensorIDs := sensors.GetAllSensorIDs() 187 | template := c.buildAPITemplate(allSensorIDs) 188 | responseBody, err := c.makeRequest(template) 189 | if err != nil { 190 | return fmt.Errorf("failed to get raw API response: %w", err) 191 | } 192 | 193 | c.logger.Debug("Diplus: comparing raw vs parsed values") 194 | sensors.CompareRawVsParsed(responseBody, sensorData) 195 | 196 | return nil 197 | } 198 | 199 | // Poll polls the Diplus API for sensor data 200 | func (c *DiplusClient) Poll() (*sensors.SensorData, error) { 201 | c.logger.Debug("Polling Diplus API for sensor data...") 202 | // For now, we use a minimal set of essential sensors. 203 | return c.GetSensorData(sensors.PollSensorIDs()) 204 | } 205 | -------------------------------------------------------------------------------- /internal/mqtt/client.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | mqtt "github.com/eclipse/paho.mqtt.golang" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // Client wraps the MQTT client with additional functionality 15 | type Client struct { 16 | client mqtt.Client 17 | deviceID string 18 | logger *logrus.Logger 19 | } 20 | 21 | // NewClient creates a new MQTT client with support for both WebSocket and standard MQTT protocols 22 | func NewClient(mqttURL, deviceID string, logger *logrus.Logger) (*Client, error) { 23 | // Parse the MQTT URL 24 | parsedURL, err := url.Parse(mqttURL) 25 | if err != nil { 26 | return nil, fmt.Errorf("invalid MQTT URL: %w", err) 27 | } 28 | 29 | // Generate client ID 30 | clientID := fmt.Sprintf("byd-hass-%s", deviceID) 31 | 32 | // Configure MQTT client options 33 | opts := mqtt.NewClientOptions() 34 | 35 | // Handle different protocol schemes 36 | var brokerURL string 37 | switch parsedURL.Scheme { 38 | case "ws": 39 | // WebSocket MQTT - use URL as-is 40 | brokerURL = mqttURL 41 | logger.Debug("Using WebSocket MQTT connection") 42 | case "wss": 43 | brokerURL = mqttURL 44 | logger.Debug("Using secure WebSocket MQTT connection") 45 | opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) 46 | case "mqtt": 47 | // Standard MQTT - convert to tcp:// 48 | brokerURL = strings.Replace(mqttURL, "mqtt://", "tcp://", 1) 49 | logger.Debug("Using standard MQTT connection (TCP)") 50 | case "mqtts": 51 | // Secure MQTT - convert to ssl:// 52 | brokerURL = strings.Replace(mqttURL, "mqtts://", "ssl://", 1) 53 | logger.Debug("Using secure MQTT connection (SSL/TLS)") 54 | // Disable certificate verification to support self-signed certs 55 | opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) 56 | default: 57 | return nil, fmt.Errorf("unsupported protocol scheme: %s (supported: ws, wss, mqtt, mqtts)", parsedURL.Scheme) 58 | } 59 | 60 | opts.AddBroker(brokerURL) 61 | opts.SetClientID(clientID) 62 | opts.SetCleanSession(true) 63 | opts.SetAutoReconnect(true) 64 | opts.SetKeepAlive(60 * time.Second) 65 | opts.SetPingTimeout(1 * time.Second) 66 | opts.SetConnectTimeout(5 * time.Second) 67 | opts.SetMaxReconnectInterval(10 * time.Second) 68 | 69 | // lets not use will for now - but maybe later 70 | //willTopic := fmt.Sprintf("byd_car/%s/availability", deviceID) 71 | ///opts.SetWill(willTopic, "offline", 1, true) 72 | 73 | // Set credentials if provided in URL 74 | if parsedURL.User != nil { 75 | username := parsedURL.User.Username() 76 | password, _ := parsedURL.User.Password() 77 | opts.SetUsername(username) 78 | opts.SetPassword(password) 79 | } 80 | 81 | // Set connection handlers 82 | opts.SetConnectionLostHandler(func(client mqtt.Client, err error) { 83 | logger.WithError(err).Warn("MQTT connection lost") 84 | }) 85 | 86 | opts.SetReconnectingHandler(func(client mqtt.Client, opts *mqtt.ClientOptions) { 87 | logger.Debug("MQTT reconnecting...") 88 | }) 89 | 90 | firstConnect := true 91 | opts.SetOnConnectHandler(func(client mqtt.Client) { 92 | if firstConnect { 93 | logger.Debug("MQTT connected") 94 | firstConnect = false 95 | } else { 96 | logger.Info("MQTT reconnected") 97 | } 98 | }) 99 | 100 | // Create client 101 | client := mqtt.NewClient(opts) 102 | 103 | // Connect to broker 104 | if token := client.Connect(); token.Wait() && token.Error() != nil { 105 | return nil, fmt.Errorf("failed to connect to MQTT broker: %w", token.Error()) 106 | } 107 | 108 | logger.WithFields(logrus.Fields{ 109 | "broker": cleanURL(mqttURL), 110 | "protocol": parsedURL.Scheme, 111 | "client_id": clientID, 112 | }).Info("MQTT client connected") 113 | 114 | return &Client{ 115 | client: client, 116 | deviceID: deviceID, 117 | logger: logger, 118 | }, nil 119 | } 120 | 121 | // Publish publishes a message to the specified topic 122 | func (c *Client) Publish(topic string, payload []byte, retained bool) error { 123 | qos := byte(1) // At least once delivery 124 | token := c.client.Publish(topic, qos, retained, payload) 125 | 126 | // Avoid potential deadlocks: wait for completion with a timeout instead of indefinitely. 127 | const pubTimeout = 5 * time.Second 128 | if !token.WaitTimeout(pubTimeout) { 129 | return fmt.Errorf("publish to topic %s timed out after %s", topic, pubTimeout) 130 | } 131 | if token.Error() != nil { 132 | return fmt.Errorf("failed to publish to topic %s: %w", topic, token.Error()) 133 | } 134 | 135 | c.logger.WithFields(logrus.Fields{ 136 | "topic": topic, 137 | "size": len(payload), 138 | "retained": retained, 139 | }).Debug("Published MQTT message") 140 | 141 | return nil 142 | } 143 | 144 | // Subscribe subscribes to a topic with a message handler 145 | func (c *Client) Subscribe(topic string, handler mqtt.MessageHandler) error { 146 | qos := byte(1) 147 | token := c.client.Subscribe(topic, qos, handler) 148 | 149 | // Prevent indefinite blocking on slow or lost connections. 150 | const subTimeout = 5 * time.Second 151 | if !token.WaitTimeout(subTimeout) { 152 | return fmt.Errorf("subscribe to topic %s timed out after %s", topic, subTimeout) 153 | } 154 | if token.Error() != nil { 155 | return fmt.Errorf("failed to subscribe to topic %s: %w", topic, token.Error()) 156 | } 157 | 158 | c.logger.WithField("topic", topic).Debug("Subscribed to MQTT topic") 159 | return nil 160 | } 161 | 162 | // IsConnected returns true if the client is connected 163 | func (c *Client) IsConnected() bool { 164 | return c.client.IsConnected() 165 | } 166 | 167 | // Disconnect disconnects the client 168 | func (c *Client) Disconnect(quiesce uint) { 169 | c.client.Disconnect(quiesce) 170 | c.logger.Debug("MQTT client disconnected") 171 | } 172 | 173 | // GetDeviceID returns the device ID 174 | func (c *Client) GetDeviceID() string { 175 | return c.deviceID 176 | } 177 | 178 | // cleanURL removes credentials from URL for logging 179 | func cleanURL(rawURL string) string { 180 | parsed, err := url.Parse(rawURL) 181 | if err != nil { 182 | return rawURL 183 | } 184 | 185 | if parsed.User != nil { 186 | parsed.User = url.UserPassword("***", "***") 187 | } 188 | 189 | return parsed.String() 190 | } 191 | 192 | // GetBaseTopic returns the base topic for this device 193 | func (c *Client) GetBaseTopic() string { 194 | return fmt.Sprintf("byd_car/%s", c.deviceID) 195 | } 196 | 197 | // GetDiscoveryTopic returns the Home Assistant discovery topic 198 | func (c *Client) GetDiscoveryTopic(prefix, entityType, entityID string) string { 199 | return fmt.Sprintf("%s/%s/byd_car_%s/%s/config", prefix, entityType, c.deviceID, entityID) 200 | } 201 | 202 | // GetStateTopic returns the state topic for this device 203 | func (c *Client) GetStateTopic() string { 204 | return fmt.Sprintf("%s/state", c.GetBaseTopic()) 205 | } 206 | 207 | // GetAvailabilityTopic returns the availability topic for this device 208 | func (c *Client) GetAvailabilityTopic() string { 209 | return fmt.Sprintf("%s/availability", c.GetBaseTopic()) 210 | } 211 | 212 | // PublishAvailability publishes device availability status 213 | func (c *Client) PublishAvailability(online bool) error { 214 | status := "offline" 215 | if online { 216 | status = "online" 217 | } 218 | 219 | return c.Publish(c.GetAvailabilityTopic(), []byte(status), true) 220 | } 221 | 222 | // BuildCleanTopic ensures topic follows MQTT standards 223 | func BuildCleanTopic(parts ...string) string { 224 | var cleanParts []string 225 | for _, part := range parts { 226 | // Replace invalid characters 227 | clean := strings.ReplaceAll(part, " ", "_") 228 | clean = strings.ReplaceAll(clean, "+", "plus") 229 | clean = strings.ReplaceAll(clean, "#", "hash") 230 | clean = strings.ToLower(clean) 231 | cleanParts = append(cleanParts, clean) 232 | } 233 | return strings.Join(cleanParts, "/") 234 | } 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BYD-HASS 2 | 3 | BYD-HASS is a small Go program that turns data from the Diplus API into MQTT messages that Home Assistant can understand, and (optionally) telemetry for A Better Route Planner (ABRP). It is built as a single static binary so it can run in the car's infotainment using Termux. 4 | 5 | ## How it works 6 | 7 | 1. Every 8 seconds `byd-hass` calls the Diplus API (`http://localhost:8988/api/getDiPars`) 8 | 2. Values are cached in memory. Nothing is sent unless a value has changed since the last time it was transmitted. 9 | 3. Changed values are published: 10 | - to MQTT every 60 seconds and are discovered by Home Assistant 11 | - to ABRP every 10 seconds if an ABRP API key and ABRP TOKEN are supplied **and** the ABRP Android app is running (can be disabled with `-require-abrp-app=false`). 12 | 13 | ## Quick start 14 | 15 | ### Prerequisites 16 | 17 | 1. **Enable Wireless Debugging** on the car's infotainment 18 | 19 | 2. **Install Termux** from [F-Droid](https://f-droid.org/packages/com.termux/) or [GitHub](https://github.com/termux/termux-app/releases) 20 | 21 | 3. **Have an MQTT broker ready** – normally the one already used by Home Assistant (tip: if you're gonna use this while traveling, consider [MQTT over WebSocket](https://cedalo.com/blog/enabling-websockets-over-mqtt-with-mosquitto/)) 22 | 23 | ### Run the installer 24 | 25 | Open Termux and run: 26 | 27 | ```bash 28 | bash <(curl -sSL https://raw.githubusercontent.com/jkaberg/byd-hass/main/install.sh) 29 | ``` 30 | 31 | The installer will: 32 | - Download and install the `byd-hass` binary 33 | - Check for and offer to install missing dependencies (Diplus, Termux:API, Termux:Boot) 34 | - Ask for your MQTT and ABRP settings 35 | - Configure the program to start automatically and keep running indefinitely 36 | 37 | **Note:** After installation, make sure to grant the required permissions to Diplus, Termux, Termux:Boot, and Termux:API (especially location permissions for GPS functionality). 38 | 39 | ### Optional: ABRP telemetry 40 | 41 | If you want to send telemetry to A Better Route Planner, you'll also need: 42 | - [ABRP Android app](https://play.google.com/store/apps/details?id=com.iternio.abrpapp) running in the background (can be disabled with `-require-abrp-app=false`) 43 | - Your ABRP API key and user token (provided during installation) 44 | 45 | --- 46 | 47 | ### Updating and maintenance 48 | 49 | Re-run the installer at any time to update to the latest version: 50 | 51 | ```bash 52 | bash <(curl -sSL https://raw.githubusercontent.com/jkaberg/byd-hass/main/install.sh) 53 | ``` 54 | 55 | To stop all running processes (useful before reconfiguring): 56 | 57 | ```bash 58 | ./install.sh cleanup 59 | ``` 60 | 61 | ## Configuration 62 | 63 | Settings can be supplied as command-line flags or environment variables (prefix `BYD_HASS_`). 64 | 65 | | Flag | Environment variable | Purpose | 66 | | ---- | -------------------- | ------- | 67 | | `-mqtt-url` | `BYD_HASS_MQTT_URL` | MQTT connection string (e.g. `ws://user:pass@broker:9001/mqtt`) | 68 | | `-abrp-api-key` | `BYD_HASS_ABRP_API_KEY` | ABRP API key (optional) | 69 | | `-abrp-token` | `BYD_HASS_ABRP_TOKEN` | ABRP user token (optional) | 70 | | `-require-abrp-app` | `BYD_HASS_REQUIRE_ABRP_APP` | Require ABRP Android app to be running before sending telemetry (default `true`) | 71 | | `-device-id` | `BYD_HASS_DEVICE_ID` | Unique name for this car (default is auto-generated) | 72 | | `-verbose` | `BYD_HASS_VERBOSE` | Enable extra logging | 73 | | `-discovery-prefix` | ― | MQTT discovery prefix (default `homeassistant`) | 74 | | `-mqtt-interval` | `BYD_HASS_MQTT_INTERVAL` | Override MQTT transmission interval (`60s` default) | 75 | | `-abrp-interval` | `BYD_HASS_ABRP_INTERVAL` | Override ABRP transmission interval (`10s` default) | 76 | | | `BYD_HASS_SENSOR_IDS` | Override default sensors published, use format "id:publish,id,...", publish can be ommited, default to true, for example "33:1,34,1:0" meaning publish id's 33 and 34, but also read id 1 and don't publish. For more details see [here](https://github.com/jkaberg/byd-hass/blob/main/internal/sensors/sensor_ids.go#L39-L50) | 77 | 78 | ## Home Assistant sensors 79 | 80 | When connected to MQTT, Home Assistant automatically discovers a single device with many entities such as battery %, speed, mileage, lock state, and more. See picture: 81 | 82 | ![Example sensors in Home Assistant](docs/pictures/mqtt-2025-06-30.png) 83 | 84 | ### Typical MQTT entities 85 | 86 | Below is the default subset of BYD signals that are exposed via MQTT (others remain internal for now). Entity IDs follow Home Assistant conventions (`snake_case`). 87 | 88 | | Entity ID | Friendly name | Device class | Unit | Notes | 89 | |-----------|---------------|--------------|------|-------| 90 | | `battery_percentage` | Battery State of Charge | battery | % | High-voltage battery SOC. | 91 | | `fuel_percentage` | Fueal tank fill percentage | battery | % | Fuel tank fill. | 92 | | `speed` | Speed | speed | km/h | Vehicle speed. | 93 | | `mileage` | Odometer | distance | km | Total mileage (odometer). | 94 | | `engine_power` | Power | power | kW | Positive → driving, negative → regen/charging. | 95 | | `outside_temperature` | Outside Temperature | temperature | °C | Outside temperature sensor. | 96 | | `cabin_temperature` | Cabin Temperature | temperature | °C | Interior temperature sensor. | 97 | | `left_front_tire_pressure` | LF Tire Pressure | pressure | bar | Raw value is in bar (converted to kPa for ABRP). | 98 | | `right_front_tire_pressure` | RF Tire Pressure | pressure | bar | | 99 | | `left_rear_tire_pressure` | LR Tire Pressure | pressure | bar | | 100 | | `right_rear_tire_pressure` | RR Tire Pressure | pressure | bar | | 101 | | `charging_status` | Charging Status | None | — | Virtual sensor derived from charge-gun state & power (`disconnected`, `connected`, `charging`). | 102 | | `last_transmission` | Last Transmission | timestamp | — | UTC timestamp of last successful publish. | 103 | | `device_tracker.` | Location | gps | — | Standard HA device-tracker entity fed by GPS or network (if available). | 104 | 105 | This list matches the `internal/transmission/mqtt_ids.go` allow-list and can be customised in code if you need more or fewer metrics. 106 | 107 | ## Building from source 108 | 109 | ```bash 110 | ./build.sh # produces a static arm64 binary for Termux 111 | ``` 112 | 113 | The build script cross-compiles for Android (GOOS=linux GOARCH=arm64 CGO_ENABLED=0) and strips debug symbols for a small footprint. 114 | 115 | ## Notes 116 | 117 | This project is not affiliated with BYD, the Diplus authors, Home Assistant, or ABRP. Use at your own risk. 118 | 119 | ## Estimated data usage (Wi-Fi/Cellular) 120 | 121 | > The figures below are **ball-park estimates** intended to help you plan for mobile data usage when running `byd-hass` on the infotainment. Actual usage will vary with driving style, connection quality, MQTT broker behaviour, etc. 122 | 123 | ### How the numbers were derived 124 | 125 | 1. **Message sizes** – The program currently sends three types of outbound traffic: 126 | * **MQTT state payload** (`byd_car//state`). A full JSON state containing ~25 numeric/boolean fields plus topic and protocol overhead is ~ **130 bytes** per publish. 127 | * **ABRP telemetry call** (HTTPS `POST`). The documented ABRP payload is smaller than the MQTT state but the TLS, HTTP and header overheads are higher. One update is ~ **500 bytes** on the wire. 128 | * **MQTT keep-alive (PINGREQ + PINGRESP)**. Over WebSocket/TCP a full round-trip (frame + TCP/IP headers each way) is ~ **100 bytes**. 129 | 2. **Send intervals** – 130 | * **MQTT**: every **60 s** *but only while at least one value has changed*. When the car is parked usually nothing changes, so the broker typically only sees a retain/heartbeat publish once an hour. During driving almost every minute triggers an update. 131 | * **ABRP**: fixed **10 s** interval (subject to the same *value-changed* guard as MQTT). When the car is parked the snapshot rarely changes, so only a handful of telemetry calls are triggered. The logic is active only when the **ABRP telemetry feature itself is enabled** – i.e. an API key/token were supplied *and* the `-require-abrp-app` (defaults to `true`) flag (or `BYD_HASS_REQUIRE_ABRP_APP`) is satisfied at runtime. 132 | * **MQTT keep-alive**: one ping round-trip every **60 s** (client default) 24 × 7, regardless of driving. 133 | 3. **Downtime assumption** – Cars spend most of the time parked. For a "typical commuter" profile we assume **1 h of driving per day** and **23 h parked**. A pessimistic worst-case and an optimistic best-case are also shown. 134 | 135 | ### Monthly totals (30-day month) 136 | 137 | | Scenario | Driving / day | MQTT state | ABRP | MQTT ping | **Total** | 138 | | -------- | ------------- | ---------- | ----- | --------- | --------- | 139 | | **Typical** (default) | 1 h | 60 msg × 130 B × 30 d = **0.23 MB** | 360 msg × 500 B × 30 d = **5.4 MB** | 1 440 ping × 100 B × 30 d = **4.3 MB** | **≈ 10 MB** | 140 | | Light usage | 30 min | 0.11 MB | 2.7 MB | 4.3 MB | **≈ 7 MB** | 141 | | Heavy usage | 4 h | 0.9 MB | 21.6 MB | 4.3 MB | **≈ 27 MB** | 142 | 143 | Even in the heavy-usage scenario the program stays well under 30 MB per month, which is only ~3 % of the 1 GB cellular plan BYD provides in many countries. 144 | 145 | *Tip: if you do **not** need ABRP telemetry you can disable it (omit `-abrp-api-key`) and cut data usage by roughly **90 %**.* 146 | -------------------------------------------------------------------------------- /internal/sensors/parser.go: -------------------------------------------------------------------------------- 1 | package sensors 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // APIResponse represents the response from the Diplus API 14 | type APIResponse struct { 15 | Success bool `json:"success"` 16 | Val string `json:"val"` 17 | } 18 | 19 | // ParseAPIResponse parses the API response and populates a SensorData struct 20 | func ParseAPIResponse(responseBody []byte) (*SensorData, error) { 21 | var apiResp APIResponse 22 | if err := json.Unmarshal(responseBody, &apiResp); err != nil { 23 | return nil, fmt.Errorf("failed to unmarshal API response: %w", err) 24 | } 25 | 26 | if !apiResp.Success { 27 | return nil, fmt.Errorf("API request failed: success=false") 28 | } 29 | 30 | sensorData := &SensorData{ 31 | Timestamp: time.Now(), 32 | } 33 | 34 | if err := parseValueString(apiResp.Val, sensorData); err != nil { 35 | return nil, fmt.Errorf("failed to parse sensor values: %w", err) 36 | } 37 | 38 | return sensorData, nil 39 | } 40 | 41 | // parseValueString parses the pipe-separated key:value string from the API 42 | func parseValueString(valString string, sensorData *SensorData) error { 43 | if valString == "" { 44 | return fmt.Errorf("empty value string") 45 | } 46 | 47 | // Split by pipe separator 48 | pairs := strings.Split(valString, "|") 49 | 50 | // Use reflection to set struct fields 51 | v := reflect.ValueOf(sensorData).Elem() 52 | 53 | for _, pair := range pairs { 54 | // Split key:value 55 | parts := strings.SplitN(pair, ":", 2) 56 | if len(parts) != 2 { 57 | continue // Skip malformed pairs 58 | } 59 | 60 | key := strings.TrimSpace(parts[0]) 61 | valueStr := strings.TrimSpace(parts[1]) 62 | 63 | // Lookup the struct field by the authoritative key directly; no fallback 64 | // conversion is needed because Diplus now echoes back exactly what we 65 | // requested. 66 | field := v.FieldByName(key) 67 | if !field.IsValid() || !field.CanSet() { 68 | // Field not found or not settable; skip. 69 | continue 70 | } 71 | 72 | // Determine scaling factor based on sensor metadata (defaults to 1) 73 | scaleFactor := GetScaleFactor(ToSnakeCase(key)) 74 | 75 | // Parse the value and set the field with scaling applied where necessary 76 | if err := setFieldValue(field, valueStr, scaleFactor); err != nil { 77 | // Log error but continue with other fields 78 | continue 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // setFieldValue sets a reflect.Value field with the parsed string value 86 | func setFieldValue(field reflect.Value, valueStr string, scaleFactor float64) error { 87 | // Normalize the value string for European formats 88 | normalizedValue := normalizeNumericValue(valueStr) 89 | 90 | // If the normalized value is empty, treat it as null/not present 91 | if normalizedValue == "" { 92 | return nil // Leave the pointer nil 93 | } 94 | if field.Kind() != reflect.Ptr { 95 | return fmt.Errorf("field is not a pointer") 96 | } 97 | 98 | // Get the type of the pointer's element 99 | elemType := field.Type().Elem() 100 | 101 | // Create a new pointer to the element type 102 | newVal := reflect.New(elemType) 103 | 104 | switch elemType.Kind() { 105 | case reflect.Float32, reflect.Float64: 106 | floatVal, err := strconv.ParseFloat(normalizedValue, 64) 107 | if err != nil { 108 | return fmt.Errorf("failed to parse float value '%s': %w", normalizedValue, err) 109 | } 110 | newVal.Elem().SetFloat(floatVal * scaleFactor) 111 | case reflect.String: 112 | newVal.Elem().SetString(normalizedValue) 113 | default: 114 | // We currently only expect *float64 and *string fields in SensorData. 115 | // Unknown types are ignored rather than treated as errors to keep the 116 | // parser resilient to future struct changes. 117 | return nil 118 | } 119 | 120 | field.Set(newVal) 121 | 122 | return nil 123 | } 124 | 125 | // normalizeNumericValue converts European number formats to standard formats 126 | func normalizeNumericValue(value string) string { 127 | if value == "" { 128 | return "" 129 | } 130 | 131 | // Replace Unicode minus sign with standard minus 132 | value = strings.ReplaceAll(value, "−", "-") 133 | 134 | // Replace European decimal comma with dot 135 | value = strings.ReplaceAll(value, ",", ".") 136 | 137 | return value 138 | } 139 | 140 | var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") 141 | var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") 142 | 143 | // ToSnakeCase converts a CamelCase string to snake_case. 144 | func ToSnakeCase(str string) string { 145 | snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") 146 | snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") 147 | return strings.ToLower(snake) 148 | } 149 | 150 | // GetAllSensorIDs returns all available sensor IDs 151 | func GetAllSensorIDs() []int { 152 | var ids []int 153 | for _, sensor := range AllSensors { 154 | ids = append(ids, sensor.ID) 155 | } 156 | return ids 157 | } 158 | 159 | // ValidateSensorData performs basic validation on sensor data 160 | func ValidateSensorData(data *SensorData) []string { 161 | var warnings []string 162 | 163 | // Check for reasonable battery percentage 164 | if data.BatteryPercentage != nil { 165 | if *data.BatteryPercentage < 0 || *data.BatteryPercentage > 100 { 166 | warnings = append(warnings, fmt.Sprintf("Battery percentage out of range: %.1f%%", *data.BatteryPercentage)) 167 | } 168 | } 169 | 170 | // Check for reasonable speed 171 | if data.Speed != nil { 172 | if *data.Speed < 0 || *data.Speed > 300 { // 300 km/h max reasonable speed 173 | warnings = append(warnings, fmt.Sprintf("Speed out of reasonable range: %.1f km/h", *data.Speed)) 174 | } 175 | } 176 | 177 | // Check for reasonable temperatures 178 | if data.CabinTemperature != nil { 179 | if *data.CabinTemperature < -40 || *data.CabinTemperature > 80 { 180 | warnings = append(warnings, fmt.Sprintf("Cabin temperature out of reasonable range: %.1f°C", *data.CabinTemperature)) 181 | } 182 | } 183 | 184 | if data.OutsideTemperature != nil { 185 | if *data.OutsideTemperature < -50 || *data.OutsideTemperature > 60 { 186 | warnings = append(warnings, fmt.Sprintf("Outside temperature out of reasonable range: %.1f°C", *data.OutsideTemperature)) 187 | } 188 | } 189 | 190 | return warnings 191 | } 192 | 193 | // GetNonNilFields returns a map of field names to values for all non-nil fields 194 | func GetNonNilFields(data *SensorData) map[string]interface{} { 195 | result := make(map[string]interface{}) 196 | 197 | v := reflect.ValueOf(data).Elem() 198 | t := reflect.TypeOf(data).Elem() 199 | 200 | for i := 0; i < v.NumField(); i++ { 201 | field := v.Field(i) 202 | fieldType := t.Field(i) 203 | 204 | // Skip timestamp field 205 | if fieldType.Name == "Timestamp" { 206 | continue 207 | } 208 | 209 | // Check if pointer field is not nil 210 | if field.Kind() == reflect.Ptr && !field.IsNil() { 211 | jsonTag := fieldType.Tag.Get("json") 212 | if jsonTag != "" { 213 | // Extract field name from json tag 214 | tagParts := strings.Split(jsonTag, ",") 215 | fieldName := tagParts[0] 216 | result[fieldName] = field.Elem().Interface() 217 | } 218 | } 219 | } 220 | 221 | return result 222 | } 223 | 224 | // CompareRawVsParsed compares the raw API response map with the parsed SensorData struct. 225 | func CompareRawVsParsed(responseBody []byte, parsedData *SensorData) { 226 | fmt.Println("\n" + strings.Repeat("=", 80)) 227 | fmt.Println("RAW API vs PARSED VALUES COMPARISON") 228 | fmt.Println(strings.Repeat("=", 80)) 229 | 230 | // Parse the API response 231 | var apiResp APIResponse 232 | if err := json.Unmarshal(responseBody, &apiResp); err != nil { 233 | fmt.Printf("ERROR: Failed to unmarshal API response: %v\n", err) 234 | return 235 | } 236 | 237 | if !apiResp.Success { 238 | fmt.Println("ERROR: API returned success=false") 239 | return 240 | } 241 | 242 | // Parse the raw value string into key-value pairs 243 | rawValues := parseRawValues(apiResp.Val) 244 | 245 | fmt.Printf("Found %d raw values from API\n", len(rawValues)) 246 | fmt.Printf("Parsed %d non-nil fields in struct\n", countNonNilFields(parsedData)) 247 | 248 | // Get reflection info for the parsed data 249 | v := reflect.ValueOf(parsedData).Elem() 250 | 251 | var successCount, failCount, mismatchCount int 252 | 253 | fmt.Println("\n📊 VALUE-BY-VALUE COMPARISON:") 254 | 255 | for key, rawValue := range rawValues { 256 | // Direct match only; we no longer support automatic key conversion. 257 | fieldName := key 258 | field := v.FieldByName(fieldName) 259 | 260 | if !field.IsValid() { 261 | fmt.Printf("❓ UNKNOWN: %s = '%s' (no matching field)\n", key, rawValue) 262 | continue 263 | } 264 | 265 | // Check if field is set (not nil) 266 | if field.IsNil() { 267 | fmt.Printf("❌ FAILED: %s = '%s' -> nil (parsing failed)\n", key, rawValue) 268 | failCount++ 269 | continue 270 | } 271 | 272 | // Get the actual parsed value 273 | parsedValue := field.Elem().Interface() 274 | 275 | // Determine expected vs actual types 276 | expectedType := getExpectedType(rawValue) 277 | actualType := fmt.Sprintf("%T", parsedValue) 278 | 279 | if expectedType != actualType { 280 | fmt.Printf("⚠️ MISMATCH: %s = '%s' -> %v (%s) [expected: %s]\n", 281 | key, rawValue, parsedValue, actualType, expectedType) 282 | mismatchCount++ 283 | } else { 284 | fmt.Printf("✅ SUCCESS: %s = '%s' -> %v (%s)\n", 285 | key, rawValue, parsedValue, actualType) 286 | successCount++ 287 | } 288 | } 289 | 290 | // Summary 291 | fmt.Println("\n" + strings.Repeat("-", 80)) 292 | fmt.Printf("📈 SUMMARY:\n") 293 | fmt.Printf(" ✅ Successful: %d\n", successCount) 294 | fmt.Printf(" ⚠️ Type Mismatches: %d\n", mismatchCount) 295 | fmt.Printf(" ❌ Parse Failures: %d\n", failCount) 296 | fmt.Printf(" Total Compared: %d\n", successCount+mismatchCount+failCount) 297 | 298 | if mismatchCount > 0 { 299 | fmt.Printf("\n🔧 TYPE MISMATCH FIXES NEEDED:\n") 300 | fmt.Printf("Review the ⚠️ MISMATCH entries above and fix the struct field types accordingly.\n") 301 | } 302 | 303 | if failCount > 0 { 304 | fmt.Printf("\n🐛 PARSING FAILURES:\n") 305 | fmt.Printf("Review the ❌ FAILED entries above - these values couldn't be parsed at all.\n") 306 | } 307 | 308 | fmt.Println(strings.Repeat("=", 80)) 309 | } 310 | 311 | // parseRawValues parses the raw API value string into key-value pairs 312 | func parseRawValues(valString string) map[string]string { 313 | values := make(map[string]string) 314 | 315 | if valString == "" { 316 | return values 317 | } 318 | 319 | // Split by pipe separator 320 | pairs := strings.Split(valString, "|") 321 | 322 | for _, pair := range pairs { 323 | // Split key:value 324 | parts := strings.SplitN(pair, ":", 2) 325 | if len(parts) == 2 { 326 | key := strings.TrimSpace(parts[0]) 327 | value := strings.TrimSpace(parts[1]) 328 | values[key] = value 329 | } 330 | } 331 | 332 | return values 333 | } 334 | 335 | // getExpectedType determines what Go type a raw string value should be 336 | func getExpectedType(rawValue string) string { 337 | // Check for empty strings or obvious string values (file paths, etc.) 338 | if rawValue == "" || strings.Contains(rawValue, "/") || strings.Contains(rawValue, "\\") { 339 | return "string" 340 | } 341 | 342 | // Check if it's a number (our sensor data is mostly numeric and we use float64 for all numeric values) 343 | if _, err := strconv.ParseFloat(rawValue, 64); err == nil { 344 | // All numeric values in our BYD sensor data are now float64 345 | return "float64" 346 | } 347 | 348 | // Check if it's a boolean-like value (though we don't currently use bool types) 349 | if rawValue == "true" || rawValue == "false" { 350 | return "bool" 351 | } 352 | 353 | // Default to string 354 | return "string" 355 | } 356 | 357 | // countNonNilFields counts how many fields in the sensor data are not nil 358 | func countNonNilFields(data *SensorData) int { 359 | count := 0 360 | v := reflect.ValueOf(data).Elem() 361 | 362 | for i := 0; i < v.NumField(); i++ { 363 | field := v.Field(i) 364 | if field.Kind() == reflect.Ptr && !field.IsNil() { 365 | count++ 366 | } 367 | } 368 | 369 | return count 370 | } 371 | -------------------------------------------------------------------------------- /internal/transmission/abrp.go: -------------------------------------------------------------------------------- 1 | package transmission 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "sync/atomic" 14 | 15 | "github.com/jkaberg/byd-hass/internal/sensors" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // ABRP (A Better Route Planner) telemetry integration 20 | // 21 | // This module transmits comprehensive vehicle telemetry data to ABRP for improved 22 | // route planning and energy consumption estimation. The telemetry includes: 23 | // 24 | // High Priority Parameters (most important for route planning): 25 | // - utc: UTC timestamp (required) 26 | // - soc: State of Charge percentage (required) 27 | // - power: Instantaneous power consumption/generation in kW 28 | // - speed: Vehicle speed in km/h 29 | // - lat/lon: GPS coordinates for location-based planning 30 | // - is_charging: Charging status indicator 31 | // - is_dcfc: DC fast charging indicator 32 | // - is_parked: Parking status 33 | // 34 | // Lower Priority Parameters (enhance accuracy): 35 | // - capacity: Battery capacity in kWh 36 | // - soe: State of Energy (absolute energy content) 37 | // - voltage/current: Battery electrical parameters 38 | // - ext_temp/batt_temp/cabin_temp: Temperature data 39 | // - odometer: Total mileage 40 | // - est_battery_range: Estimated remaining range 41 | // - hvac_power/hvac_setpoint: Climate control data 42 | // - tire_pressure_*: Tire pressure monitoring 43 | // - heading/elevation: Navigation enhancement data 44 | 45 | // ABRPTransmitter transmits telemetry data to A Better Route Planner 46 | type ABRPTransmitter struct { 47 | apiKey string 48 | token string 49 | httpClient *http.Client 50 | logger *logrus.Logger 51 | healthy uint32 // 1 = last transmission successful, 0 = failed/unknown 52 | } 53 | 54 | // ABRPTelemetry represents the telemetry data format for ABRP 55 | type ABRPTelemetry struct { 56 | // High priority parameters (required) 57 | Utc int64 `json:"utc"` // UTC timestamp in seconds 58 | SOC float64 `json:"soc"` // State of charge (0-100) 59 | 60 | // High priority parameters (optional but important) 61 | Power *float64 `json:"power,omitempty"` // Instantaneous power in kW (positive=output, negative=charging) 62 | Speed *float64 `json:"speed,omitempty"` // Vehicle speed in km/h 63 | Lat *float64 `json:"lat,omitempty"` // Current latitude 64 | Lon *float64 `json:"lon,omitempty"` // Current longitude 65 | IsCharging *bool `json:"is_charging,omitempty"` // 0=not charging, 1=charging 66 | IsDCFC *bool `json:"is_dcfc,omitempty"` // DC fast charging indicator 67 | IsParked *bool `json:"is_parked,omitempty"` // Vehicle gear in P or driver left car 68 | 69 | // Lower priority parameters 70 | Capacity *float64 `json:"capacity,omitempty"` // Estimated usable battery capacity in kWh 71 | SOE *float64 `json:"soe,omitempty"` // Present energy capacity (SoC * capacity) 72 | SOH *float64 `json:"soh,omitempty"` // State of Health (100 = no degradation) 73 | Heading *float64 `json:"heading,omitempty"` // Current heading in degrees 74 | Elevation *float64 `json:"elevation,omitempty"` // Current elevation in meters 75 | ExtTemp *float64 `json:"ext_temp,omitempty"` // Outside temperature in °C 76 | BattTemp *float64 `json:"batt_temp,omitempty"` // Battery temperature in °C 77 | Voltage *float64 `json:"voltage,omitempty"` // Battery pack voltage in V 78 | Current *float64 `json:"current,omitempty"` // Battery pack current in A 79 | Odometer *float64 `json:"odometer,omitempty"` // Current odometer reading in km 80 | EstBatteryRange *float64 `json:"est_battery_range,omitempty"` // Estimated remaining range in km 81 | HVACPower *float64 `json:"hvac_power,omitempty"` // HVAC power usage in kW 82 | HVACSetpoint *float64 `json:"hvac_setpoint,omitempty"` // HVAC setpoint temperature in °C 83 | CabinTemp *float64 `json:"cabin_temp,omitempty"` // Current cabin temperature in °C 84 | TirePressureFL *float64 `json:"tire_pressure_fl,omitempty"` // Front left tire pressure in kPa 85 | TirePressureFR *float64 `json:"tire_pressure_fr,omitempty"` // Front right tire pressure in kPa 86 | TirePressureRL *float64 `json:"tire_pressure_rl,omitempty"` // Rear left tire pressure in kPa 87 | TirePressureRR *float64 `json:"tire_pressure_rr,omitempty"` // Rear right tire pressure in kPa 88 | } 89 | 90 | // NewABRPTransmitter creates a new ABRP transmitter 91 | func NewABRPTransmitter(apiKey, token string, logger *logrus.Logger) *ABRPTransmitter { 92 | // Rely on the global custom DNS resolver installed in main.go. 93 | transport := &http.Transport{ 94 | Proxy: http.ProxyFromEnvironment, 95 | ForceAttemptHTTP2: true, 96 | MaxIdleConns: 100, 97 | IdleConnTimeout: 90 * time.Second, 98 | TLSHandshakeTimeout: 10 * time.Second, 99 | ExpectContinueTimeout: 1 * time.Second, 100 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 101 | } 102 | 103 | return &ABRPTransmitter{ 104 | apiKey: apiKey, 105 | token: token, 106 | httpClient: &http.Client{ 107 | Timeout: 10 * time.Second, 108 | Transport: transport, 109 | }, 110 | logger: logger, 111 | } 112 | } 113 | 114 | // TransmitWithContext sends sensor data to ABRP using the provided context. 115 | // If ctx is cancelled or times out, the request is aborted. 116 | func (t *ABRPTransmitter) TransmitWithContext(ctx context.Context, data *sensors.SensorData) error { 117 | // Convert sensor data to ABRP telemetry JSON once so we can reuse it between retries. 118 | telemetry := t.buildTelemetryData(data) 119 | 120 | payload, err := json.Marshal(telemetry) 121 | if err != nil { 122 | return fmt.Errorf("failed to marshal ABRP telemetry: %w", err) 123 | } 124 | 125 | // Prepare the constant request body and target URL up-front. 126 | formEncoded := url.Values{"tlm": []string{string(payload)}}.Encode() 127 | apiURL := fmt.Sprintf("https://api.iternio.com/1/tlm/send?api_key=%s&token=%s", t.apiKey, t.token) 128 | 129 | // Retry parameters. We use exponential back-off capped at 30 seconds and keep retrying 130 | // until the provided context is cancelled. 131 | const ( 132 | initialBackoff = 2 * time.Second 133 | maxBackoff = 30 * time.Second 134 | ) 135 | 136 | backoff := initialBackoff 137 | attempt := 0 138 | var lastErr error 139 | 140 | for { 141 | // Honour caller cancellation. 142 | select { 143 | case <-ctx.Done(): 144 | if lastErr == nil { 145 | lastErr = ctx.Err() 146 | } 147 | return lastErr 148 | default: 149 | } 150 | 151 | attempt++ 152 | 153 | // Build a fresh *http.Request for every attempt because the request body reader 154 | // cannot be reused once it has been read. 155 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(formEncoded)) 156 | if err != nil { 157 | return fmt.Errorf("failed to create ABRP request: %w", err) 158 | } 159 | req.Header.Set("User-Agent", "byd-hass/1.0.0") 160 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 161 | 162 | resp, err := t.httpClient.Do(req) 163 | if err == nil && resp != nil && resp.StatusCode == http.StatusOK { 164 | if resp.Body != nil { 165 | _ = resp.Body.Close() 166 | } 167 | prev := atomic.SwapUint32(&t.healthy, 1) 168 | 169 | if prev == 0 { 170 | t.logger.Info("ABRP connection restored") 171 | } else if t.logger.IsLevelEnabled(logrus.DebugLevel) { 172 | t.logger.WithFields(logrus.Fields{ 173 | "attempt": attempt, 174 | "status_code": resp.StatusCode, 175 | }).Debug("Successfully transmitted to ABRP") 176 | } 177 | return nil 178 | } 179 | 180 | // Handle failure path – we want to retry. 181 | if resp != nil { 182 | _ = resp.Body.Close() 183 | err = fmt.Errorf("ABRP API returned status %d: %s", resp.StatusCode, resp.Status) 184 | } 185 | lastErr = err 186 | atomic.StoreUint32(&t.healthy, 0) 187 | 188 | // Drop idle connections to avoid half-open sockets after network hand-over. 189 | if tr, ok := t.httpClient.Transport.(*http.Transport); ok { 190 | tr.CloseIdleConnections() 191 | } 192 | 193 | if attempt == 1 { 194 | // Surface the initial failure at WARN so operators know we are offline. 195 | // Detailed retry counters/back-off remain at DEBUG level to keep INFO/WARN output concise. 196 | t.logger.WithError(err).Warn("ABRP transmit failed – retrying") 197 | } else { 198 | t.logger.WithError(err).Debugf("ABRP retry %d failed – next attempt in %s", attempt, backoff) 199 | } 200 | 201 | // Wait for the back-off period or exit early if the caller cancels. 202 | select { 203 | case <-ctx.Done(): 204 | if lastErr == nil { 205 | lastErr = ctx.Err() 206 | } 207 | return lastErr 208 | case <-time.After(backoff): 209 | } 210 | 211 | // Exponential back-off with an upper bound. 212 | backoff *= 2 213 | if backoff > maxBackoff { 214 | backoff = maxBackoff 215 | } 216 | } 217 | } 218 | 219 | // Transmit is kept for backward-compatibility and uses Background context. 220 | func (t *ABRPTransmitter) Transmit(data *sensors.SensorData) error { 221 | return t.TransmitWithContext(context.Background(), data) 222 | } 223 | 224 | // IsConnected returns true when the last transmission attempt succeeded. 225 | func (t *ABRPTransmitter) IsConnected() bool { 226 | return atomic.LoadUint32(&t.healthy) == 1 227 | } 228 | 229 | // buildTelemetryData converts sensor data to ABRP telemetry format 230 | func (t *ABRPTransmitter) buildTelemetryData(data *sensors.SensorData) ABRPTelemetry { 231 | telemetry := ABRPTelemetry{ 232 | Utc: data.Timestamp.Unix(), 233 | } 234 | 235 | // High priority parameters - State of charge (required) 236 | if data.BatteryPercentage != nil { 237 | telemetry.SOC = *data.BatteryPercentage 238 | } 239 | 240 | // High priority - Speed 241 | if data.Speed != nil { 242 | telemetry.Speed = data.Speed 243 | 244 | // Determine parking status based on speed 245 | isParked := *data.Speed == 0 246 | telemetry.IsParked = &isParked 247 | } 248 | 249 | // High priority - Location coordinates 250 | if data.Location != nil { 251 | telemetry.Lat = &data.Location.Latitude 252 | telemetry.Lon = &data.Location.Longitude 253 | if data.Location.Altitude > 0 { 254 | telemetry.Elevation = &data.Location.Altitude 255 | } 256 | if data.Location.Bearing > 0 { 257 | telemetry.Heading = &data.Location.Bearing 258 | } 259 | } 260 | 261 | // High priority - Power from engine 262 | if data.EnginePower != nil { 263 | telemetry.Power = data.EnginePower 264 | } 265 | 266 | // High priority - Charging status and DC fast-charging detection based on instantaneous power 267 | // ABRP expects negative values for battery charge (power flowing INTO the battery). 268 | // Charging detection rules: 269 | // * is_charging = 1 when power is below -1 kW (i.e. < −1). 270 | // * is_dcfc = 1 when power is below -50 kW (i.e. < −50). 271 | // Note: "below" means numerically less (more negative). 272 | 273 | // Determine if the charging gun is physically connected (gun state 2) 274 | connected := false 275 | if data.ChargeGunState != nil && int(*data.ChargeGunState) == 2 { 276 | connected = true 277 | } 278 | 279 | // Initialise flags to false so they are always sent 280 | isCharging := false 281 | isDCFC := false 282 | 283 | // Update flags only when the gun is connected and power thresholds are met 284 | if telemetry.Power != nil && connected { 285 | p := *telemetry.Power 286 | if p < -1.0 { 287 | isCharging = true 288 | } 289 | if p < -50.0 { 290 | isDCFC = true 291 | } 292 | } 293 | 294 | telemetry.IsCharging = &isCharging 295 | telemetry.IsDCFC = &isDCFC 296 | 297 | // Lower priority - Battery information 298 | if data.BatteryCapacity != nil { 299 | telemetry.Capacity = data.BatteryCapacity 300 | 301 | // Calculate SOE (State of Energy) = SoC * capacity 302 | if data.BatteryPercentage != nil { 303 | soe := (*data.BatteryCapacity * *data.BatteryPercentage) / 100 304 | telemetry.SOE = &soe 305 | } 306 | } 307 | 308 | // Lower priority - Battery voltage and estimated current 309 | if data.MaxBatteryVoltage != nil { 310 | telemetry.Voltage = data.MaxBatteryVoltage 311 | 312 | // Estimate current from power and voltage (I = P / V) 313 | if telemetry.Power != nil && telemetry.Voltage != nil && *telemetry.Voltage > 0 { 314 | // Power is in kW, convert to W for calculation 315 | powerWatts := *telemetry.Power * 1000 316 | current := powerWatts / *telemetry.Voltage 317 | telemetry.Current = ¤t 318 | } 319 | } 320 | 321 | // Lower priority - Temperature data 322 | if data.OutsideTemperature != nil { 323 | telemetry.ExtTemp = data.OutsideTemperature 324 | } 325 | if data.AvgBatteryTemp != nil { 326 | telemetry.BattTemp = data.AvgBatteryTemp 327 | } 328 | if data.CabinTemperature != nil { 329 | telemetry.CabinTemp = data.CabinTemperature 330 | } 331 | 332 | // Lower priority - Odometer 333 | if data.Mileage != nil { 334 | telemetry.Odometer = data.Mileage 335 | } 336 | 337 | // Lower priority - HVAC data 338 | if data.ACStatus != nil && *data.ACStatus > 0 { 339 | // Estimate HVAC power based on temperature difference and fan speed 340 | hvacPower := 2.0 // Base HVAC power consumption in kW 341 | 342 | if data.CabinTemperature != nil && data.OutsideTemperature != nil { 343 | tempDiff := *data.CabinTemperature - *data.OutsideTemperature 344 | if tempDiff < 0 { 345 | tempDiff = -tempDiff // Absolute difference 346 | } 347 | // More temperature difference = more power needed 348 | hvacPower += (tempDiff / 10.0) * 1.0 // 1kW per 10°C difference 349 | } 350 | 351 | // Adjust based on fan speed level 352 | if data.FanSpeedLevel != nil { 353 | fanMultiplier := *data.FanSpeedLevel / 3.0 // Assume max fan level is 3 354 | hvacPower *= fanMultiplier 355 | } 356 | telemetry.HVACPower = &hvacPower 357 | } 358 | 359 | // Lower priority - Tire pressure (convert from bar to kPa) 360 | if data.LeftFrontTirePressure != nil { 361 | // BYD sensor data now in bar; convert to kPa 362 | pressureKPa := *data.LeftFrontTirePressure * 100 363 | telemetry.TirePressureFL = &pressureKPa 364 | } 365 | if data.RightFrontTirePressure != nil { 366 | pressureKPa := *data.RightFrontTirePressure * 100 367 | telemetry.TirePressureFR = &pressureKPa 368 | } 369 | if data.LeftRearTirePressure != nil { 370 | pressureKPa := *data.LeftRearTirePressure * 100 371 | telemetry.TirePressureRL = &pressureKPa 372 | } 373 | if data.RightRearTirePressure != nil { 374 | pressureKPa := *data.RightRearTirePressure * 100 375 | telemetry.TirePressureRR = &pressureKPa 376 | } 377 | 378 | return telemetry 379 | } 380 | 381 | // SetTimeout configures the HTTP client timeout 382 | func (t *ABRPTransmitter) SetTimeout(timeout time.Duration) { 383 | t.httpClient.Timeout = timeout 384 | } 385 | 386 | // GetConnectionStatus returns detailed connection status for diagnostics 387 | func (t *ABRPTransmitter) GetConnectionStatus() map[string]interface{} { 388 | return map[string]interface{}{ 389 | "connected": t.IsConnected(), 390 | "api_key_set": t.apiKey != "", 391 | "token_set": t.token != "", 392 | "timeout": t.httpClient.Timeout, 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /internal/transmission/mqtt.go: -------------------------------------------------------------------------------- 1 | package transmission 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jkaberg/byd-hass/internal/mqtt" 11 | "github.com/jkaberg/byd-hass/internal/sensors" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // MQTTTransmitter transmits sensor data via MQTT 16 | type MQTTTransmitter struct { 17 | client *mqtt.Client 18 | deviceID string 19 | discoveryPrefix string 20 | logger *logrus.Logger 21 | publishedSensors map[string]bool // Tracks published discovery configs 22 | } 23 | 24 | // HADiscoveryConfig represents Home Assistant MQTT discovery configuration 25 | type HADiscoveryConfig struct { 26 | Name string `json:"name"` 27 | UniqueID string `json:"unique_id"` 28 | StateTopic string `json:"state_topic"` 29 | ValueTemplate string `json:"value_template,omitempty"` 30 | DeviceClass string `json:"device_class,omitempty"` 31 | UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` 32 | Device HADevice `json:"device"` 33 | AvailabilityTopic string `json:"availability_topic"` 34 | Icon string `json:"icon,omitempty"` 35 | StateClass string `json:"state_class,omitempty"` 36 | EntityCategory string `json:"entity_category,omitempty"` 37 | } 38 | 39 | // HADevice represents the device information for Home Assistant 40 | type HADevice struct { 41 | Identifiers []string `json:"identifiers"` 42 | Name string `json:"name"` 43 | Model string `json:"model"` 44 | Manufacturer string `json:"manufacturer"` 45 | SWVersion string `json:"sw_version,omitempty"` 46 | } 47 | 48 | // SensorConfig defines the configuration for each sensor 49 | type SensorConfig struct { 50 | Name string 51 | EntityID string 52 | EntityType string 53 | DeviceClass string 54 | Unit string 55 | Icon string 56 | StateClass string 57 | Category string 58 | ScaleFactor float64 // For unit conversion 59 | } 60 | 61 | // NewMQTTTransmitter creates a new MQTT transmitter 62 | func NewMQTTTransmitter(client *mqtt.Client, deviceID, discoveryPrefix string, logger *logrus.Logger) *MQTTTransmitter { 63 | return &MQTTTransmitter{ 64 | client: client, 65 | deviceID: deviceID, 66 | discoveryPrefix: discoveryPrefix, 67 | logger: logger, 68 | publishedSensors: make(map[string]bool), 69 | } 70 | } 71 | 72 | // getSensorConfigs builds sensor discovery configurations dynamically 73 | // from the canonical sensors.AllSensors slice. This removes the need to 74 | // manually maintain a duplicate list every time a new sensor is added. 75 | // 76 | // Icons, state-classes and other Home-Assistant niceties can be added 77 | // later via dedicated mapping tables, but we prefer to keep the core 78 | // list lean and fully data-driven for now. 79 | func (t *MQTTTransmitter) getSensorConfigs() []SensorConfig { 80 | // Build a lookup table for quick ID → definition mapping 81 | idSet := make(map[int]struct{}, len(sensors.PublishedSensorIDs())) 82 | for _, id := range sensors.PublishedSensorIDs() { 83 | idSet[id] = struct{}{} 84 | } 85 | 86 | configs := make([]SensorConfig, 0, len(idSet)) 87 | 88 | for _, def := range sensors.AllSensors { 89 | if _, ok := idSet[def.ID]; !ok { 90 | continue // skip sensors not in the allowed MQTT list 91 | } 92 | configs = append(configs, SensorConfig{ 93 | Name: def.EnglishName, 94 | EntityID: sensors.ToSnakeCase(def.FieldName), 95 | EntityType: def.Category, // "sensor" / "binary_sensor" 96 | DeviceClass: def.DeviceClass, // may be "" if not set 97 | Unit: def.UnitOfMeasurement, // may be "" if not set 98 | ScaleFactor: 1.0, // default; can be refined later 99 | }) 100 | } 101 | return configs 102 | } 103 | 104 | // publishDiscoveryForSensor publishes the discovery config for a single sensor. 105 | func (t *MQTTTransmitter) publishDiscoveryForSensor(sensor SensorConfig, device HADevice, baseTopic string) error { 106 | uniqueID := fmt.Sprintf("%s_%s", t.deviceID, sensor.EntityID) 107 | 108 | // Skip if already published 109 | if t.publishedSensors[uniqueID] { 110 | return nil 111 | } 112 | 113 | config := HADiscoveryConfig{ 114 | Name: sensor.Name, 115 | UniqueID: uniqueID, 116 | StateTopic: fmt.Sprintf("%s/state", baseTopic), 117 | ValueTemplate: fmt.Sprintf("{{ value_json.%s | default(0) }}", sensor.EntityID), 118 | AvailabilityTopic: fmt.Sprintf("%s/availability", baseTopic), 119 | Device: device, 120 | } 121 | 122 | if sensor.DeviceClass != "" { 123 | config.DeviceClass = sensor.DeviceClass 124 | } 125 | if sensor.Unit != "" { 126 | config.UnitOfMeasurement = sensor.Unit 127 | } 128 | if sensor.Icon != "" { 129 | config.Icon = sensor.Icon 130 | } 131 | if sensor.StateClass != "" { 132 | config.StateClass = sensor.StateClass 133 | } 134 | if sensor.Category != "" { 135 | config.EntityCategory = sensor.Category 136 | } 137 | 138 | topic := fmt.Sprintf("%s/%s/byd_car_%s/%s/config", 139 | t.discoveryPrefix, sensor.EntityType, t.deviceID, sensor.EntityID) 140 | 141 | if err := t.publishConfigRaw(topic, config); err != nil { 142 | return fmt.Errorf("failed to publish %s discovery config: %w", sensor.Name, err) 143 | } 144 | 145 | t.logger.WithFields(logrus.Fields{ 146 | "sensor_name": sensor.Name, 147 | "entity_id": sensor.EntityID, 148 | "topic": topic, 149 | }).Debug("Published sensor discovery config") 150 | 151 | // Mark as published 152 | t.publishedSensors[uniqueID] = true 153 | return nil 154 | } 155 | 156 | // publishDiscoveryConfigs ensures all available sensors have their discovery configs published. 157 | func (t *MQTTTransmitter) publishDiscoveryConfigs(data *sensors.SensorData) error { 158 | device := HADevice{ 159 | Identifiers: []string{fmt.Sprintf("byd_car_%s", t.deviceID)}, 160 | Name: "BYD Car", 161 | Model: "Car", 162 | Manufacturer: "BYD", 163 | SWVersion: "1.0.0", 164 | } 165 | baseTopic := fmt.Sprintf("byd_car/%s", t.deviceID) 166 | 167 | // Publish device_tracker discovery first (if not already done) 168 | if !t.publishedSensors["device_tracker"] { 169 | if err := t.publishDeviceTrackerDiscovery(baseTopic, device); err != nil { 170 | t.logger.WithError(err).Warn("Failed to publish device_tracker discovery") 171 | } else { 172 | t.logger.Debug("Device tracker discovery config published") 173 | t.publishedSensors["device_tracker"] = true 174 | } 175 | } 176 | 177 | sensorConfigs := t.getSensorConfigs() 178 | 179 | for _, config := range sensorConfigs { 180 | // Always publish Home-Assistant discovery for allowed sensors even if we don't 181 | // currently have a value for them. This guarantees that the full set of 182 | // entities defined in PublishedSensorIDs becomes available in the UI right from 183 | // the start. The ValueTemplate in publishDiscoveryForSensor already 184 | // employs a `default(0)` filter, so missing values will not break 185 | // rendering. 186 | if err := t.publishDiscoveryForSensor(config, device, baseTopic); err != nil { 187 | t.logger.WithError(err).WithField("sensor", config.Name).Error("Failed to publish discovery config") 188 | // Continue to the next sensor 189 | } 190 | } 191 | 192 | // Publish Last Transmission discovery 193 | if err := t.publishLastTransmissionDiscovery(baseTopic, device); err != nil { 194 | t.logger.WithError(err).Error("Failed to publish Last Transmission discovery") 195 | } 196 | 197 | // Publish derived Charging Status discovery (virtual sensor) 198 | if err := t.publishDerivedChargingStatusDiscovery(baseTopic, device); err != nil { 199 | t.logger.WithError(err).Error("Failed to publish Charging Status discovery") 200 | } 201 | 202 | return nil 203 | } 204 | 205 | // publishConfigRaw publishes a raw configuration object 206 | func (t *MQTTTransmitter) publishConfigRaw(topic string, config interface{}) error { 207 | payload, err := json.Marshal(config) 208 | if err != nil { 209 | return fmt.Errorf("failed to marshal discovery config: %w", err) 210 | } 211 | 212 | if err := t.client.Publish(topic, payload, true); err != nil { 213 | return fmt.Errorf("failed to publish discovery config to %s: %w", topic, err) 214 | } 215 | 216 | return nil 217 | } 218 | 219 | // buildStatePayload builds the JSON payload for the state topic 220 | func (t *MQTTTransmitter) buildStatePayload(data *sensors.SensorData) ([]byte, error) { 221 | state := make(map[string]interface{}) 222 | // Pre-compute allowed entityIDs in snake_case for quick filtering 223 | allowed := make(map[string]struct{}, len(sensors.PublishedSensorIDs())) 224 | for _, id := range sensors.PublishedSensorIDs() { 225 | if def := sensors.GetSensorByID(id); def != nil { 226 | allowed[sensors.ToSnakeCase(def.FieldName)] = struct{}{} 227 | } 228 | } 229 | 230 | v := reflect.ValueOf(data).Elem() 231 | tOf := v.Type() 232 | 233 | for i := 0; i < v.NumField(); i++ { 234 | field := v.Field(i) 235 | 236 | // Skip unexported fields or fields that are nil 237 | if !field.CanInterface() || (field.Kind() == reflect.Ptr && field.IsNil()) { 238 | continue 239 | } 240 | 241 | jsonTag := tOf.Field(i).Tag.Get("json") 242 | jsonKey := strings.Split(jsonTag, ",")[0] 243 | 244 | if jsonKey == "" || jsonKey == "-" { 245 | continue 246 | } 247 | 248 | if _, ok := allowed[jsonKey]; !ok { 249 | continue // not in MQTT allow-list 250 | } 251 | 252 | // Dereference pointer to get the actual value 253 | var value interface{} 254 | if field.Kind() == reflect.Ptr { 255 | value = field.Elem().Interface() 256 | } else { 257 | value = field.Interface() 258 | } 259 | state[jsonKey] = value 260 | } 261 | // Inject derived/virtual sensors ------------------------------------- 262 | state["charging_status"] = sensors.DeriveChargingStatus(data) 263 | 264 | // Add a 'state' field for the device_tracker 265 | if data.Speed != nil && *data.Speed > 0 { 266 | state["state"] = "moving" 267 | } else if sensors.DeriveChargingStatus(data) == "charging" { 268 | state["state"] = "charging" 269 | } else if data.PowerStatus != nil && *data.PowerStatus > 0 { 270 | state["state"] = "online" 271 | } else { 272 | state["state"] = "parked" 273 | } 274 | 275 | return json.Marshal(state) 276 | } 277 | 278 | // Transmit sends sensor data to MQTT 279 | func (t *MQTTTransmitter) Transmit(data *sensors.SensorData) error { 280 | if !t.client.IsConnected() { 281 | // Best-effort publish "offline" retained message (will silently drop if 282 | // the client really is disconnected). Ignore error. 283 | _ = t.publishAvailability(false) 284 | return fmt.Errorf("MQTT client not connected") 285 | } 286 | 287 | // Publish discovery config for available sensors if it hasn't been done 288 | if err := t.publishDiscoveryConfigs(data); err != nil { 289 | // Log error but don't block transmission 290 | t.logger.WithError(err).Error("Failed to publish Home Assistant discovery configs") 291 | } 292 | 293 | // Publish sensor data 294 | if err := t.publishSensorData(data); err != nil { 295 | return fmt.Errorf("failed to publish sensor data: %w", err) 296 | } 297 | 298 | // Publish location data if available 299 | if data.Location != nil { 300 | if err := t.publishLocationData(data); err != nil { 301 | // Log error but don't block other publications 302 | t.logger.WithError(err).Warn("Failed to publish location data") 303 | } 304 | } 305 | 306 | // Publish availability 307 | if err := t.publishAvailability(true); err != nil { 308 | return fmt.Errorf("failed to publish availability: %w", err) 309 | } 310 | 311 | // Publish last transmission timestamp 312 | if err := t.publishLastTransmission(); err != nil { 313 | return fmt.Errorf("failed to publish last transmission: %w", err) 314 | } 315 | 316 | t.logger.Debug("Data transmitted successfully") 317 | return nil 318 | } 319 | 320 | // publishSensorData publishes the main sensor data payload 321 | func (t *MQTTTransmitter) publishSensorData(data *sensors.SensorData) error { 322 | payload, err := t.buildStatePayload(data) 323 | if err != nil { 324 | return fmt.Errorf("failed to build state payload: %w", err) 325 | } 326 | 327 | topic := fmt.Sprintf("byd_car/%s/state", t.deviceID) 328 | if err := t.client.Publish(topic, payload, true); err != nil { 329 | return fmt.Errorf("failed to publish sensor data to %s: %w", topic, err) 330 | } 331 | 332 | t.logger.WithFields(logrus.Fields{ 333 | "topic": topic, 334 | "payload": string(payload), 335 | }).Debug("Published sensor data") 336 | 337 | return nil 338 | } 339 | 340 | // publishLocationData publishes location data to the device_tracker entity 341 | func (t *MQTTTransmitter) publishLocationData(data *sensors.SensorData) error { 342 | if data.Location == nil { 343 | return nil 344 | } 345 | 346 | topic := fmt.Sprintf("byd_car/%s/location", t.deviceID) 347 | payload := map[string]interface{}{ 348 | "latitude": data.Location.Latitude, 349 | "longitude": data.Location.Longitude, 350 | "gps_accuracy": data.Location.Accuracy, 351 | "battery": data.BatteryPercentage, 352 | "speed": data.Speed, 353 | } 354 | 355 | jsonPayload, err := json.Marshal(payload) 356 | if err != nil { 357 | return fmt.Errorf("failed to marshal location data: %w", err) 358 | } 359 | 360 | return t.client.Publish(topic, jsonPayload, false) 361 | } 362 | 363 | // publishDeviceTrackerDiscovery publishes the discovery config for the device tracker. 364 | func (t *MQTTTransmitter) publishDeviceTrackerDiscovery(baseTopic string, device HADevice) error { 365 | attributesTopic := fmt.Sprintf("%s/location", baseTopic) 366 | config := map[string]interface{}{ 367 | "name": "Location", 368 | "unique_id": fmt.Sprintf("%s_location", t.deviceID), 369 | "json_attributes_topic": attributesTopic, 370 | "source_type": "gps", 371 | "device": device, 372 | "availability_topic": fmt.Sprintf("%s/availability", baseTopic), 373 | } 374 | topic := fmt.Sprintf("%s/device_tracker/byd_car_%s/config", t.discoveryPrefix, t.deviceID) 375 | 376 | return t.publishConfigRaw(topic, config) 377 | } 378 | 379 | // publishAvailability publishes the availability status 380 | func (t *MQTTTransmitter) publishAvailability(online bool) error { 381 | payload := "online" 382 | if !online { 383 | payload = "offline" 384 | } 385 | 386 | topic := fmt.Sprintf("byd_car/%s/availability", t.deviceID) 387 | if err := t.client.Publish(topic, []byte(payload), true); err != nil { 388 | return fmt.Errorf("failed to publish availability to %s: %w", topic, err) 389 | } 390 | return nil 391 | } 392 | 393 | // publishLastTransmissionDiscovery publishes discovery config for the "Last Transmission" timestamp sensor 394 | func (t *MQTTTransmitter) publishLastTransmissionDiscovery(baseTopic string, device HADevice) error { 395 | uniqueID := fmt.Sprintf("%s_last_transmission", t.deviceID) 396 | 397 | // Skip if already published 398 | if t.publishedSensors[uniqueID] { 399 | return nil 400 | } 401 | 402 | config := HADiscoveryConfig{ 403 | Name: "Last Transmission", 404 | UniqueID: uniqueID, 405 | StateTopic: fmt.Sprintf("%s/last_transmission", baseTopic), 406 | AvailabilityTopic: fmt.Sprintf("%s/availability", baseTopic), 407 | DeviceClass: "timestamp", 408 | Device: device, 409 | } 410 | 411 | topic := fmt.Sprintf("%s/sensor/byd_car_%s/last_transmission/config", t.discoveryPrefix, t.deviceID) 412 | 413 | if err := t.publishConfigRaw(topic, config); err != nil { 414 | return fmt.Errorf("failed to publish Last Transmission discovery config: %w", err) 415 | } 416 | 417 | t.logger.WithFields(logrus.Fields{ 418 | "sensor_name": "Last Transmission", 419 | "entity_id": "last_transmission", 420 | "topic": topic, 421 | }).Debug("Published Last Transmission discovery config") 422 | 423 | t.publishedSensors[uniqueID] = true 424 | return nil 425 | } 426 | 427 | // publishLastTransmission publishes the current timestamp indicating the last successful transmission 428 | func (t *MQTTTransmitter) publishLastTransmission() error { 429 | topic := fmt.Sprintf("byd_car/%s/last_transmission", t.deviceID) 430 | timestamp := time.Now().Format(time.RFC3339) 431 | if err := t.client.Publish(topic, []byte(timestamp), true); err != nil { 432 | return fmt.Errorf("failed to publish last transmission timestamp to %s: %w", topic, err) 433 | } 434 | return nil 435 | } 436 | 437 | // publishDerivedChargingStatusDiscovery publishes discovery config for the virtual Charging Status sensor. 438 | func (t *MQTTTransmitter) publishDerivedChargingStatusDiscovery(baseTopic string, device HADevice) error { 439 | uniqueID := fmt.Sprintf("%s_charging_status", t.deviceID) 440 | 441 | if t.publishedSensors[uniqueID] { 442 | return nil 443 | } 444 | 445 | config := HADiscoveryConfig{ 446 | Name: "Charging Status", 447 | UniqueID: uniqueID, 448 | StateTopic: fmt.Sprintf("%s/state", baseTopic), 449 | ValueTemplate: "{{ value_json.charging_status }}", 450 | AvailabilityTopic: fmt.Sprintf("%s/availability", baseTopic), 451 | Device: device, 452 | Icon: "mdi:ev-station", // generic charging icon 453 | } 454 | 455 | topic := fmt.Sprintf("%s/sensor/byd_car_%s/charging_status/config", t.discoveryPrefix, t.deviceID) 456 | 457 | if err := t.publishConfigRaw(topic, config); err != nil { 458 | return err 459 | } 460 | 461 | t.logger.WithFields(logrus.Fields{ 462 | "sensor_name": "Charging Status", 463 | "entity_id": "charging_status", 464 | "topic": topic, 465 | }).Debug("Published Charging Status discovery config") 466 | 467 | // Mark as published 468 | t.publishedSensors[uniqueID] = true 469 | return nil 470 | } 471 | 472 | // IsConnected checks if the MQTT client is connected 473 | func (t *MQTTTransmitter) IsConnected() bool { 474 | return t.client.IsConnected() 475 | } 476 | -------------------------------------------------------------------------------- /internal/sensors/types.go: -------------------------------------------------------------------------------- 1 | package sensors 2 | 3 | import ( 4 | "time" 5 | "github.com/jkaberg/byd-hass/internal/location" 6 | ) 7 | 8 | // SensorData struct to hold all possible sensor values. 9 | // We use pointers to float64 for numeric values so we can distinguish between a missing value (nil) and a value of 0. 10 | type SensorData struct { 11 | Timestamp time.Time `json:"timestamp"` 12 | 13 | // --- Core Vehicle Data --- 14 | Speed *float64 `json:"speed,omitempty"` 15 | Mileage *float64 `json:"mileage,omitempty"` 16 | GearPosition *float64 `json:"gear_position,omitempty"` 17 | PowerStatus *float64 `json:"power_status,omitempty"` 18 | SteeringAngle *float64 `json:"steering_angle,omitempty"` 19 | AcceleratorDepth *float64 `json:"accelerator_depth,omitempty"` 20 | BrakeDepth *float64 `json:"brake_depth,omitempty"` 21 | 22 | // --- Powertrain & Battery --- 23 | EnginePower *float64 `json:"engine_power,omitempty"` 24 | EngineRPM *float64 `json:"engine_rpm,omitempty"` 25 | FrontMotorRPM *float64 `json:"front_motor_rpm,omitempty"` 26 | FrontMotorTorque *float64 `json:"front_motor_torque,omitempty"` 27 | RearMotorRPM *float64 `json:"rear_motor_rpm,omitempty"` 28 | FuelPercentage *float64 `json:"fuel_percentage,omitempty"` 29 | BatteryPercentage *float64 `json:"battery_percentage,omitempty"` 30 | BatteryCapacity *float64 `json:"battery_capacity,omitempty"` 31 | ChargingStatus *float64 `json:"charging_status,omitempty"` 32 | ChargeGunState *float64 `json:"charge_gun_state,omitempty"` 33 | MaxBatteryVoltage *float64 `json:"max_battery_voltage,omitempty"` 34 | MinBatteryVoltage *float64 `json:"min_battery_voltage,omitempty"` 35 | TotalPowerConsumption *float64 `json:"total_power_consumption,omitempty"` 36 | PowerConsumption100km *float64 `json:"power_consumption_100km,omitempty"` 37 | BatteryVoltage12V *float64 `json:"battery_voltage_12v,omitempty"` 38 | 39 | // --- Temperature Sensors --- 40 | AvgBatteryTemp *float64 `json:"avg_battery_temp,omitempty"` 41 | MinBatteryTemp *float64 `json:"min_battery_temp,omitempty"` 42 | MaxBatteryTemp *float64 `json:"max_battery_temp,omitempty"` 43 | CabinTemperature *float64 `json:"cabin_temperature,omitempty"` 44 | OutsideTemperature *float64 `json:"outside_temperature,omitempty"` 45 | TemperatureUnit *float64 `json:"temperature_unit,omitempty"` 46 | 47 | // --- Doors & Locks --- 48 | DriverDoor *float64 `json:"driver_door,omitempty"` 49 | PassengerDoor *float64 `json:"passenger_door,omitempty"` 50 | LeftRearDoor *float64 `json:"left_rear_door,omitempty"` 51 | RightRearDoor *float64 `json:"right_rear_door,omitempty"` 52 | TrunkDoor *float64 `json:"trunk_door,omitempty"` 53 | Hood *float64 `json:"hood,omitempty"` 54 | DriverDoorLock *float64 `json:"driver_door_lock,omitempty"` 55 | PassengerDoorLock *float64 `json:"passenger_door_lock,omitempty"` 56 | LeftRearDoorLock *float64 `json:"left_rear_door_lock,omitempty"` 57 | RightRearDoorLock *float64 `json:"right_rear_door_lock,omitempty"` 58 | TrunkLock *float64 `json:"trunk_lock,omitempty"` 59 | RemoteLockStatus *float64 `json:"remote_lock_status,omitempty"` 60 | LeftRearChildLock *float64 `json:"left_rear_child_lock,omitempty"` 61 | RightRearChildLock *float64 `json:"right_rear_child_lock,omitempty"` 62 | 63 | // --- Windows & Sunroof --- 64 | DriverWindowOpenPercent *float64 `json:"driver_window_open_percent,omitempty"` 65 | PassengerWindowOpenPercent *float64 `json:"passenger_window_open_percent,omitempty"` 66 | LeftRearWindowOpenPercent *float64 `json:"left_rear_window_open_percent,omitempty"` 67 | RightRearWindowOpenPercent *float64 `json:"right_rear_window_open_percent,omitempty"` 68 | SunroofOpenPercent *float64 `json:"sunroof_open_percent,omitempty"` 69 | SunshadeOpenPercent *float64 `json:"sunshade_open_percent,omitempty"` 70 | 71 | // --- Tire Pressures --- 72 | LeftFrontTirePressure *float64 `json:"left_front_tire_pressure,omitempty"` 73 | RightFrontTirePressure *float64 `json:"right_front_tire_pressure,omitempty"` 74 | LeftRearTirePressure *float64 `json:"left_rear_tire_pressure,omitempty"` 75 | RightRearTirePressure *float64 `json:"right_rear_tire_pressure,omitempty"` 76 | 77 | // --- Lights & Wipers --- 78 | LowBeamLights *float64 `json:"low_beam_lights,omitempty"` 79 | HighBeamLights *float64 `json:"high_beam_lights,omitempty"` 80 | FrontFogLights *float64 `json:"front_fog_lights,omitempty"` 81 | RearFogLights *float64 `json:"rear_fog_lights,omitempty"` 82 | ParkingLights *float64 `json:"parking_lights,omitempty"` 83 | DaytimeRunningLights *float64 `json:"daytime_running_lights,omitempty"` 84 | LeftTurnSignal *float64 `json:"left_turn_signal,omitempty"` 85 | RightTurnSignal *float64 `json:"right_turn_signal,omitempty"` 86 | HazardLights *float64 `json:"hazard_lights,omitempty"` 87 | WiperGear *float64 `json:"wiper_gear,omitempty"` 88 | FrontWiperSpeed *float64 `json:"front_wiper_speed,omitempty"` 89 | LastWiperTime *float64 `json:"last_wiper_time,omitempty"` 90 | 91 | // --- Climate Control (AC) --- 92 | ACStatus *float64 `json:"ac_status,omitempty"` 93 | DriverACTemperature *float64 `json:"driver_ac_temperature,omitempty"` 94 | FanSpeedLevel *float64 `json:"fan_speed_level,omitempty"` 95 | ACBlowingMode *float64 `json:"ac_blowing_mode,omitempty"` 96 | ACCirculationMode *float64 `json:"ac_circulation_mode,omitempty"` 97 | Weather *float64 `json:"weather,omitempty"` 98 | FootwellLights *float64 `json:"footwell_lights,omitempty"` 99 | 100 | // --- Driving Assistance & Safety --- 101 | ACCCruiseStatus *float64 `json:"acc_cruise_status,omitempty"` 102 | LaneKeepAssistStatus *float64 `json:"lane_keep_assist_status,omitempty"` 103 | DriverSeatbelt *float64 `json:"driver_seatbelt,omitempty"` 104 | PassengerSeatbeltWarn *float64 `json:"passenger_seatbelt_warn,omitempty"` 105 | Row2LeftSeatbelt *float64 `json:"row2_left_seatbelt,omitempty"` 106 | Row2RightSeatbelt *float64 `json:"row2_right_seatbelt,omitempty"` 107 | Row2CenterSeatbelt *float64 `json:"row2_center_seatbelt,omitempty"` 108 | DistanceToCarAhead *float64 `json:"distance_to_car_ahead,omitempty"` 109 | LaneCurvature *float64 `json:"lane_curvature,omitempty"` 110 | RightLineDistance *float64 `json:"right_line_distance,omitempty"` 111 | LeftLineDistance *float64 `json:"left_line_distance,omitempty"` 112 | CruiseSwitch *float64 `json:"cruise_switch,omitempty"` 113 | AutoParking *float64 `json:"auto_parking,omitempty"` 114 | 115 | // --- Radar Sensors --- 116 | RadarFrontLeft *float64 `json:"radar_front_left,omitempty"` 117 | RadarFrontRight *float64 `json:"radar_front_right,omitempty"` 118 | RadarRearLeft *float64 `json:"radar_rear_left,omitempty"` 119 | RadarRearRight *float64 `json:"radar_rear_right,omitempty"` 120 | RadarLeft *float64 `json:"radar_left,omitempty"` 121 | RadarFrontMidLeft *float64 `json:"radar_front_mid_left,omitempty"` 122 | RadarFrontMidRight *float64 `json:"radar_front_mid_right,omitempty"` 123 | RadarRearCenter *float64 `json:"radar_rear_center,omitempty"` 124 | RearLeftProximityAlert *float64 `json:"rear_left_proximity_alert,omitempty"` 125 | RearRightProximityAlert *float64 `json:"rear_right_proximity_alert,omitempty"` 126 | 127 | // --- Vehicle & System --- 128 | VehicleOperatingMode *float64 `json:"vehicle_operating_mode,omitempty"` 129 | VehicleRunningMode *float64 `json:"vehicle_running_mode,omitempty"` 130 | SurroundViewStatus *float64 `json:"surround_view_status,omitempty"` 131 | UIConfigVersion *float64 `json:"ui_config_version,omitempty"` 132 | SentryModeStatus *float64 `json:"sentry_mode_status,omitempty"` 133 | PowerOffRecordingConfig *float64 `json:"power_off_recording_config,omitempty"` 134 | PowerOffSentryAlarm *float64 `json:"power_off_sentry_alarm,omitempty"` 135 | WiFiStatus *float64 `json:"wifi_status,omitempty"` 136 | BluetoothStatus *float64 `json:"bluetooth_status,omitempty"` 137 | BluetoothSignalStrength *float64 `json:"bluetooth_signal_strength,omitempty"` 138 | WirelessADBSwitch *float64 `json:"wireless_adb_switch,omitempty"` 139 | SteeringRotationSpeed *float64 `json:"steering_rotation_speed,omitempty"` 140 | 141 | // --- AI & Video --- 142 | AIPersonConfidence *float64 `json:"ai_person_confidence,omitempty"` 143 | AIVehicleConfidence *float64 `json:"ai_vehicle_confidence,omitempty"` 144 | LastSentryTriggerTime *float64 `json:"last_sentry_trigger_time,omitempty"` 145 | LastSentryTriggerImage *string `json:"last_sentry_trigger_image,omitempty"` 146 | LastVideoStartTime *float64 `json:"last_video_start_time,omitempty"` 147 | LastVideoEndTime *float64 `json:"last_video_end_time,omitempty"` 148 | LastVideoPath *string `json:"last_video_path,omitempty"` 149 | 150 | // --- Location & Time --- 151 | Location *location.LocationData `json:"location,omitempty"` 152 | Year *float64 `json:"year,omitempty"` 153 | Month *float64 `json:"month,omitempty"` 154 | Day *float64 `json:"day,omitempty"` 155 | Hour *float64 `json:"hour,omitempty"` 156 | Minute *float64 `json:"minute,omitempty"` 157 | } 158 | 159 | // SensorDefinition provides metadata for a sensor. 160 | type SensorDefinition struct { 161 | ID int 162 | FieldName string 163 | ChineseName string 164 | EnglishName string 165 | Category string // "sensor", "binary_sensor", "device_tracker" 166 | DeviceClass string 167 | UnitOfMeasurement string 168 | ScaleFactor float64 169 | } 170 | 171 | // ---------------------------------------------------------------------------- 172 | // AllSensors 173 | // ------------ 174 | // This table contains one entry for every public field in SensorData that we 175 | // want to surface to higher layers (Diplus polling → MQTT discovery → Home 176 | // Assistant). Each row provides the metadata needed to build the Diplus query 177 | // template, scale raw values, and publish Home-Assistant discovery messages. 178 | // 179 | // ID – Stable numerical identifier (starts at 1, never reused) 180 | // FieldName – _Exact_ Go struct field in SensorData (PascalCase) 181 | // ChineseName – The precise label Diplus uses in its JSON output 182 | // EnglishName – Clear English label for UIs / logs 183 | // Category – "sensor" or "binary_sensor" (matches HA platform) 184 | // DeviceClass – Optional Home-Assistant device_class (speed, voltage, …) 185 | // Unit – Unit of measurement (km/h, °C, %, …) – empty if unit-less 186 | // ScaleFactor – Multiply raw value by this to obtain the real value (1 = none) 187 | // 188 | // Whenever you add / remove a field in SensorData **make sure** to update this 189 | // slice accordingly; build failures will warn you if you forget. 190 | // ---------------------------------------------------------------------------- 191 | var AllSensors = []SensorDefinition{ 192 | {1, "PowerStatus", "电源状态", "Power Status", "sensor", "", "", 1}, 193 | {2, "Speed", "车速", "Speed", "sensor", "speed", "km/h", 1}, 194 | {3, "Mileage", "里程", "Mileage", "sensor", "distance", "km", 0.1}, 195 | {4, "GearPosition", "档位", "Gear Position", "sensor", "", "", 1}, 196 | {5, "EngineRPM", "发动机转速", "Engine RPM", "sensor", "", "rpm", 1}, 197 | {6, "BrakePedalDepth", "刹车深度", "Brake Pedal Depth", "sensor", "", "%", 1}, 198 | {7, "AcceleratorPedalDepth", "加速踏板深度", "Accelerator Pedal Depth", "sensor", "", "%", 1}, 199 | {8, "FrontMotorRPM", "前电机转速", "Front Motor RPM", "sensor", "", "rpm", 1}, 200 | {9, "RearMotorRPM", "后电机转速", "Rear Motor RPM", "sensor", "", "rpm", 1}, 201 | {10, "EnginePower", "发动机功率", "Engine Power", "sensor", "power", "kW", 1}, 202 | {11, "FrontMotorTorque", "前电机扭矩", "Front Motor Torque", "sensor", "", "Nm", 1}, 203 | {12, "ChargeGunState", "充电枪插枪状态", "Charge Gun State", "binary_sensor", "", "", 1}, 204 | {13, "PowerConsumption100KM", "百公里电耗", "Power consumption per 100 kilometers", "sensor", "", "kWh/100km", 1}, 205 | {14, "MaxBatteryTemp", "最高电池温度", "Maximum Battery Temperature", "sensor", "temperature", "°C", 1}, 206 | {15, "AvgBatteryTemp", "平均电池温度", "Average Battery Temperature", "sensor", "temperature", "°C", 1}, 207 | {16, "MinBatteryTemp", "最低电池温度", "Minimum Battery Temperature", "sensor", "", "°C", 1}, 208 | {17, "MaxBatteryVoltage", "最高电池电压", "Max Battery Voltage", "sensor", "voltage", "V", 1}, // This is the 12V battery voltage 209 | {18, "MinBatteryVoltage", "最低电池电压", "Minimum Battery Voltage", "sensor", "", "V", 1}, 210 | {19, "LastWiperTime", "上次雨刮时间", "Last Wiper Time", "sensor", "timestamp", "", 1}, 211 | {20, "Weather", "天气", "Weather", "sensor", "distance", "", 1}, 212 | {21, "DriverSeatBeltStatus", "主驾驶安全带状态", "Driver's seat belt status", "binary_sensor", "", "", 1}, 213 | {22, "RemoteLockStatus", "远程锁车状态", "Remote Lock Status", "binary_sensor", "lock", "", 1}, 214 | // what is ID 23 and 24? not documeneted in the spec. 215 | {25, "CabinTemperature", "车内温度", "Cabin Temperature", "sensor", "", "°C", 1}, 216 | {26, "OutsideTemperature", "车外温度", "Outside Temperature", "sensor", "temperature", "°C", 1}, 217 | {27, "DriverACTemp", "主驾驶空调温度", "Driver AC temperature", "sensor", "", "°C", 1}, 218 | {28, "TemperatureUnit", "温度单位", "Temperature unit", "sensor", "", "", 1}, 219 | {29, "BatteryCapacity", "电池容量", "Battery Capacity", "sensor", "energy_storage", "kWh", 1}, // seems to be 0 all the time? 220 | {30, "SteeringWheelAngle", "方向盘转角", "Steering Wheel Angle", "sensor", "safety", "°", 1}, 221 | {31, "SteeringWheelSpeed", "方向盘转速", "Steering Sheel Speed", "sensor", "safety", "°/s", 1}, 222 | {32, "TotalPowerConsumption", "总电耗", "Total Power Consumption", "sensor", "safety", "kWh", 1}, 223 | {33, "BatteryPercentage", "电量百分比", "Battery Percentage", "sensor", "battery", "%", 1}, 224 | {34, "FuelPercentage", "油量百分比", "Fuel Percentage", "sensor", "battery", "%", 1}, 225 | {35, "TotalFuelConsumption", "总燃油消耗", "Total Fuel Consumption", "sensor", "timestamp", "L", 1}, 226 | {36, "LaneLineCurvature", "车道线曲率", "Lane Line Curvature", "sensor", "timestamp", "", 1}, 227 | {37, "RightLaneDistance", "右侧线距离", "Right Lane Distance", "sensor", "timestamp", "", 1}, 228 | {38, "LeftLaneDistance", "左侧线距离", "Left Lane Distance", "sensor", "timestamp", "", 1}, 229 | {39, "BatteryVoltage", "蓄电池电压", "Battery Voltage", "sensor", "", "", 1}, // seems to be 0 all the time? 230 | {40, "RadarLeftFront", "雷达左前", "Radar Left Front", "sensor", "", "m", 1}, 231 | {41, "RadarRightFront", "雷达右前", "Radar Right Front", "sensor", "", "m", 1}, 232 | {42, "RadarLeftRear", "雷达左后", "Radar Left Rear", "sensor", "", "m", 1}, 233 | {43, "RadarRightRear", "雷达右后", "Radar Right Rear", "sensor", "", "m", 1}, 234 | {44, "RadarLeft", "雷达左", "Radar Left", "sensor", "", "m", 1}, 235 | {45, "RadarFrontLeftCenter", "雷达前左中", "Radar Front Left Center", "sensor", "distance", "m", 1}, 236 | {46, "RadarFrontRightCenter", "雷达前右中", "Radar Front Right Center", "sensor", "distance", "m", 1}, 237 | {47, "RadarCenterRear", "雷达中后", "Radar Center Rear", "sensor", "distance", "m", 1}, 238 | {48, "FrontWiperSpeed", "前雨刮速度", "Front Wiper Speed", "sensor", "", "", 1}, 239 | {49, "WiperGear", "雨刮档位", "WiperGear", "sensor", "", "", 1}, 240 | {50, "CruiseSwitch", "巡航开关", "Cruise Switch", "binary_sensor", "", "", 1}, 241 | {51, "DistanceToVehicleAhead", "前车距离", "Distance To The Vehicle Ahead", "sensor", "distance", "m", 1}, 242 | {52, "ChargingStatus", "充电状态", "Charging Status", "sensor", "", "", 1}, 243 | {53, "LeftFrontTirePressure", "左前轮气压", "Left Front Tire Pressure", "sensor", "pressure", "bar", 0.01}, 244 | {54, "RightFrontTirePressure", "右前轮气压", "Right Front Tire Pressure", "sensor", "pressure", "bar", 0.01}, 245 | {55, "LeftRearTirePressure", "左后轮气压", "Left Rear Tire Pressure", "sensor", "pressure", "bar", 0.01}, 246 | {56, "RightRearTirePressure", "右后轮气压", "Right Rear Tire Pressure", "sensor", "pressure", "bar", 0.01}, 247 | {57, "LeftTurnSignal", "左转向灯", "Left Turn Signal", "binary_sensor", "light", "", 1}, 248 | {58, "RightTurnSignal", "右转向灯", "Right Turn Signal", "binary_sensor", "light", "", 1}, 249 | {59, "DriverDoorLock", "主驾车门锁", "Driver Door Lock", "binary_sensor", "light", "", 1}, 250 | // what is ID 60? not documeneted in the spec. 251 | {61, "DriverWindowOpenPercentage", "主驾车窗打开百分比", "Driver Window Open Percentage", "sensor", "light", "%", 1}, 252 | {62, "PassengerWindowOpenPercentage", "副驾车窗打开百分比", "Passenger Window Open Percentage", "sensor", "light", "%", 1}, 253 | {63, "LeftLearWindowOpenPercentage", "左后车窗打开百分比", "Left Rear Window Open Percentage", "sensor", "light", "%", 1}, 254 | {64, "RightRearWindowOpenPercentage", "右后车窗打开百分比", "Right Rear Window Open Percentage", "sensor", "light", "%", 1}, 255 | {65, "SunroofOpenPercentage", "天窗打开百分比", "Sunroof Open Percentage", "sensor", "light", "%", 1}, 256 | {66, "SunshadeOpenPercentage", "遮阳帘打开百分比", "SunshadeOpenPercentage", "sensor", "door", "%", 1}, 257 | {67, "VehicleWorkingMode", "整车工作模式", "Vehicle Working Mode", "sensor", "door", "", 1}, 258 | {68, "VehicleOperationMode", "整车运行模式", "Vehicle Operation Mode", "sensor", "door", "", 1}, 259 | {69, "Month", "月", "Month", "sensor", "door", "", 1}, 260 | {70, "Day", "日", "Day", "sensor", "door", "", 1}, 261 | {71, "Hour", "时", "Hour", "sensor", "door", "", 1}, 262 | {72, "Year", "分", "Year", "sensor", "lock", "", 1}, 263 | {73, "PassengerSeatBeltWarning", "副驾安全带警告", "Passenger Seat Belt Warning", "binary_sensor", "lock", "", 1}, 264 | {74, "SecondRowLeftSeatBelt", "二排左安全带", "Second Row Left Seat Belt", "binary_sensor", "lock", "", 1}, 265 | {75, "SecondRowRightSeatBelt", "二排右安全带", "Second Row Right Seat Belt", "binary_sensor", "lock", "", 1}, 266 | {76, "Second Row Center Seat Belt", "二排中安全带", "Second Row Center Seat Belt", "binary_sensor", "lock", "", 1}, 267 | {77, "ACStatus", "空调状态", "AC Status", "sensor", "", "", 1}, 268 | {78, "FanSpeedLevel", "风量档位", "Fan Speed Level", "sensor", "", "", 1}, 269 | {79, "ACCirculationMode", "空调循环方式", "AC Circulation Mode", "sensor", "", "", 1}, 270 | {80, "AC Outlet Mode", "空调出风模式", "AC Outlet Mode", "sensor", "", "", 1}, 271 | {81, "DriverDoor", "主驾车门", "Driver Door", "binary_sensor", "", "", 1}, 272 | {82, "PassengerDoor", "副驾车门", "Passenger Door", "binary_sensor", "safety", "", 1}, 273 | {83, "LeftRearDoor", "左后车门", "Left Rear Door", "binary_sensor", "safety", "", 1}, 274 | {84, "RightRearDoor", "右后车门", "Right Rear Door", "binary_sensor", "", "", 1}, 275 | {85, "Hood", "引擎盖", "Hood", "binary_sensor", "power", "", 1}, 276 | {86, "Trunk", "后备箱门", "Trunk", "binary_sensor", "", "", 1}, 277 | {87, "FuelTankCap", "油箱盖", "Fuel Tank Cap", "binary_sensor", "", "", 1}, 278 | {88, "AutomaticParking", "自动驻车", "Automatic Parking", "binary_sensor", "", "", 1}, 279 | {89, "ACCCruiseStatus", "ACC巡航状态", "ACC Cruise Status", "sensor", "", "", 1}, 280 | {90, "LeftRearApproachWarning", "左后接近告警", "Left Rear Approach Warning", "binary_sensor", "power", "", 1}, 281 | {91, "RightRearApproachWarning", "右后接近告警", "Right Rear Approach Warning", "binary_sensor", "", "", 1}, 282 | {92, "Lane Keeping Status", "车道保持状态", "Lane Keeping Status", "sensor", "", "", 1}, 283 | {93, "LeftRearDoorLock", "左后车门锁", "Left Rear Door Lock", "binary_sensor", "", "", 1}, 284 | {94, "PassengerDoorLock", "副驾车门锁", "Passenger Door Lock", "binary_sensor", "", "", 1}, 285 | {95, "RightRearDoorLock", "上次雨刮时间", "Right Rear Door Lock", "binary_sensor", "", "", 1}, 286 | {96, "TrunkDoorLock", "后备箱门锁", "Trunk Toor Lock", "binary_sensor", "", "", 1}, 287 | {97, "LeftRearChildLock", "左后儿童锁", "Left Rear Child Lock", "binary_sensor", "", "", 1}, 288 | {98, "RightRearChildLock", "右后儿童锁", "Right Rear Child Lock", "binary_sensor", "", "", 1}, 289 | {99, "LowBeam", "小灯", "Low Beam", "binary_sensor", "", "", 1}, 290 | {100, "LowBeam2", "近光灯", "Low Beam", "binary_sensor", "", "", 1}, 291 | {101, "HighBeam", "远光灯", "High Beam", "binary_sensor", "lock", "", 1}, 292 | // what is ID 102 and 103? not documeneted in the spec. 293 | {104, "FrontFogLamp", "前雾灯", "Front Fog Lamp", "binary_sensor", "", "", 1}, 294 | {105, "RearFogLamp", "后雾灯", "Rear Fog Lamp", "binary_sensor", "", "", 1}, 295 | {106, "Footlights", "脚照灯", "Footlights", "binary_sensor", "", "", 1}, 296 | {107, "DaytimeRunningLights", "日行灯", "Daytime Running Lights", "binary_sensor", "", "", 1}, 297 | {108, "EngineWaterTemperature", "发动机水温", "Engine Water Temperature", "sensor", "", "°C", 1}, 298 | {109, "DoubleFlash", "双闪", "DoubleFlash", "binary_sensor", "", "", 1}, 299 | 300 | {1001, "PanoramaStatus", "熄火录制配置", "PanoramaStatus", "binary_sensor", "", "", 1}, 301 | {1002, "ConfigUIVer", "熄火哨兵警报", "Configuration UI Version", "binary_sensor", "", "", 1}, 302 | {1003, "SentryStatus", "WiFi状态", "Sentry Status", "binary_sensor", "connectivity", "", 1}, 303 | {1004, "RecordingConfigSwitch", "蓝牙状态", "Recording Configuration Switch", "binary_sensor", "connectivity", "", 1}, 304 | {1006, "SentryAlarm", "蓝牙信号强度", "Sentry Alarm", "sensor", "signal_strength", "dBm", 1}, 305 | {1007, "WIFIStatus", "上次哨兵触发时间", "WIFI Status", "sensor", "timestamp", "", 1}, 306 | {1008, "BluetoothStatus", "上次哨兵触发图像", "Bluetooth Status", "sensor", "", "", 1}, 307 | {1009, "BluetoothSignalStrength", "上次录像开始时间", "Bluetooth Signal Strength", "sensor", "timestamp", "", 1}, 308 | {1101, "WirelessADBSwitch", "上次录像结束时间", "Wireless ADB Switch", "binary_sensor", "timestamp", "", 1}, 309 | } 310 | 311 | // GetSensorByID returns a sensor definition by its ID 312 | func GetSensorByID(id int) *SensorDefinition { 313 | for _, sensor := range AllSensors { 314 | if sensor.ID == id { 315 | return &sensor 316 | } 317 | } 318 | return nil 319 | } 320 | 321 | // GetScaleFactor returns the scaling factor for a given JSON field key (snake_case). 322 | // If no explicit factor is defined, 1.0 is returned. 323 | func GetScaleFactor(jsonKey string) float64 { 324 | factor := 1.0 325 | for _, s := range AllSensors { 326 | if ToSnakeCase(s.FieldName) == jsonKey { 327 | if s.ScaleFactor != 0 { 328 | factor = s.ScaleFactor // keep updating; last match wins 329 | } 330 | } 331 | } 332 | return factor 333 | } 334 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/data/data/com.termux/files/usr/bin/bash 2 | 3 | # BYD-HASS Bootstrapping Installation Script 4 | # This script sets up a self-healing, two-tiered system. 5 | # 1. A Termux:Boot script acts as the orchestrator. 6 | # 2. The orchestrator starts an external keep-alive script via ADB. 7 | # 3. The external script ensures the Termux app itself stays running. 8 | # 4. The orchestrator then starts the main byd-hass binary. 9 | 10 | #set -e 11 | 12 | # Disable strict error handling for interactive and ADB setup steps 13 | #set +e 14 | 15 | # Re-attach stdin to the user's terminal when the script is executed through a pipe (e.g. curl | bash) 16 | if [ ! -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then 17 | exec < /dev/tty 18 | fi 19 | 20 | # --- Configuration --- 21 | # Shared storage paths (primary location for binary & config) 22 | BINARY_NAME="byd-hass" 23 | SHARED_DIR="/storage/emulated/0/bydhass" 24 | BINARY_PATH="$SHARED_DIR/$BINARY_NAME" 25 | EXEC_PATH="/data/local/tmp/$BINARY_NAME" # Execution location inside Android shell (exec allowed) 26 | CONFIG_PATH="$SHARED_DIR/config.env" 27 | LOG_FILE="$SHARED_DIR/byd-hass.log" 28 | 29 | # Termux-local paths (used only for the Termux:Boot starter logs) 30 | INSTALL_DIR="$HOME/.byd-hass" 31 | INTERNAL_LOG_FILE="$INSTALL_DIR/starter.log" 32 | 33 | # Termux:Boot script (The starter that keeps the external guardian alive) 34 | BOOT_DIR="$HOME/.termux/boot" 35 | BOOT_SCRIPT_NAME="byd-hass-starter.sh" 36 | BOOT_GPS_SCRIPT_NAME="byd-hass-gpsdata.sh" 37 | BOOT_SCRIPT_PATH="$BOOT_DIR/$BOOT_SCRIPT_NAME" 38 | BOOT_GPS_SCRIPT_PATH="$BOOT_DIR/$BOOT_GPS_SCRIPT_NAME" 39 | 40 | # External guardian (runs under Android's 'sh') 41 | ADB_KEEPALIVE_SCRIPT_NAME="keep-alive.sh" 42 | ADB_KEEPALIVE_SCRIPT_PATH="$SHARED_DIR/$ADB_KEEPALIVE_SCRIPT_NAME" 43 | ADB_LOG_FILE="$SHARED_DIR/keep-alive.log" 44 | 45 | # ADB connection to self 46 | ADB_SERVER="localhost:5555" 47 | 48 | # GitHub repo details 49 | REPO="jkaberg/byd-hass" 50 | ASSET_NAME="byd-hass-arm64" 51 | RELEASES_API="https://api.github.com/repos/$REPO/releases/latest" 52 | 53 | # Dependency apps (package name, download URL) 54 | DIPLUS_PKG="com.van.diplus" 55 | DIPLUS_URL="http://jt.x2x.fun:852/Update/Apk/diplus.1.3.7.apk" 56 | TERMUX_API_PKG="com.termux.api" 57 | TERMUX_API_RELEASES="https://api.github.com/repos/termux/termux-api/releases/latest" 58 | TERMUX_BOOT_PKG="com.termux.boot" 59 | TERMUX_BOOT_RELEASES="https://api.github.com/repos/termux/termux-boot/releases/latest" 60 | 61 | # Local temporary download path 62 | TEMP_BINARY_PATH="/data/data/com.termux/files/usr/tmp/$BINARY_NAME" 63 | TEMP_APK_PATH="/data/data/com.termux/files/usr/tmp" 64 | 65 | # Colors 66 | RED='\033[0;31m' 67 | GREEN='\033[0;32m' 68 | YELLOW='\033[1;33m' 69 | BLUE='\033[0;34m' 70 | NC='\033[0m' 71 | 72 | # Helper function: ensure ADB connection, then run the given command in the device shell 73 | adbs() { 74 | # Establish connection if it is not already present 75 | if ! adb devices | grep -q "$ADB_SERVER"; then 76 | echo "Connecting to ADB $ADB_SERVER ..." 77 | adb connect "$ADB_SERVER" >/dev/null 2>&1 || return 1 78 | fi 79 | # Execute the requested command in the device shell 80 | adb -s "$ADB_SERVER" shell "$@" 81 | } 82 | 83 | # Helper function: check if an app is installed 84 | is_app_installed() { 85 | local pkg="$1" 86 | adbs "pm list packages" 2>/dev/null | grep -q "package:$pkg" 87 | } 88 | 89 | # Helper function: install APK via ADB 90 | install_apk() { 91 | local apk_path="$1" 92 | local pkg_name="$2" 93 | echo " Installing $pkg_name..." 94 | if adb -s "$ADB_SERVER" install -r "$apk_path" 2>&1 | grep -q "Success"; then 95 | echo " ✅ $pkg_name installed successfully." 96 | return 0 97 | else 98 | echo " ❌ Failed to install $pkg_name." 99 | return 1 100 | fi 101 | } 102 | 103 | # Helper function: get latest APK URL from GitHub releases 104 | get_github_apk_url() { 105 | local releases_api="$1" 106 | local release_info 107 | release_info=$(curl -s "$releases_api") 108 | # Look for APK asset (prefer universal or arm64) 109 | echo "$release_info" | jq -r '.assets[] | select(.name | test("\\.apk$")) | .browser_download_url' | head -1 110 | } 111 | 112 | # Comprehensive cleanup function 113 | cleanup_all_processes() { 114 | echo -e "${YELLOW}Performing cleanup of all BYD-HASS processes...${NC}" 115 | 116 | # Kill Android-side processes via ADB 117 | echo "Stopping Android-side processes..." 118 | adbs "pkill -f $ADB_KEEPALIVE_SCRIPT_NAME" 2>/dev/null || true 119 | adbs "pkill -f $BINARY_NAME" 2>/dev/null || true 120 | adbs "pkill -f byd-hass" 2>/dev/null || true 121 | # Force kill any remaining processes by exact binary path 122 | adbs "pkill -f $EXEC_PATH" 2>/dev/null || true 123 | adbs "pkill -f $BINARY_PATH" 2>/dev/null || true 124 | 125 | # Kill Termux-side processes 126 | echo "Stopping Termux-side processes..." 127 | pkill -f "$BOOT_SCRIPT_NAME" 2>/dev/null || true 128 | pkill -f "$BOOT_GPS_SCRIPT_NAME" 2>/dev/null || true 129 | pkill -f "byd-hass-starter.sh" 2>/dev/null || true 130 | pkill -f "byd-hass-gpsdata.sh" 2>/dev/null || true 131 | pkill -f "$BINARY_NAME" 2>/dev/null || true 132 | pkill -f "byd-hass" 2>/dev/null || true 133 | 134 | # Wait a moment for processes to terminate 135 | sleep 2 136 | 137 | # Double-check and force kill any stubborn processes 138 | echo "Performing final cleanup..." 139 | adbs "ps | grep -E '(keep-alive|byd-hass|gpsdata)' | grep -v grep | awk '{print \$2}' | xargs -r kill -9" 2>/dev/null || true 140 | ps aux | grep -E '(byd-hass|keep-alive|gpsdata)' | grep -v grep | awk '{print $2}' | xargs -r kill -9 2>/dev/null || true 141 | 142 | echo "✅ All processes terminated." 143 | } 144 | 145 | # --- Script Start --- 146 | echo -e "${GREEN}🚗 BYD-HASS Bootstrapping Installer${NC}" 147 | 148 | # Handle cleanup-only mode 149 | if [ "$1" = "cleanup" ]; then 150 | echo -e "\n${BLUE}Cleanup mode - stopping all BYD-HASS processes...${NC}" 151 | cleanup_all_processes 152 | echo -e "\n${GREEN}✅ Cleanup complete. All BYD-HASS processes have been terminated.${NC}" 153 | exit 0 154 | fi 155 | 156 | # 1. Setup Termux Environment 157 | echo -e "\n${BLUE}1. Setting up Termux environment...${NC}" 158 | echo "Installing dependencies (adb, curl, jq, termux-api)..." 159 | pkg install -y android-tools curl jq termux-api bc >/dev/null 2>&1 160 | echo "✅ Environment ready." 161 | 162 | # 2. Explain Manual Steps 163 | echo -e "\n${BLUE}2. Important Manual Steps Required:${NC}" 164 | echo -e "${YELLOW} - You must enable 'Wireless debugging' in Android Developer Options.${NC}" 165 | read -p "Press [Enter] to continue once you have completed this step..." 166 | 167 | # 3a. Connect ADB to self 168 | echo -e "\n${BLUE}3a. Connecting ADB to localhost (make sure to Accept and remember the connection)...${NC}" 169 | if adbs true; then 170 | echo "✅ ADB connected." 171 | else 172 | echo -e "${RED}❌ Failed to connect to ADB.${NC}" 173 | exit 1 174 | fi 175 | 176 | # 3b. Check and install dependencies 177 | echo -e "\n${BLUE}3b. Checking required apps...${NC}" 178 | 179 | MISSING_APPS="" 180 | if ! is_app_installed "$DIPLUS_PKG"; then 181 | MISSING_APPS="$MISSING_APPS Diplus" 182 | echo " ⚠️ Diplus not installed" 183 | else 184 | echo " ✅ Diplus installed" 185 | fi 186 | if ! is_app_installed "$TERMUX_API_PKG"; then 187 | MISSING_APPS="$MISSING_APPS Termux:API" 188 | echo " ⚠️ Termux:API not installed" 189 | else 190 | echo " ✅ Termux:API installed" 191 | fi 192 | if ! is_app_installed "$TERMUX_BOOT_PKG"; then 193 | MISSING_APPS="$MISSING_APPS Termux:Boot" 194 | echo " ⚠️ Termux:Boot not installed" 195 | else 196 | echo " ✅ Termux:Boot installed" 197 | fi 198 | 199 | if [ -n "$MISSING_APPS" ]; then 200 | echo -e "\n${YELLOW}Missing apps:$MISSING_APPS${NC}" 201 | read -p " - Do you want to install missing apps now? (Y/n): " INSTALL_DEPS || true 202 | if [ "${INSTALL_DEPS,,}" != "n" ]; then 203 | # Install Diplus if missing 204 | if ! is_app_installed "$DIPLUS_PKG"; then 205 | echo " Downloading Diplus..." 206 | curl -sL -o "$TEMP_APK_PATH/diplus.apk" "$DIPLUS_URL" 207 | install_apk "$TEMP_APK_PATH/diplus.apk" "Diplus" 208 | rm -f "$TEMP_APK_PATH/diplus.apk" 209 | fi 210 | 211 | # Install Termux:API if missing 212 | if ! is_app_installed "$TERMUX_API_PKG"; then 213 | echo " Downloading Termux:API..." 214 | TERMUX_API_URL=$(get_github_apk_url "$TERMUX_API_RELEASES") 215 | if [ -n "$TERMUX_API_URL" ] && [ "$TERMUX_API_URL" != "null" ]; then 216 | curl -sL -o "$TEMP_APK_PATH/termux-api.apk" "$TERMUX_API_URL" 217 | install_apk "$TEMP_APK_PATH/termux-api.apk" "Termux:API" 218 | rm -f "$TEMP_APK_PATH/termux-api.apk" 219 | else 220 | echo " ❌ Could not find Termux:API APK download URL" 221 | fi 222 | fi 223 | 224 | # Install Termux:Boot if missing 225 | if ! is_app_installed "$TERMUX_BOOT_PKG"; then 226 | echo " Downloading Termux:Boot..." 227 | TERMUX_BOOT_URL=$(get_github_apk_url "$TERMUX_BOOT_RELEASES") 228 | if [ -n "$TERMUX_BOOT_URL" ] && [ "$TERMUX_BOOT_URL" != "null" ]; then 229 | curl -sL -o "$TEMP_APK_PATH/termux-boot.apk" "$TERMUX_BOOT_URL" 230 | install_apk "$TEMP_APK_PATH/termux-boot.apk" "Termux:Boot" 231 | rm -f "$TEMP_APK_PATH/termux-boot.apk" 232 | else 233 | echo " ❌ Could not find Termux:Boot APK download URL" 234 | fi 235 | fi 236 | else 237 | echo -e "${YELLOW} Skipping app installation. Please install manually before continuing.${NC}" 238 | fi 239 | else 240 | echo " ✅ All required apps are installed" 241 | fi 242 | 243 | # 3c. Enable background start for Termux, Termux:Boot and Termux:API 244 | echo -e "\n${BLUE}3c. Opening 'Deactive background start' app, uncheck Diplus, Termux, Termux:Boot and Termux:API and hit OK...${NC}" 245 | adb -s "$ADB_SERVER" shell "am start -n com.byd.appstartmanagement/.frame.AppStartManagement" >/dev/null 2>&1 || true 246 | read -p "Press [Enter] to continue once you have completed these steps..." || true 247 | 248 | # 3d. BYD Traffic Monitor (optional toggle) 249 | echo -e "\n${BLUE}3d. BYD Traffic Monitor${NC}" 250 | TRAFFIC_MONITOR_PKG="com.byd.trafficmonitor" 251 | TRAFFIC_STATE=$(adbs "pm list packages -d" 2>/dev/null | grep -c "$TRAFFIC_MONITOR_PKG" || echo "0") 252 | 253 | if [ "$TRAFFIC_STATE" -gt 0 ]; then 254 | echo " Status: ${RED}disabled${NC}" 255 | read -p " - Do you want to re-enable the BYD Traffic Monitor? (y/N): " ENABLE_TRAFFIC || true 256 | if [ "${ENABLE_TRAFFIC,,}" == "y" ]; then 257 | adbs "pm enable $TRAFFIC_MONITOR_PKG" 2>/dev/null || true 258 | echo " ✅ BYD Traffic Monitor enabled" 259 | else 260 | echo " Keeping disabled." 261 | fi 262 | else 263 | echo " Status: ${GREEN}enabled${NC}" 264 | echo -e " ${YELLOW}Disabling allows byd-hass to keep running when the car is turned off.${NC}" 265 | read -p " - Do you want to disable the BYD Traffic Monitor? (y/N): " DISABLE_TRAFFIC || true 266 | if [ "${DISABLE_TRAFFIC,,}" == "y" ]; then 267 | adbs "pm disable-user --user 0 $TRAFFIC_MONITOR_PKG" 2>/dev/null || true 268 | echo " ✅ BYD Traffic Monitor disabled" 269 | else 270 | echo " Keeping enabled." 271 | fi 272 | fi 273 | 274 | # 4. Create Directories 275 | echo -e "\n${BLUE}4. Creating necessary directories...${NC}" 276 | # Local directories for logs 277 | mkdir -p "$INSTALL_DIR" 2>/dev/null || true 278 | # Starter script directory 279 | mkdir -p "$BOOT_DIR" 2>/dev/null || true 280 | # Shared storage on Android side (binary + config + logs) 281 | adbs "mkdir -p $SHARED_DIR" 282 | echo "✅ Directories created." 283 | 284 | # 5. Download Latest Binary 285 | echo -e "\n${BLUE}5. Downloading latest binary from GitHub...${NC}" 286 | RELEASE_INFO=$(curl -s "$RELEASES_API") 287 | DOWNLOAD_URL=$(echo "$RELEASE_INFO" | jq -r --arg ASSET_NAME "$ASSET_NAME" '.assets[] | select(.name == $ASSET_NAME) | .browser_download_url') 288 | if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" == "null" ]; then 289 | echo -e "${RED}❌ Could not find asset '$ASSET_NAME' in the latest release.${NC}" 290 | exit 1 291 | fi 292 | LATEST_VERSION=$(echo "$RELEASE_INFO" | jq -r .tag_name) 293 | echo "Downloading '$ASSET_NAME' v$LATEST_VERSION..." 294 | curl -sL -o "$TEMP_BINARY_PATH" "$DOWNLOAD_URL" 295 | chmod +x "$TEMP_BINARY_PATH" 296 | echo "✅ Download complete." 297 | 298 | # 6. Stop Previous Instances (Comprehensive Cleanup) 299 | echo -e "\n${BLUE}6. Stopping any previous instances...${NC}" 300 | cleanup_all_processes 301 | 302 | # Move new binary into shared storage location 303 | mv "$TEMP_BINARY_PATH" "$BINARY_PATH" 304 | 305 | # Copy binary into exec-friendly location and make it runnable for the shell user 306 | echo -e "\n${BLUE}6b. Copying binary to exec-friendly path (/data/local/tmp)...${NC}" 307 | adbs "cp $BINARY_PATH $EXEC_PATH && chmod 755 $EXEC_PATH" 308 | echo "✅ Binary copied to $EXEC_PATH and made executable." 309 | 310 | # 7. Configuration 311 | CONFIG_CHANGED=false 312 | if [ -f "$CONFIG_PATH" ]; then 313 | echo -e "\n${BLUE}7. Existing configuration detected at $CONFIG_PATH.${NC}" 314 | read -p " - Do you want to update the configuration? (y/N): " UPDATE_CONF || true 315 | if [ "${UPDATE_CONF,,}" == "y" ]; then 316 | CONFIG_CHANGED=true 317 | else 318 | echo "✅ Keeping existing configuration." 319 | fi 320 | else 321 | CONFIG_CHANGED=true 322 | fi 323 | 324 | if [ "$CONFIG_CHANGED" = true ]; then 325 | echo -e "\n${BLUE}7. Please provide your configuration:${NC}" 326 | read -p " - MQTT WebSocket URL (e.g., ws://user:pass@host:port): " MQTT_URL || true 327 | read -p " - ABRP API Key (optional): " ABRP_API_KEY || true 328 | if [ -n "$ABRP_API_KEY" ]; then 329 | read -p " - ABRP User Token: " ABRP_TOKEN || true 330 | else 331 | ABRP_TOKEN="" 332 | fi 333 | read -p " - Enable verbose logging? (y/N): " VERBOSE_INPUT || true 334 | VERBOSE=$([ "${VERBOSE_INPUT,,}" == "y" ] && echo "true" || echo "false") 335 | # Ask whether the ABRP Android app must be running (only relevant if an API key was provided) 336 | if [ -n "$ABRP_API_KEY" ]; then 337 | read -p " - Require the ABRP Android app to be running? (Y/n): " REQUIRE_ABRP_INPUT || true 338 | REQUIRE_ABRP_APP=$([ "${REQUIRE_ABRP_INPUT,,}" == "n" ] && echo "false" || echo "true") 339 | else 340 | # No ABRP telemetry configured, disable requirement explicitly 341 | REQUIRE_ABRP_APP="false" 342 | fi 343 | 344 | # 8. Create or update Environment File 345 | echo -e "\n${BLUE}8. Saving environment configuration...${NC}" 346 | cat > "$CONFIG_PATH" << EOF 347 | # Configuration for byd-hass service 348 | export BYD_HASS_MQTT_URL='$MQTT_URL' 349 | export BYD_HASS_ABRP_API_KEY='$ABRP_API_KEY' 350 | export BYD_HASS_ABRP_TOKEN='$ABRP_TOKEN' 351 | export BYD_HASS_VERBOSE='$VERBOSE' 352 | export BYD_HASS_REQUIRE_ABRP_APP='$REQUIRE_ABRP_APP' 353 | EOF 354 | echo "✅ Config file saved at $CONFIG_PATH" 355 | else 356 | echo "Using existing configuration file." 357 | fi 358 | 359 | # 9. Create External Guardian Script 360 | echo -e "\n${BLUE}9. Creating external keep-alive script...${NC}" 361 | adbs "cat > $ADB_KEEPALIVE_SCRIPT_PATH" << KEEP_ALIVE_EOF 362 | #!/system/bin/sh 363 | echo "[\$(date)] BYD-HASS keep-alive started." >> "$ADB_LOG_FILE" 364 | 365 | # Path to binary & config (mounted shared storage) 366 | BIN_EXEC="$EXEC_PATH" # Exec-friendly path inside Android shell 367 | BIN_SRC="$BINARY_PATH" # Persistent copy in shared storage 368 | CONFIG_PATH="$CONFIG_PATH" 369 | LOG_FILE="$LOG_FILE" 370 | ADB_LOG_FILE="$ADB_LOG_FILE" 371 | 372 | # Track current day for daily log rotation 373 | CUR_DAY="\$(date +%Y%m%d)" 374 | 375 | # Ensure executable exists (copy from shared storage if /data/local/tmp was cleared) 376 | if [ ! -x "\$BIN_EXEC" ]; then 377 | cp "\$BIN_SRC" "\$BIN_EXEC" && chmod 755 "\$BIN_EXEC" 378 | fi 379 | 380 | while true; do 381 | # Export configuration variables if present 382 | if [ -f "\$CONFIG_PATH" ]; then 383 | . "\$CONFIG_PATH" 384 | fi 385 | 386 | # Rotate logs daily, keep only yesterday's copy 387 | NEW_DAY="\$(date +%Y%m%d)" 388 | if [ "\$NEW_DAY" != "\$CUR_DAY" ]; then 389 | # Remove previous .old 390 | rm -f "\$LOG_FILE.old" "\$ADB_LOG_FILE.old" 391 | # Rotate current to .old if exists 392 | [ -f "\$LOG_FILE" ] && mv "\$LOG_FILE" "\$LOG_FILE.old" 393 | [ -f "\$ADB_LOG_FILE" ] && mv "\$ADB_LOG_FILE" "\$ADB_LOG_FILE.old" 394 | CUR_DAY="\$NEW_DAY" 395 | fi 396 | 397 | # Ensure executable exists (copy from shared storage if /data/local/tmp was cleared) 398 | if [ ! -x "\$BIN_EXEC" ]; then 399 | cp "\$BIN_SRC" "\$BIN_EXEC" && chmod 755 "\$BIN_EXEC" 400 | fi 401 | 402 | if ! pgrep -f "\$BIN_EXEC" > /dev/null; then 403 | echo "[\$(date)] BYD-HASS not running. Starting it..." >> "$ADB_LOG_FILE" 404 | nohup "\$BIN_EXEC" >> \$LOG_FILE 2>&1 & 405 | fi 406 | sleep 10 407 | done 408 | KEEP_ALIVE_EOF 409 | adbs "chmod +x $ADB_KEEPALIVE_SCRIPT_PATH" 410 | echo "✅ External keep-alive script created." 411 | 412 | # 10. Create Termux:Boot Orchestrator Script 413 | echo -e "\n${BLUE}10. Creating Termux:Boot orchestrator script...${NC}" 414 | cat > "$BOOT_SCRIPT_PATH" << BOOT_EOF 415 | #!/data/data/com.termux/files/usr/bin/sh 416 | termux-wake-lock 417 | 418 | # Ensure ADB is connected before executing commands 419 | if ! adb devices | grep -q "$ADB_SERVER"; then 420 | adb connect "$ADB_SERVER" > /dev/null 2>&1 421 | fi 422 | 423 | # This script is the starter, launched by Termux:Boot. It only ensures 424 | # that the external keep-alive guardian is running on the Android side. 425 | 426 | # --- Simple Log Rotation for starter log --- 427 | if [ -f "$INTERNAL_LOG_FILE" ]; then 428 | mv -f "$INTERNAL_LOG_FILE" "$INTERNAL_LOG_FILE.old" 429 | fi 430 | 431 | # Redirect all subsequent output of this orchestrator session to the fresh log 432 | exec >> "$INTERNAL_LOG_FILE" 2>&1 433 | 434 | echo "---" 435 | echo "[\$(date)] Starter script running." 436 | 437 | while true; do 438 | # Ensure ADB connection is alive; reconnect if necessary 439 | if ! adb devices | grep -q "$ADB_SERVER"; then 440 | adb connect "$ADB_SERVER" >/dev/null 2>&1 441 | fi 442 | 443 | # Count keep-alive processes by checking if the script file is being executed 444 | # Use a more reliable method that won't match the check itself 445 | PROCESS_COUNT=\$(adb -s "$ADB_SERVER" shell "ps -ef 2>/dev/null | grep '/storage/emulated/0/bydhass/keep-alive.sh' | grep -v grep | wc -l" 2>/dev/null || echo "0") 446 | 447 | if [ "\${PROCESS_COUNT:-0}" -eq 0 ]; then 448 | echo "[\$(date)] Keep-alive not running. Starting it..." 449 | adb -s "$ADB_SERVER" shell "nohup sh $ADB_KEEPALIVE_SCRIPT_PATH > /dev/null 2>&1 &" 450 | elif [ "\${PROCESS_COUNT:-0}" -gt 1 ]; then 451 | echo "[\$(date)] Multiple keep-alive processes detected (\$PROCESS_COUNT). Cleaning up..." 452 | adb -s "$ADB_SERVER" shell "pkill -f $ADB_KEEPALIVE_SCRIPT_NAME" 453 | sleep 2 454 | echo "[\$(date)] Starting single keep-alive process..." 455 | adb -s "$ADB_SERVER" shell "nohup sh $ADB_KEEPALIVE_SCRIPT_PATH > /dev/null 2>&1 &" 456 | fi 457 | sleep 60 458 | done 459 | BOOT_EOF 460 | chmod +x "$BOOT_SCRIPT_PATH" 461 | echo "✅ Termux:Boot orchestrator script created." 462 | 463 | # 10a. Create Termux:Boot GPS Script 464 | echo -e "\n${BLUE}10a. Creating Termux:Boot GPS script...${NC}" 465 | cat > "$BOOT_GPS_SCRIPT_PATH" << 'BOOT_GPS_EOF' 466 | #!/data/data/com.termux/files/usr/bin/bash 467 | 468 | # --- CONFIG ----------------------------------------------------- 469 | 470 | INTERVAL=12 # seconds 471 | 472 | # Previous values (empty on first run) 473 | OLD_LAT="" 474 | OLD_LON="" 475 | 476 | # Trim function: keep 6 decimal digits 477 | trim() { 478 | printf "%.6f" "$1" 479 | } 480 | 481 | trim_acc() { 482 | printf "%.2f" "$1" 483 | } 484 | 485 | to_decimal() { 486 | printf "%f" "$1" 487 | } 488 | 489 | # --- LOOP ------------------------------------------------------- 490 | while true; do 491 | 492 | # Request GPS fix 493 | LOC=$(termux-location) 494 | 495 | # If termux-location failed, wait and retry 496 | if [ -z "$LOC" ]; then 497 | sleep $INTERVAL 498 | continue 499 | fi 500 | 501 | # Extract raw values 502 | RAW_LAT=$(echo "$LOC" | jq -r .latitude) 503 | RAW_LON=$(echo "$LOC" | jq -r .longitude) 504 | SPD=$(echo "$LOC" | jq -r .speed) 505 | RAW_ACC=$(echo "$LOC" | jq -r .accuracy) 506 | 507 | # Trim coordinates 508 | LAT=$(trim "$RAW_LAT") 509 | LON=$(trim "$RAW_LON") 510 | ACC=$(trim_acc "$RAW_ACC") 511 | 512 | # ------------------------- 513 | # CHANGE DETECTION SECTION 514 | # ------------------------- 515 | 516 | if [ -n "$OLD_LAT" ] && [ -n "$OLD_LON" ]; then 517 | 518 | # Compute difference 519 | DIFF_LAT=$(awk -v a="$LAT" -v b="$OLD_LAT" 'BEGIN{print (a-b)}') 520 | DIFF_LON=$(awk -v a="$LON" -v b="$OLD_LON" 'BEGIN{print (a-b)}') 521 | 522 | # Absolute values 523 | ABS_LAT=$(awk -v x="$DIFF_LAT" 'BEGIN {print (x<0?-x:x)}') 524 | ABS_LON=$(awk -v x="$DIFF_LON" 'BEGIN {print (x<0?-x:x)}') 525 | 526 | # Only publish if moved >0.00001° (~1 m) 527 | ABS_LAT_DEC=$(to_decimal "$ABS_LAT") 528 | ABS_LON_DEC=$(to_decimal "$ABS_LON") 529 | 530 | if (( $(echo "$ABS_LAT_DEC < 0.00001" | bc -l) )) && \ 531 | (( $(echo "$ABS_LON_DEC < 0.00001" | bc -l) )); then 532 | # No significant change → skip publish 533 | sleep $INTERVAL 534 | continue 535 | fi 536 | fi 537 | 538 | # Update previous values 539 | OLD_LAT="$LAT" 540 | OLD_LON="$LON" 541 | 542 | # ------------------------- 543 | # JSON PAYLOAD 544 | # ------------------------- 545 | 546 | JSON_PAYLOAD=$(jq -n -c \ 547 | --arg lat "$LAT" \ 548 | --arg lon "$LON" \ 549 | --arg spd "$SPD" \ 550 | --arg acc "$ACC" \ 551 | '{ 552 | latitude: ($lat|tonumber), 553 | longitude: ($lon|tonumber), 554 | speed: ($spd|tonumber), 555 | accuracy: ($acc|tonumber) 556 | }') 557 | 558 | echo "$JSON_PAYLOAD" > /storage/emulated/0/bydhass/gps 559 | sleep $INTERVAL 560 | done 561 | BOOT_GPS_EOF 562 | chmod +x "$BOOT_GPS_SCRIPT_PATH" 563 | echo "✅ Termux:Boot GPS script created." 564 | 565 | # 10b. Ensure .bashrc autostart entries 566 | BASHRC_PATH="$HOME/.bashrc" 567 | AUTOSTART_CMD="$BOOT_SCRIPT_PATH &" 568 | AUTOSTART_GPS_CMD="$BOOT_GPS_SCRIPT_PATH &" 569 | 570 | echo -e "\n${BLUE}10b. Ensuring .bashrc autostart entries...${NC}" 571 | # Create .bashrc if it does not exist 572 | if [ ! -f "$BASHRC_PATH" ]; then 573 | touch "$BASHRC_PATH" 574 | echo "Created $BASHRC_PATH" 575 | fi 576 | # Add orchestrator autostart if not already present 577 | if grep -Fxq "$AUTOSTART_CMD" "$BASHRC_PATH"; then 578 | echo "✅ Orchestrator autostart entry already present." 579 | else 580 | echo "$AUTOSTART_CMD" >> "$BASHRC_PATH" 581 | echo "✅ Added orchestrator autostart entry." 582 | fi 583 | # Add GPS autostart if not already present 584 | if grep -Fxq "$AUTOSTART_GPS_CMD" "$BASHRC_PATH"; then 585 | echo "✅ GPS autostart entry already present." 586 | else 587 | echo "$AUTOSTART_GPS_CMD" >> "$BASHRC_PATH" 588 | echo "✅ Added GPS autostart entry." 589 | fi 590 | 591 | # 11. Start the services 592 | echo -e "\n${BLUE}11. Starting the services...${NC}" 593 | nohup sh "$BOOT_SCRIPT_PATH" > /dev/null 2>&1 & 594 | ORCHESTRATOR_PID=$! 595 | nohup sh "$BOOT_GPS_SCRIPT_PATH" > /dev/null 2>&1 & 596 | GPS_PID=$! 597 | 598 | # Wait a moment and verify the services started 599 | sleep 2 600 | if kill -0 "$ORCHESTRATOR_PID" 2>/dev/null; then 601 | echo "✅ Orchestrator started successfully (PID: $ORCHESTRATOR_PID)" 602 | echo " → The orchestrator will start the keep-alive script via ADB" 603 | echo " → The keep-alive script will start the BYD-HASS binary" 604 | else 605 | echo "⚠️ Orchestrator may have failed to start. Check logs: tail -f $INTERNAL_LOG_FILE" 606 | fi 607 | if kill -0 "$GPS_PID" 2>/dev/null; then 608 | echo "✅ GPS script started successfully (PID: $GPS_PID)" 609 | else 610 | echo "⚠️ GPS script may have failed to start." 611 | fi 612 | 613 | echo -e "\n${GREEN}🎉 Installation complete! BYD-HASS is now managed by a self-healing service.${NC}" 614 | echo -e "${YELLOW}The service will restart automatically if the app is killed or the device reboots.${NC}" 615 | echo -e "${YELLOW}To see the main app logs, run: tail -f $LOG_FILE${NC}" 616 | echo -e "${YELLOW}To see the orchestrator logs, run: tail -f $INTERNAL_LOG_FILE${NC}" 617 | echo -e "${YELLOW}To see the external guardian logs, run: tail -f $ADB_LOG_FILE${NC}" 618 | echo -e "${YELLOW}To stop everything, run: ./install.sh cleanup${NC}" 619 | echo -e "${YELLOW}To reinstall/update, re-run this install script.${NC}" 620 | 621 | adb disconnect "$ADB_SERVER" >/dev/null 2>&1 || true 622 | exit 0 623 | --------------------------------------------------------------------------------