├── CODEOWNERS ├── .gitignore ├── cmd └── teslamate-discovery │ ├── version.go │ ├── config.go │ └── main.go ├── .vscode ├── settings.json ├── launch.json └── snippets.code-snippets ├── tm └── config.go ├── hack ├── goimports │ ├── tools.go │ ├── go.mod │ └── go.sum └── goreleaser │ ├── tools.go │ └── go.mod ├── ha ├── config.go ├── device.go ├── device_tracker.go ├── binary_sensor.go └── sensor.go ├── .devcontainer └── devcontainer.json ├── .github ├── workflows │ ├── pull-request.yml │ └── release.yml └── dependabot.yml ├── units ├── config.go ├── range_type.go ├── system_of_measurement.go ├── range_type_test.go └── system_of_measurement_test.go ├── mqtt ├── config.go ├── stub_token_test.go ├── random_string_test.go ├── stub_message_test.go ├── random_string.go ├── list_vehicles.go ├── stub_pub_sub_test.go ├── mqtt.go ├── list_vehicles_test.go ├── mqtt_test.go ├── publish_discovery_test.go └── publish_discovery.go ├── .goreleaser.yaml ├── go.mod ├── Makefile ├── README.md ├── go.sum └── LICENSE /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nebhale 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __debug_bin 2 | !.vscode/launch.json 3 | !.vscode/settings.json 4 | !.vscode/snippets.code-snippets 5 | dist/ 6 | -------------------------------------------------------------------------------- /cmd/teslamate-discovery/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | var version = "unknown" 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "crosshairs", 4 | "Frunk", 5 | "goimports", 6 | "goreleaser", 7 | "homeassistant", 8 | "iancoleman", 9 | "MQTT", 10 | "paho", 11 | "strcase", 12 | "teslamate", 13 | "tpms" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tm/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package tm 5 | 6 | const ( 7 | DefaultPrefix = "teslamate" 8 | ) 9 | 10 | var DefaultConfig = Config{ 11 | Prefix: DefaultPrefix, 12 | } 13 | 14 | type Config struct { 15 | Prefix string `mapstructure:"prefix"` 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "teslamate-discovery", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "./cmd/teslamate-discovery", 10 | "console": "integratedTerminal" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /hack/goimports/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build tools 5 | 6 | // This package imports things required by build scripts, to force `go mod` to 7 | // see them as dependencies 8 | package tools 9 | 10 | import ( 11 | _ "golang.org/x/tools/cmd/goimports" 12 | ) 13 | -------------------------------------------------------------------------------- /hack/goreleaser/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build tools 5 | 6 | // This package imports things required by build scripts, to force `go mod` to 7 | // see them as dependencies 8 | package tools 9 | 10 | import ( 11 | _ "github.com/goreleaser/goreleaser/v2" 12 | ) 13 | -------------------------------------------------------------------------------- /ha/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ha 5 | 6 | const ( 7 | DefaultDiscoveryPrefix = "homeassistant" 8 | ) 9 | 10 | var DefaultConfig = Config{ 11 | DiscoveryPrefix: DefaultDiscoveryPrefix, 12 | } 13 | 14 | type Config struct { 15 | DiscoveryPrefix string `mapstructure:"discovery_prefix"` 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Go File Header": { 3 | "scope": "go", 4 | "prefix": "gof", 5 | "body": [ 6 | "// Copyright $CURRENT_YEAR Ben Hale", 7 | "// SPDX-License-Identifier: Apache-2.0", 8 | "", 9 | "package ${TM_DIRECTORY/.*\\/([^\\/]+)$/$1/}", 10 | "", 11 | "$0" 12 | ], 13 | "description": "Standard Go file header" 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /hack/goimports/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nebhale/teslamate-discovery/hack/goimports 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require golang.org/x/tools v0.40.0 8 | 9 | require ( 10 | golang.org/x/mod v0.31.0 // indirect 11 | golang.org/x/sync v0.19.0 // indirect 12 | golang.org/x/sys v0.39.0 // indirect 13 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Teslamate Discovery", 3 | 4 | "image": "ubuntu:kinetic", 5 | "remoteUser": "codespaces", 6 | 7 | "features": { 8 | "ghcr.io/devcontainers/features/common-utils:2": { 9 | "installOhMyZsh": false 10 | }, 11 | "ghcr.io/devcontainers/features/git:1": {}, 12 | "ghcr.io/devcontainers/features/github-cli:1": {}, 13 | "ghcr.io/devcontainers/features/go:1": {} 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ha/device.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ha 5 | 6 | type Device struct { 7 | Identifiers []string `json:"identifiers,omitempty"` 8 | Manufacturer string `json:"manufacturer,omitempty"` 9 | Model string `json:"model,omitempty"` 10 | Name string `json:"name,omitempty"` 11 | SuggestedArea string `json:"suggested_area,omitempty"` 12 | SoftwareVersion string `json:"sw_version,omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Checks 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | 10 | jobs: 11 | tests: 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Checkout Source 16 | uses: actions/checkout@v6.0.1 17 | 18 | - name: Setup Go 19 | uses: actions/setup-go@v6.1.0 20 | with: 21 | go-version-file: go.mod 22 | check-latest: true 23 | 24 | - name: Run Tests 25 | run: make test 26 | -------------------------------------------------------------------------------- /units/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package units 5 | 6 | const ( 7 | DefaultDistance = Imperial 8 | DefaultPressure = Imperial 9 | DefaultRangeType = Rated 10 | ) 11 | 12 | var DefaultConfig = Config{ 13 | Distance: DefaultDistance, 14 | Pressure: DefaultPressure, 15 | RangeType: DefaultRangeType, 16 | } 17 | 18 | type Config struct { 19 | Distance SystemOfMeasurement `mapstructure:"distance"` 20 | Pressure SystemOfMeasurement `mapstructure:"pressure"` 21 | RangeType RangeType `mapstructure:"range_type"` 22 | } 23 | -------------------------------------------------------------------------------- /mqtt/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt 5 | 6 | const ( 7 | DefaultScheme = "ssl" 8 | DefaultHost = "127.0.0.1" 9 | DefaultPort = 8883 10 | ) 11 | 12 | var DefaultConfig = Config{ 13 | Scheme: DefaultScheme, 14 | Host: DefaultHost, 15 | Port: DefaultPort, 16 | } 17 | 18 | type Config struct { 19 | Scheme string `mapstructure:"scheme"` 20 | Host string `mapstructure:"host"` 21 | Port int `mapstructure:"port"` 22 | Username string `mapstructure:"username"` 23 | Password string `mapstructure:"password"` 24 | } 25 | -------------------------------------------------------------------------------- /mqtt/stub_token_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | ) 10 | 11 | var unexpectedOperationToken = &stubToken{ 12 | err: fmt.Errorf("unexpected operation"), 13 | } 14 | 15 | type stubToken struct { 16 | err error 17 | } 18 | 19 | func (s *stubToken) Wait() bool { 20 | return true 21 | } 22 | 23 | func (s *stubToken) WaitTimeout(t time.Duration) bool { 24 | return true 25 | } 26 | 27 | func (s *stubToken) Done() <-chan struct{} { 28 | ch := make(chan struct{}) 29 | close(ch) 30 | return ch 31 | } 32 | 33 | func (s *stubToken) Error() error { 34 | return s.err 35 | } 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | labels: 9 | - dependencies 10 | - github-actions 11 | 12 | - package-ecosystem: gomod 13 | directory: / 14 | schedule: 15 | interval: daily 16 | labels: 17 | - dependencies 18 | - gomod 19 | 20 | 21 | - package-ecosystem: gomod 22 | directory: "/hack/goimports" 23 | schedule: 24 | interval: daily 25 | labels: 26 | - dependencies 27 | - gomod 28 | - tools 29 | 30 | - package-ecosystem: gomod 31 | directory: "/hack/goreleaser" 32 | schedule: 33 | interval: daily 34 | labels: 35 | - dependencies 36 | - gomod 37 | - tools 38 | -------------------------------------------------------------------------------- /mqtt/random_string_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/nebhale/teslamate-discovery/mqtt" 10 | ) 11 | 12 | func TestRandomString(t *testing.T) { 13 | type args struct { 14 | n int 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want int 20 | }{ 21 | { 22 | name: "short", 23 | args: args{n: 10}, 24 | want: 10, 25 | }, 26 | { 27 | name: "long", 28 | args: args{n: 100}, 29 | want: 100, 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := RandomString(tt.args.n); len(got) != tt.want { 35 | t.Errorf("RandomString() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /mqtt/stub_message_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | type stubMessage struct { 7 | duplicate bool 8 | qos byte 9 | retained bool 10 | topic string 11 | messageId uint16 12 | payload []byte 13 | ack bool 14 | } 15 | 16 | func (s *stubMessage) Duplicate() bool { 17 | return s.duplicate 18 | } 19 | 20 | func (s *stubMessage) Qos() byte { 21 | return s.qos 22 | } 23 | 24 | func (s *stubMessage) Retained() bool { 25 | return s.retained 26 | } 27 | 28 | func (s *stubMessage) Topic() string { 29 | return s.topic 30 | } 31 | 32 | func (s *stubMessage) MessageID() uint16 { 33 | return s.messageId 34 | } 35 | 36 | func (s *stubMessage) Payload() []byte { 37 | return s.payload 38 | } 39 | 40 | func (s *stubMessage) Ack() { 41 | s.ack = true 42 | } 43 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/teslamate-discovery 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | universal_binaries: 17 | - replace: true 18 | 19 | notarize: 20 | macos: 21 | - enabled: true 22 | sign: 23 | certificate: "{{.Env.MACOS_SIGN_P12}}" 24 | password: "{{.Env.MACOS_SIGN_PASSWORD}}" 25 | notarize: 26 | issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" 27 | key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" 28 | key: "{{.Env.MACOS_NOTARY_KEY}}" 29 | wait: true 30 | timeout: 20m 31 | 32 | archives: 33 | - format_overrides: 34 | - goos: windows 35 | formats: zip 36 | kos: 37 | - base_import_paths: true 38 | tags: 39 | - '{{.Version}}' 40 | - latest 41 | platforms: 42 | - linux/amd64 43 | - linux/arm64 44 | sbom: none 45 | checksum: {} 46 | 47 | changelog: 48 | use: github-native 49 | 50 | snapshot: {} 51 | 52 | report_sizes: true 53 | -------------------------------------------------------------------------------- /hack/goimports/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 4 | golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 5 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 6 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 7 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 8 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 9 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= 10 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= 11 | golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 12 | golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 13 | -------------------------------------------------------------------------------- /mqtt/random_string.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt 5 | 6 | import ( 7 | "math/rand" 8 | "time" 9 | "unsafe" 10 | ) 11 | 12 | const ( 13 | letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 14 | letterIdxBits = 6 // 6 bits to represent a letter index 15 | letterIdxMask = 1<= 0; { 25 | if remain == 0 { 26 | cache, remain = src.Int63(), letterIdxMax 27 | } 28 | if idx := int(cache & letterIdxMask); idx < len(letterBytes) { 29 | b[i] = letterBytes[idx] 30 | i-- 31 | } 32 | cache >>= letterIdxBits 33 | remain-- 34 | } 35 | 36 | return *(*string)(unsafe.Pointer(&b)) 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nebhale/teslamate-discovery 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/eclipse/paho.mqtt.golang v1.5.1 9 | github.com/iancoleman/strcase v0.3.0 10 | github.com/spf13/cobra v1.10.2 11 | github.com/spf13/viper v1.21.0 12 | ) 13 | 14 | require ( 15 | github.com/fsnotify/fsnotify v1.9.0 // indirect 16 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 17 | github.com/gorilla/websocket v1.5.3 // indirect 18 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 19 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 20 | github.com/sagikazarmark/locafero v0.11.0 // indirect 21 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 22 | github.com/spf13/afero v1.15.0 // indirect 23 | github.com/spf13/cast v1.10.0 // indirect 24 | github.com/spf13/pflag v1.0.10 // indirect 25 | github.com/subosito/gotenv v1.6.0 // indirect 26 | go.yaml.in/yaml/v3 v3.0.4 // indirect 27 | golang.org/x/net v0.44.0 // indirect 28 | golang.org/x/sync v0.17.0 // indirect 29 | golang.org/x/sys v0.36.0 // indirect 30 | golang.org/x/text v0.29.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /units/range_type.go: -------------------------------------------------------------------------------- 1 | package units 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | type RangeType string 10 | 11 | const ( 12 | Estimated RangeType = "estimated" 13 | Ideal RangeType = "ideal" 14 | Rated RangeType = "rated" 15 | ) 16 | 17 | func (r RangeType) Prefix() string { 18 | if r == Estimated { 19 | return "est" 20 | } 21 | 22 | if r == Ideal { 23 | return "ideal" 24 | } 25 | 26 | return "rated" 27 | } 28 | 29 | func (r *RangeType) Set(v string) error { 30 | switch v { 31 | case "estimated": 32 | *r = Estimated 33 | case "ideal": 34 | *r = Ideal 35 | case "rated": 36 | *r = Rated 37 | default: 38 | return fmt.Errorf("must be one of estimated, ideal, rated") 39 | } 40 | return nil 41 | } 42 | 43 | func (r RangeType) String() string { 44 | return string(r) 45 | } 46 | 47 | func (r RangeType) Type() string { 48 | return "string" 49 | } 50 | 51 | func RangeTypeCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 52 | return []string{string(Estimated), string(Ideal), string(Rated)}, cobra.ShellCompDirectiveDefault 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - name: Checkout Source 18 | uses: actions/checkout@v6.0.1 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Fetch All Tags 23 | run: git fetch --force --tags 24 | 25 | - name: Setup Go 26 | uses: actions/setup-go@v6.1.0 27 | with: 28 | go-version-file: go.mod 29 | check-latest: true 30 | 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v6.4.0 33 | with: 34 | version: ~> v2 35 | args: release --clean 36 | env: 37 | KO_DOCKER_REPO: ghcr.io/nebhale 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 40 | MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} 41 | MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} 42 | MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} 43 | MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} 44 | -------------------------------------------------------------------------------- /ha/device_tracker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ha 5 | 6 | type DeviceTracker struct { 7 | Device Device `json:"device,omitempty"` 8 | Icon string `json:"icon,omitempty"` 9 | JSONAttributesTemplate string `json:"json_attributes_template,omitempty"` 10 | JSONAttributesTopic string `json:"json_attributes_topic,omitempty"` 11 | Name string `json:"name,omitempty"` 12 | PayloadHome string `json:"payload_home,omitempty"` 13 | PayloadNotHome string `json:"payload_not_home,omitempty"` 14 | SourceType DeviceTrackerSourceType `json:"source_type,omitempty"` 15 | StateTopic string `json:"state_topic"` 16 | UniqueId string `json:"unique_id,omitempty"` 17 | ValueTemplate string `json:"value_template,omitempty"` 18 | } 19 | 20 | type DeviceTrackerSourceType string 21 | 22 | const ( 23 | Bluetooth DeviceTrackerSourceType = "bluetooth" 24 | BluetoothLE DeviceTrackerSourceType = "bluetooth_le" 25 | GPS DeviceTrackerSourceType = "gps" 26 | Router DeviceTrackerSourceType = "router" 27 | ) 28 | -------------------------------------------------------------------------------- /cmd/teslamate-discovery/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/nebhale/teslamate-discovery/ha" 14 | "github.com/nebhale/teslamate-discovery/mqtt" 15 | "github.com/nebhale/teslamate-discovery/tm" 16 | "github.com/nebhale/teslamate-discovery/units" 17 | ) 18 | 19 | var DefaultConfig = Config{ 20 | HomeAssistant: ha.DefaultConfig, 21 | MQTT: mqtt.DefaultConfig, 22 | Teslamate: tm.DefaultConfig, 23 | Units: units.DefaultConfig, 24 | } 25 | 26 | type Config struct { 27 | HomeAssistant ha.Config `mapstructure:"ha"` 28 | MQTT mqtt.Config `mapstructure:"mqtt"` 29 | Teslamate tm.Config `mapstructure:"tm"` 30 | Units units.Config `mapstructure:"units"` 31 | } 32 | 33 | func UnmarshalConfig(config *Config, v *viper.Viper) CobraEFn { 34 | return func(cmd *cobra.Command, args []string) error { 35 | v.SetConfigName("config") 36 | v.SetConfigType("yaml") 37 | 38 | if dir, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok { 39 | v.AddConfigPath(fmt.Sprintf("%s/teslamate-discovery", dir)) 40 | } 41 | if dir, err := os.UserConfigDir(); err == nil { 42 | v.AddConfigPath(fmt.Sprintf("%s/teslamate-discovery", dir)) 43 | } 44 | 45 | if err := v.ReadInConfig(); err != nil { 46 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 47 | return err 48 | } 49 | } 50 | 51 | if err := v.Unmarshal(&config); err != nil { 52 | return err 53 | } 54 | 55 | if config.MQTT.Username == "" { 56 | return fmt.Errorf("mqtt username must be specified") 57 | } 58 | 59 | if config.MQTT.Password == "" { 60 | return fmt.Errorf("mqtt password must be specified") 61 | } 62 | 63 | return nil 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mqtt/list_vehicles.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "regexp" 10 | "time" 11 | 12 | "github.com/nebhale/teslamate-discovery/ha" 13 | "github.com/nebhale/teslamate-discovery/tm" 14 | ) 15 | 16 | func (m *MQTT) ListVehicles(ctx context.Context, tmCfg tm.Config) (map[string]ha.Device, error) { 17 | fmt.Println("Listing Vehicles") 18 | 19 | topic := fmt.Sprintf("%s/#", tmCfg.Prefix) 20 | 21 | in, err := m.Subscribe(ctx, topic) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer func() { _ = m.Unsubscribe(ctx, topic) }() 26 | 27 | vehicles := make(map[string]ha.Device) 28 | r := regexp.MustCompile(fmt.Sprintf(`%s/cars/([\d]+)/([\w]+)`, tmCfg.Prefix)) 29 | 30 | for { 31 | select { 32 | case <-ctx.Done(): 33 | return nil, nil 34 | 35 | case msg := <-in: 36 | s := r.FindStringSubmatch(msg.Topic()) 37 | if s == nil { 38 | continue 39 | } 40 | 41 | switch s[2] { 42 | case "display_name": 43 | dev := vehicles[s[1]] 44 | dev.Name = string(msg.Payload()) 45 | vehicles[s[1]] = dev 46 | case "model": 47 | dev := vehicles[s[1]] 48 | dev.Model = fmt.Sprintf("Model %s%s", string(msg.Payload()), dev.Model) 49 | vehicles[s[1]] = dev 50 | case "trim_badging": 51 | dev := vehicles[s[1]] 52 | dev.Model = fmt.Sprintf("%s %s", dev.Model, string(msg.Payload())) 53 | vehicles[s[1]] = dev 54 | case "version": 55 | dev := vehicles[s[1]] 56 | dev.SoftwareVersion = string(msg.Payload()) 57 | vehicles[s[1]] = dev 58 | } 59 | 60 | case <-time.After(250 * time.Millisecond): 61 | for id, dev := range vehicles { 62 | if dev.Name == "" { 63 | dev.Name = "Tesla" 64 | } 65 | dev.Identifiers = []string{fmt.Sprintf("%s/cars/%s", tmCfg.Prefix, id)} 66 | dev.Manufacturer = "Tesla" 67 | dev.SuggestedArea = "Garage" 68 | vehicles[id] = dev 69 | } 70 | 71 | return vehicles, nil 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /mqtt/stub_pub_sub_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import paho "github.com/eclipse/paho.mqtt.golang" 7 | 8 | type stubPubSub struct { 9 | publishArgs []publishArgs 10 | publishTokens []paho.Token 11 | subscribeArgs []subscribeArgs 12 | subscribeHandler func(callback paho.MessageHandler) 13 | subscribeTokens []paho.Token 14 | unsubscribeArgs []unsubscribeArgs 15 | unsubscribeTokens []paho.Token 16 | } 17 | 18 | type publishArgs struct { 19 | topic string 20 | qos byte 21 | retained bool 22 | payload interface{} 23 | } 24 | 25 | func (s *stubPubSub) Publish(topic string, qos byte, retained bool, payload interface{}) paho.Token { 26 | s.publishArgs = append(s.publishArgs, publishArgs{ 27 | topic: topic, 28 | qos: qos, 29 | retained: retained, 30 | payload: payload, 31 | }) 32 | 33 | count := len(s.publishArgs) - 1 34 | if count < len(s.publishTokens) { 35 | return s.publishTokens[count] 36 | } 37 | if len(s.publishTokens) == 1 { 38 | return s.publishTokens[0] 39 | } 40 | return unexpectedOperationToken 41 | } 42 | 43 | type subscribeArgs struct { 44 | topic string 45 | qos byte 46 | callback paho.MessageHandler 47 | } 48 | 49 | func (s *stubPubSub) Subscribe(topic string, qos byte, callback paho.MessageHandler) paho.Token { 50 | s.subscribeArgs = append(s.subscribeArgs, subscribeArgs{ 51 | topic: topic, 52 | qos: qos, 53 | callback: callback, 54 | }) 55 | 56 | if s.subscribeHandler != nil { 57 | go s.subscribeHandler(callback) 58 | } 59 | 60 | count := len(s.subscribeArgs) 61 | if count < len(s.subscribeTokens) { 62 | return s.subscribeTokens[count] 63 | } 64 | if len(s.subscribeTokens) == 1 { 65 | return s.subscribeTokens[0] 66 | } 67 | return unexpectedOperationToken 68 | } 69 | 70 | type unsubscribeArgs struct { 71 | topics []string 72 | } 73 | 74 | func (s *stubPubSub) Unsubscribe(topics ...string) paho.Token { 75 | s.unsubscribeArgs = append(s.unsubscribeArgs, unsubscribeArgs{ 76 | topics: topics, 77 | }) 78 | 79 | count := len(s.unsubscribeArgs) - 1 80 | if count < len(s.unsubscribeTokens) { 81 | return s.unsubscribeTokens[count] 82 | } 83 | if len(s.unsubscribeTokens) == 1 { 84 | return s.unsubscribeTokens[0] 85 | } 86 | return unexpectedOperationToken 87 | } 88 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 2 | ifeq (,$(shell go env GOBIN)) 3 | GOBIN=$(shell go env GOPATH)/bin 4 | else 5 | GOBIN=$(shell go env GOBIN) 6 | endif 7 | 8 | VERSION ?= $(shell git describe --always --dirty) 9 | 10 | # Tools 11 | GOIMPORTS ?= go run -modfile hack/goimports/go.mod golang.org/x/tools/cmd/goimports 12 | GORELEASER ?= go run -modfile hack/goreleaser/go.mod github.com/goreleaser/goreleaser/v2 13 | 14 | # Setting SHELL to bash allows bash commands to be executed by recipes. 15 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 16 | SHELL = /usr/bin/env bash -o pipefail 17 | .SHELLFLAGS = -ec 18 | 19 | .PHONY: all 20 | all: test 21 | 22 | ##@ General 23 | 24 | # The help target prints out all targets with their descriptions organized 25 | # beneath their categories. The categories are represented by '##@' and the 26 | # target descriptions by '##'. The awk commands is responsible for reading the 27 | # entire set of makefiles included in this invocation, looking for lines of the 28 | # file as xyz: ## something, and then pretty-format the target and help. Then, 29 | # if there's a line with ##@ something, that gets pretty-printed as a category. 30 | # More info on the usage of ANSI control characters for terminal formatting: 31 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 32 | # More info on the awk command: 33 | # http://linuxcommand.org/lc3_adv_awk.php 34 | 35 | .PHONY: help 36 | help: ## Display this help. 37 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 38 | 39 | ##@ Development 40 | 41 | .PHONY: fmt 42 | fmt: ## Run go fmt against code. 43 | $(GOIMPORTS) --local github.com/nebhale/teslamate-discovery -w . 44 | 45 | tidy: ## Run go mod tidy against code. 46 | go mod tidy 47 | 48 | .PHONY: vet 49 | vet: ## Run go vet against code. 50 | go vet ./... 51 | 52 | .PHONY: test 53 | test: fmt vet tidy ## Run tests. 54 | go test ./... 55 | 56 | ##@ Running 57 | 58 | .PHONY: run 59 | run: ## Run the application. 60 | $(GORELEASER) build --single-target --snapshot --clean 61 | dist/teslamate-discovery_$(shell go env GOOS)_$(shell go env GOARCH)/teslamate-discovery 62 | -------------------------------------------------------------------------------- /units/system_of_measurement.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package units 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | const RoundingValueTemplate = "{{ value | round(1) }}" 13 | 14 | type SystemOfMeasurement string 15 | 16 | const ( 17 | Imperial SystemOfMeasurement = "imperial" 18 | Metric SystemOfMeasurement = "metric" 19 | ) 20 | 21 | func (s SystemOfMeasurement) DistanceLongUnits() string { 22 | if s == Metric { 23 | return "km" 24 | } 25 | 26 | return "mi" 27 | } 28 | 29 | func (s SystemOfMeasurement) DistanceLongValueTemplate() string { 30 | if s == Metric { 31 | return RoundingValueTemplate 32 | } 33 | 34 | return "{{ (value | float(0) / 1.609344) | round(1) }}" 35 | } 36 | 37 | func (s SystemOfMeasurement) DistanceShortUnits() string { 38 | if s == Metric { 39 | return "m" 40 | } 41 | 42 | return "ft" 43 | } 44 | 45 | func (s SystemOfMeasurement) DistanceShortValueTemplate() string { 46 | if s == Metric { 47 | return RoundingValueTemplate 48 | } 49 | 50 | return "{{ (value | float(0) * 3.280839) | round(1) }}" 51 | } 52 | 53 | func (s SystemOfMeasurement) PressureUnits() string { 54 | if s == Metric { 55 | return "bar" 56 | } 57 | 58 | return "psi" 59 | } 60 | 61 | func (s SystemOfMeasurement) PressureValueTemplate() string { 62 | if s == Metric { 63 | return RoundingValueTemplate 64 | } 65 | 66 | return "{{ (value | float(0) * 14.503773) | round(1) }}" 67 | } 68 | 69 | func (s *SystemOfMeasurement) Set(v string) error { 70 | switch v { 71 | case "imperial", "metric": 72 | *s = SystemOfMeasurement(v) 73 | default: 74 | return fmt.Errorf("must be one of imperial, metric") 75 | } 76 | return nil 77 | } 78 | 79 | func (s SystemOfMeasurement) String() string { 80 | return string(s) 81 | } 82 | 83 | func (s SystemOfMeasurement) SpeedUnits() string { 84 | if s == Metric { 85 | return "kph" 86 | } 87 | 88 | return "mph" 89 | } 90 | 91 | func (s SystemOfMeasurement) SpeedValueTemplate() string { 92 | return s.DistanceLongValueTemplate() 93 | } 94 | 95 | func (s SystemOfMeasurement) Type() string { 96 | return "string" 97 | } 98 | 99 | func SystemOfMeasurementCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 100 | return []string{string(Imperial), string(Metric)}, cobra.ShellCompDirectiveDefault 101 | } 102 | -------------------------------------------------------------------------------- /ha/binary_sensor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ha 5 | 6 | type BinarySensor struct { 7 | Device Device `json:"device,omitempty"` 8 | DeviceClass BinarySensorDeviceClass `json:"device_class,omitempty"` 9 | Icon string `json:"icon,omitempty"` 10 | JSONAttributesTemplate string `json:"json_attributes_template,omitempty"` 11 | JSONAttributesTopic string `json:"json_attributes_topic,omitempty"` 12 | Name string `json:"name,omitempty"` 13 | PayloadOff string `json:"payload_off,omitempty"` 14 | PayloadOn string `json:"payload_on,omitempty"` 15 | StateTopic string `json:"state_topic"` 16 | UniqueId string `json:"unique_id,omitempty"` 17 | ValueTemplate string `json:"value_template,omitempty"` 18 | } 19 | 20 | type BinarySensorDeviceClass string 21 | 22 | const ( 23 | Battery BinarySensorDeviceClass = "battery" 24 | BatteryCharging BinarySensorDeviceClass = "battery_charging" 25 | CarbonMonoxide BinarySensorDeviceClass = "carbon_monoxide" 26 | Cold BinarySensorDeviceClass = "cold" 27 | Connectivity BinarySensorDeviceClass = "connectivity" 28 | Door BinarySensorDeviceClass = "door" 29 | Garage BinarySensorDeviceClass = "garage_door" 30 | Gas BinarySensorDeviceClass = "gas" 31 | Heat BinarySensorDeviceClass = "heat" 32 | Light BinarySensorDeviceClass = "light" 33 | Lock BinarySensorDeviceClass = "lock" 34 | Moisture BinarySensorDeviceClass = "moisture" 35 | Motion BinarySensorDeviceClass = "motion" 36 | Moving BinarySensorDeviceClass = "moving" 37 | Occupancy BinarySensorDeviceClass = "occupancy" 38 | Opening BinarySensorDeviceClass = "opening" 39 | Plug BinarySensorDeviceClass = "plug" 40 | PowerDetected BinarySensorDeviceClass = "power" 41 | Presence BinarySensorDeviceClass = "presence" 42 | Problem BinarySensorDeviceClass = "problem" 43 | Running BinarySensorDeviceClass = "running" 44 | Safety BinarySensorDeviceClass = "safety" 45 | Smoke BinarySensorDeviceClass = "smoke" 46 | Sound BinarySensorDeviceClass = "sound" 47 | Tamper BinarySensorDeviceClass = "tamper" 48 | Update BinarySensorDeviceClass = "update" 49 | Vibration BinarySensorDeviceClass = "vibration" 50 | Window BinarySensorDeviceClass = "window" 51 | ) 52 | -------------------------------------------------------------------------------- /mqtt/mqtt.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | 11 | paho "github.com/eclipse/paho.mqtt.golang" 12 | 13 | "github.com/nebhale/teslamate-discovery/ha" 14 | ) 15 | 16 | type PubSub interface { 17 | Publish(topic string, qos byte, retained bool, payload interface{}) paho.Token 18 | Subscribe(topic string, qos byte, callback paho.MessageHandler) paho.Token 19 | Unsubscribe(topics ...string) paho.Token 20 | } 21 | 22 | type MQTT struct { 23 | Client PubSub 24 | } 25 | 26 | func NewMQTT(ctx context.Context, config Config) (*MQTT, error) { 27 | uri := BrokerURI(config) 28 | fmt.Printf("Connecting to %s\n", uri) 29 | 30 | c := paho.NewClient(paho.NewClientOptions(). 31 | AddBroker(uri). 32 | SetClientID(fmt.Sprintf("teslamate-discovery-%s", RandomString(12))). 33 | SetOrderMatters(false). 34 | SetUsername(config.Username). 35 | SetPassword(config.Password)) 36 | 37 | t := c.Connect() 38 | select { 39 | case <-ctx.Done(): 40 | return nil, nil 41 | case <-t.Done(): 42 | if err := t.Error(); err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | go func() { 48 | <-ctx.Done() 49 | c.Disconnect(500) 50 | }() 51 | 52 | return &MQTT{Client: c}, nil 53 | } 54 | 55 | func (m *MQTT) Publish(ctx context.Context, discoveryPrefix string, v ...interface{}) error { 56 | for _, v := range v { 57 | var topic string 58 | 59 | switch v := v.(type) { 60 | case ha.BinarySensor: 61 | topic = fmt.Sprintf("%s/binary_sensor/%s/config", discoveryPrefix, v.UniqueId) 62 | case ha.DeviceTracker: 63 | topic = fmt.Sprintf("%s/device_tracker/%s/config", discoveryPrefix, v.UniqueId) 64 | case ha.Sensor: 65 | topic = fmt.Sprintf("%s/sensor/%s/config", discoveryPrefix, v.UniqueId) 66 | default: 67 | return fmt.Errorf("unexpected message type: %T", v) 68 | } 69 | 70 | fmt.Printf(" %s\n", topic) 71 | 72 | payload, err := json.Marshal(v) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | t := m.Client.Publish(topic, 0, true, payload) 78 | select { 79 | case <-ctx.Done(): 80 | return nil 81 | case <-t.Done(): 82 | if err := t.Error(); err != nil { 83 | return err 84 | } 85 | } 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (m *MQTT) Subscribe(ctx context.Context, topic string) (<-chan paho.Message, error) { 92 | ch := make(chan paho.Message) 93 | 94 | t := m.Client.Subscribe(topic, 0, func(c paho.Client, m paho.Message) { 95 | ch <- m 96 | }) 97 | 98 | select { 99 | case <-ctx.Done(): 100 | return nil, nil 101 | case <-t.Done(): 102 | if err := t.Error(); err != nil { 103 | return nil, err 104 | } 105 | } 106 | 107 | return ch, nil 108 | } 109 | 110 | func (m *MQTT) Unsubscribe(ctx context.Context, topic string) error { 111 | t := m.Client.Unsubscribe(topic) 112 | 113 | select { 114 | case <-ctx.Done(): 115 | return nil 116 | case <-t.Done(): 117 | if err := t.Error(); err != nil { 118 | return err 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func BrokerURI(config Config) string { 126 | return fmt.Sprintf("%s://%s:%d", config.Scheme, config.Host, config.Port) 127 | } 128 | -------------------------------------------------------------------------------- /ha/sensor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ha 5 | 6 | type Sensor struct { 7 | Device Device `json:"device,omitempty"` 8 | DeviceClass SensorDeviceClass `json:"device_class,omitempty"` 9 | Icon string `json:"icon,omitempty"` 10 | JSONAttributesTemplate string `json:"json_attributes_template,omitempty"` 11 | JSONAttributesTopic string `json:"json_attributes_topic,omitempty"` 12 | Name string `json:"name,omitempty"` 13 | StateClass StateClass `json:"state_class,omitempty"` 14 | StateTopic string `json:"state_topic"` 15 | UniqueId string `json:"unique_id,omitempty"` 16 | UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` 17 | ValueTemplate string `json:"value_template,omitempty"` 18 | } 19 | 20 | type SensorDeviceClass string 21 | 22 | const ( 23 | ApparentPower SensorDeviceClass = "apparent_power" 24 | AirQualityIndex SensorDeviceClass = "aqi" 25 | BatteryCharge SensorDeviceClass = "battery" 26 | CarbonDioxideConcentration SensorDeviceClass = "carbon_dioxide" 27 | CarbonMonoxideConcentration SensorDeviceClass = "carbon_monoxide" 28 | Current SensorDeviceClass = "current" 29 | Date SensorDeviceClass = "date" 30 | Duration SensorDeviceClass = "duration" 31 | Energy SensorDeviceClass = "energy" 32 | Frequency SensorDeviceClass = "frequency" 33 | GasVolume SensorDeviceClass = "gas" 34 | Humidity SensorDeviceClass = "humidity" 35 | Illuminance SensorDeviceClass = "illuminance" 36 | Monetary SensorDeviceClass = "monetary" 37 | NitrogenDioxideConcentration SensorDeviceClass = "nitrogen_dioxide" 38 | NitrogenMonoxideConcentration SensorDeviceClass = "nitrogen_monoxide" 39 | NitrousOxideConcentration SensorDeviceClass = "nitrous_oxide" 40 | OzoneConcentration SensorDeviceClass = "ozone" 41 | ParticulateMatter1Concentration SensorDeviceClass = "pm1" 42 | ParticulateMatter10Concentration SensorDeviceClass = "pm10" 43 | ParticulateMatter25Concentration SensorDeviceClass = "pm25" 44 | PowerFactor SensorDeviceClass = "power_factor" 45 | Power SensorDeviceClass = "power" 46 | Pressure SensorDeviceClass = "pressure" 47 | ReactivePower SensorDeviceClass = "reactive_power" 48 | SignalStrength SensorDeviceClass = "signal_strength" 49 | SulphurDioxideConcentration SensorDeviceClass = "sulphur_dioxide" 50 | Temperature SensorDeviceClass = "temperature" 51 | Timestamp SensorDeviceClass = "timestamp" 52 | VolatileOrganicCompoundsConcentration SensorDeviceClass = "volatile_organic_compounds" 53 | Voltage SensorDeviceClass = "voltage" 54 | ) 55 | 56 | type StateClass string 57 | 58 | const ( 59 | Measurement StateClass = "measurement" 60 | Total StateClass = "total" 61 | TotalIncreasing StateClass = "total_increasing" 62 | ) 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TeslaMate Discovery 2 | If you're a fan of the very excellent [TeslaMate][tm] and use it with [Home Assistant][ha], then this project is for you! 3 | 4 | The TeslaMate + Home Assistant [integration documentation][tmha] shows a very long, manual configuration of each of the entities that TeslaMate sends messages for. While this works quite well (I used it for years) it has some shortcomings, the biggest of which is that they aren't all collected under a single [Home Assistant Device][had]. Unfortunately, devices cannot be created using purely manual end-user configuration, they can only be added with [MQTT Discovery][hamd] or a dedicated integration. 5 | 6 | This application replaces most, if not all, of the documented TeslaMate + Home Assistant integration. In a single command, it synthesizes and publishes the MQTT Discovery configuration for commonly used entities converted to desired units and a `device_tracker` with precise coordinates that can also determine if the car is home (any geofence with "Home" in it). 7 | 8 | [ha]: https://www.home-assistant.io 9 | [had]: https://developers.home-assistant.io/docs/device_registry_index/ 10 | [hamd]: https://www.home-assistant.io/docs/mqtt/discovery/ 11 | [tm]: https://github.com/adriankumpf/teslamate 12 | [tmha]: https://docs.teslamate.org/docs/integrations/home_assistant 13 | 14 | # Pre-requisites 15 | The application assumes that you have a healthy TeslaMate installation sending messages to a [Mosquitto MQTT broker][mos] and that Home Assistant can see those messages. The only other requirement is to have an account that can read messages from the `/teslamate/#` topic tree and send messages to the `/homeassistant/#` topic tree. This is often done by configuring `/share/mosquitto/accesscontrollist` as in the following example: 16 | 17 | ```plain 18 | user homeassistant 19 | topic readwrite homeassistant/# 20 | topic read teslamate/# 21 | 22 | user teslamate 23 | topic write teslamate/# 24 | 25 | user teslamate-discovery 26 | topic write homeassistant/# 27 | topic read teslamate/# 28 | ``` 29 | 30 | [mos]: https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md 31 | 32 | # Installation 33 | To install the application, navigate to the [Releases][r] page for the project and download the appropriate Asset for the platform you'll be running on (e.g. `teslamate-discovery_2.0.2_Darwin_all.tar.gz` for macOS). Unzip the package and within it you'll find the `teslamate-discovery` binary that you'll run. 34 | 35 | [r]: https://github.com/nebhale/teslamate-discovery/releases 36 | 37 | # Usage 38 | Common usage might look like: 39 | 40 | ```plain 41 | $ teslamate-discovery \ 42 | --mqtt-host \ 43 | --mqtt-username \ 44 | --mqtt-password 45 | ``` 46 | 47 | ## Usage Options 48 | ```plain 49 | Usage: 50 | teslamate-discovery [flags] 51 | 52 | Flags: 53 | --ha-discovery-prefix string home assistant discovery message prefix (default "homeassistant") 54 | --help help for teslamate-discovery 55 | -h, --mqtt-host string mqtt broker host (default "127.0.0.1") 56 | -P, --mqtt-password string mqtt broker password 57 | -p, --mqtt-port int mqtt broker port (default 8883) 58 | -s, --mqtt-scheme string mqtt broker scheme (default "ssl") 59 | -u, --mqtt-username string mqtt broker username 60 | --range-type string range type ["estimated", "ideal", "rated"] (default "rated") 61 | --tm-prefix string teslamate message prefix (default "teslamate") 62 | --units-distance string distance units ["imperial", "metric"] (default "imperial") 63 | --units-pressure string pressure units ["imperial", "metric"] (default "imperial") 64 | -v, --version version for teslamate-discovery 65 | ``` 66 | 67 | ## License 68 | Apache License v2.0: see [LICENSE](./LICENSE) for details. 69 | -------------------------------------------------------------------------------- /units/range_type_test.go: -------------------------------------------------------------------------------- 1 | package units 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func TestRangeType_Prefix(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | r RangeType 14 | want string 15 | }{ 16 | { 17 | name: "estimated", 18 | r: Estimated, 19 | want: "est", 20 | }, 21 | { 22 | name: "ideal", 23 | r: Ideal, 24 | want: "ideal", 25 | }, 26 | { 27 | name: "rated", 28 | r: Rated, 29 | want: "rated", 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := tt.r.Prefix(); got != tt.want { 35 | t.Errorf("RangeType.Prefix() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestRangeType_Set(t *testing.T) { 42 | type args struct { 43 | v string 44 | } 45 | tests := []struct { 46 | name string 47 | args args 48 | want RangeType 49 | wantErr bool 50 | }{ 51 | { 52 | name: "estimated", 53 | args: args{v: "estimated"}, 54 | want: Estimated, 55 | }, 56 | { 57 | name: "ideal", 58 | args: args{v: "ideal"}, 59 | want: Ideal, 60 | }, 61 | { 62 | name: "rated", 63 | args: args{v: "rated"}, 64 | want: Rated, 65 | }, 66 | { 67 | name: "unknown", 68 | args: args{v: "unknown"}, 69 | wantErr: true, 70 | }, 71 | } 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | var r RangeType 75 | err := r.Set(tt.args.v) 76 | if (err != nil) != tt.wantErr { 77 | t.Errorf("RangeType.Set() error = %v, wantErr %v", err, tt.wantErr) 78 | return 79 | } 80 | if tt.wantErr { 81 | return 82 | } 83 | 84 | if r != tt.want { 85 | t.Errorf("RangeType.Set() = %v, want %v", r, tt.want) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestRangeType_String(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | r RangeType 95 | want string 96 | }{ 97 | { 98 | name: "estimated", 99 | r: Estimated, 100 | want: "estimated", 101 | }, 102 | { 103 | name: "ideal", 104 | r: Ideal, 105 | want: "ideal", 106 | }, 107 | { 108 | name: "rated", 109 | r: Rated, 110 | want: "rated", 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | if got := tt.r.String(); got != tt.want { 116 | t.Errorf("RangeType.String() = %v, want %v", got, tt.want) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestRangeType_Type(t *testing.T) { 123 | tests := []struct { 124 | name string 125 | r RangeType 126 | want string 127 | }{ 128 | { 129 | name: "estimated", 130 | r: Estimated, 131 | want: "string", 132 | }, 133 | { 134 | name: "ideal", 135 | r: Ideal, 136 | want: "string", 137 | }, 138 | { 139 | name: "rated", 140 | r: Rated, 141 | want: "string", 142 | }, 143 | } 144 | for _, tt := range tests { 145 | t.Run(tt.name, func(t *testing.T) { 146 | if got := tt.r.Type(); got != tt.want { 147 | t.Errorf("RangeType.Type() = %v, want %v", got, tt.want) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | func TestRangeTypeCompletion(t *testing.T) { 154 | type args struct { 155 | cmd *cobra.Command 156 | args []string 157 | toComplete string 158 | } 159 | tests := []struct { 160 | name string 161 | args args 162 | want []string 163 | want1 cobra.ShellCompDirective 164 | }{ 165 | { 166 | name: "always", 167 | args: args{}, 168 | want: []string{"estimated", "ideal", "rated"}, 169 | want1: cobra.ShellCompDirectiveDefault, 170 | }, 171 | } 172 | for _, tt := range tests { 173 | t.Run(tt.name, func(t *testing.T) { 174 | got, got1 := RangeTypeCompletion(tt.args.cmd, tt.args.args, tt.args.toComplete) 175 | if !reflect.DeepEqual(got, tt.want) { 176 | t.Errorf("RangeTypeCompletion() got = %v, want %v", got, tt.want) 177 | } 178 | if !reflect.DeepEqual(got1, tt.want1) { 179 | t.Errorf("RangeTypeCompletion() got1 = %v, want %v", got1, tt.want1) 180 | } 181 | }) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /mqtt/list_vehicles_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "testing" 11 | 12 | paho "github.com/eclipse/paho.mqtt.golang" 13 | 14 | "github.com/nebhale/teslamate-discovery/ha" 15 | . "github.com/nebhale/teslamate-discovery/mqtt" 16 | "github.com/nebhale/teslamate-discovery/tm" 17 | ) 18 | 19 | func TestMQTT_ListVehicles(t *testing.T) { 20 | type fields struct { 21 | Client stubPubSub 22 | } 23 | type args struct { 24 | ctx context.Context 25 | tmCfg tm.Config 26 | } 27 | tests := []struct { 28 | name string 29 | fields fields 30 | args args 31 | want map[string]ha.Device 32 | wantErr bool 33 | }{ 34 | { 35 | name: "default", 36 | fields: fields{ 37 | Client: stubPubSub{ 38 | subscribeHandler: func(cb paho.MessageHandler) { 39 | cb(nil, &stubMessage{ 40 | topic: "test-prefix/cars/1/display_name", 41 | payload: []byte("test-display-name-1"), 42 | }) 43 | cb(nil, &stubMessage{ 44 | topic: "test-prefix/cars/1/model", 45 | payload: []byte("test-model-1"), 46 | }) 47 | cb(nil, &stubMessage{ 48 | topic: "test-prefix/cars/1/trim_badging", 49 | payload: []byte("test-trim-badging-1"), 50 | }) 51 | cb(nil, &stubMessage{ 52 | topic: "test-prefix/cars/1/version", 53 | payload: []byte("test-version-1"), 54 | }) 55 | cb(nil, &stubMessage{ 56 | topic: "test-prefix/cars/2/display_name", 57 | payload: []byte("test-display-name-2"), 58 | }) 59 | cb(nil, &stubMessage{ 60 | topic: "test-prefix/cars/2/trim_badging", 61 | payload: []byte("test-trim-badging-2"), 62 | }) 63 | cb(nil, &stubMessage{ 64 | topic: "test-prefix/cars/2/model", 65 | payload: []byte("test-model-2"), 66 | }) 67 | cb(nil, &stubMessage{ 68 | topic: "test-prefix/cars/2/version", 69 | payload: []byte("test-version-2"), 70 | }) 71 | 72 | cb(nil, &stubMessage{ 73 | topic: "test-prefix/cars/3/trim_badging", 74 | payload: []byte("test-trim-badging-3"), 75 | }) 76 | cb(nil, &stubMessage{ 77 | topic: "test-prefix/cars/3/model", 78 | payload: []byte("test-model-3"), 79 | }) 80 | cb(nil, &stubMessage{ 81 | topic: "test-prefix/cars/3/version", 82 | payload: []byte("test-version-3"), 83 | }) 84 | }, 85 | subscribeTokens: []paho.Token{&stubToken{}}, 86 | }, 87 | }, 88 | args: args{ 89 | ctx: context.Background(), 90 | tmCfg: tm.Config{Prefix: "test-prefix"}, 91 | }, 92 | want: map[string]ha.Device{ 93 | "1": { 94 | Identifiers: []string{"test-prefix/cars/1"}, 95 | Manufacturer: "Tesla", 96 | Model: "Model test-model-1 test-trim-badging-1", 97 | Name: "test-display-name-1", 98 | SoftwareVersion: "test-version-1", 99 | SuggestedArea: "Garage", 100 | }, 101 | "2": { 102 | Identifiers: []string{"test-prefix/cars/2"}, 103 | Manufacturer: "Tesla", 104 | Model: "Model test-model-2 test-trim-badging-2", 105 | Name: "test-display-name-2", 106 | SoftwareVersion: "test-version-2", 107 | SuggestedArea: "Garage", 108 | }, 109 | "3": { 110 | Identifiers: []string{"test-prefix/cars/3"}, 111 | Manufacturer: "Tesla", 112 | Model: "Model test-model-3 test-trim-badging-3", 113 | Name: "Tesla", 114 | SoftwareVersion: "test-version-3", 115 | SuggestedArea: "Garage", 116 | }, 117 | }, 118 | }, 119 | { 120 | name: "error", 121 | fields: fields{ 122 | Client: stubPubSub{ 123 | subscribeTokens: []paho.Token{ 124 | &stubToken{err: fmt.Errorf("subscribe error")}, 125 | }, 126 | }, 127 | }, 128 | args: args{ 129 | ctx: context.Background(), 130 | tmCfg: tm.Config{Prefix: "test-prefix"}, 131 | }, 132 | wantErr: true, 133 | }, 134 | } 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | m := &MQTT{ 138 | Client: &tt.fields.Client, 139 | } 140 | got, err := m.ListVehicles(tt.args.ctx, tt.args.tmCfg) 141 | if (err != nil) != tt.wantErr { 142 | t.Errorf("MQTT.ListVehicles() error = %v, wantErr %v", err, tt.wantErr) 143 | return 144 | } 145 | if tt.wantErr { 146 | return 147 | } 148 | 149 | if !reflect.DeepEqual(got, tt.want) { 150 | t.Errorf("MQTT.ListVehicles() = %v, want %v", got, tt.want) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /cmd/teslamate-discovery/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os" 10 | "os/signal" 11 | 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | 15 | "github.com/nebhale/teslamate-discovery/ha" 16 | "github.com/nebhale/teslamate-discovery/mqtt" 17 | "github.com/nebhale/teslamate-discovery/tm" 18 | "github.com/nebhale/teslamate-discovery/units" 19 | ) 20 | 21 | func main() { 22 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 23 | 24 | config := &DefaultConfig 25 | 26 | cmd, viper := CreateCommand() 27 | cmd.PreRunE = UnmarshalConfig(config, viper) 28 | cmd.RunE = Run(config) 29 | 30 | if err := cmd.ExecuteContext(ctx); err != nil { 31 | stop() 32 | os.Exit(1) 33 | } 34 | 35 | stop() 36 | } 37 | 38 | type CobraEFn func(cmd *cobra.Command, args []string) error 39 | 40 | func CreateCommand() (*cobra.Command, *viper.Viper) { 41 | viper := viper.New() 42 | 43 | cmd := &cobra.Command{ 44 | Use: "teslamate-discovery", 45 | Short: "Configure Home Assistant MQTT Discovery for TeslaMate instances", 46 | Version: version, 47 | } 48 | 49 | flags := cmd.Flags() 50 | 51 | _ = flags.String("ha-discovery-prefix", ha.DefaultDiscoveryPrefix, "home assistant discovery message prefix") 52 | _ = viper.BindPFlag("ha.discovery_prefix", flags.Lookup("ha-discovery-prefix")) 53 | _ = viper.BindEnv("ha.discovery_prefix", "HA_DISCOVERY_PREFIX") 54 | viper.SetDefault("ha.discovery_prefix", ha.DefaultDiscoveryPrefix) 55 | 56 | _ = flags.StringP("mqtt-scheme", "s", mqtt.DefaultScheme, "mqtt broker scheme") 57 | _ = viper.BindPFlag("mqtt.scheme", flags.Lookup("mqtt-scheme")) 58 | _ = viper.BindEnv("mqtt.scheme", "MQTT_SCHEME") 59 | viper.SetDefault("mqtt.scheme", mqtt.DefaultScheme) 60 | 61 | _ = flags.StringP("mqtt-host", "h", mqtt.DefaultHost, "mqtt broker host") 62 | _ = viper.BindPFlag("mqtt.host", flags.Lookup("mqtt-host")) 63 | _ = viper.BindEnv("mqtt.host", "MQTT_HOST") 64 | viper.SetDefault("mqtt.host", mqtt.DefaultHost) 65 | 66 | _ = flags.IntP("mqtt-port", "p", mqtt.DefaultPort, "mqtt broker port") 67 | _ = viper.BindPFlag("mqtt.port", flags.Lookup("mqtt-port")) 68 | _ = viper.BindEnv("mqtt.port", "MQTT_PORT") 69 | viper.SetDefault("mqtt.port", mqtt.DefaultPort) 70 | 71 | _ = flags.StringP("mqtt-username", "u", "", "mqtt broker username") 72 | _ = viper.BindPFlag("mqtt.username", flags.Lookup("mqtt-username")) 73 | _ = viper.BindEnv("mqtt.username", "MQTT_USERNAME") 74 | 75 | _ = flags.StringP("mqtt-password", "P", "", "mqtt broker password") 76 | _ = viper.BindPFlag("mqtt.password", flags.Lookup("mqtt-password")) 77 | _ = viper.BindEnv("mqtt.password", "MQTT_PASSWORD") 78 | 79 | _ = flags.String("tm-prefix", tm.DefaultPrefix, "teslamate message prefix") 80 | _ = viper.BindPFlag("tm.prefix", flags.Lookup("tm-prefix")) 81 | _ = viper.BindEnv("tm.prefix", "TM_PREFIX") 82 | viper.SetDefault("tm.prefix", tm.DefaultPrefix) 83 | 84 | r := units.DefaultRangeType 85 | flags.Var(&r, "range-type", "range type [\"estimated\", \"ideal\", \"rated\"]") 86 | _ = cmd.RegisterFlagCompletionFunc("range-type", units.RangeTypeCompletion) 87 | _ = viper.BindPFlag("units.range_type", flags.Lookup("range-type")) 88 | _ = viper.BindEnv("units.range_type", "UNITS_RANGE_TYPE") 89 | viper.SetDefault("units.range_type", units.DefaultRangeType) 90 | 91 | d := units.DefaultDistance 92 | flags.Var(&d, "units-distance", "distance units [\"imperial\", \"metric\"]") 93 | _ = cmd.RegisterFlagCompletionFunc("units-distance", units.SystemOfMeasurementCompletion) 94 | _ = viper.BindPFlag("units.distance", flags.Lookup("units-distance")) 95 | _ = viper.BindEnv("units.distance", "UNITS_DISTANCE") 96 | viper.SetDefault("units.distance", units.DefaultDistance) 97 | 98 | p := units.DefaultPressure 99 | flags.Var(&p, "units-pressure", "pressure units [\"imperial\", \"metric\"]") 100 | _ = cmd.RegisterFlagCompletionFunc("units-pressure", units.SystemOfMeasurementCompletion) 101 | _ = viper.BindPFlag("units.pressure", flags.Lookup("units-pressure")) 102 | _ = viper.BindEnv("units.pressure", "UNITS_PRESSURE") 103 | viper.SetDefault("units.pressure", units.DefaultPressure) 104 | 105 | _ = flags.Bool("help", false, fmt.Sprintf("help for %s", cmd.Name())) 106 | 107 | return cmd, viper 108 | } 109 | 110 | func Run(config *Config) CobraEFn { 111 | return func(cmd *cobra.Command, args []string) error { 112 | ctx := cmd.Context() 113 | 114 | mqtt, err := mqtt.NewMQTT(ctx, config.MQTT) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | vehicles, err := mqtt.ListVehicles(ctx, config.Teslamate) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | for id, dev := range vehicles { 125 | if err := mqtt.PublishDiscovery(ctx, id, dev, config.HomeAssistant, config.Units); err != nil { 126 | return err 127 | } 128 | } 129 | 130 | return nil 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= 5 | github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= 6 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 7 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 8 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 9 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 10 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 11 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 15 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 16 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 17 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 18 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 19 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 20 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 21 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 22 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 23 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 24 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 25 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 29 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 30 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 31 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 32 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 33 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 34 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 35 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 36 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 37 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 38 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 39 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 40 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 41 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 42 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 43 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 44 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 45 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 46 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 47 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 48 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 49 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 50 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 51 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 52 | golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= 53 | golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 54 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 55 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 56 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 57 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 58 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 59 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 60 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 61 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 62 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | -------------------------------------------------------------------------------- /mqtt/mqtt_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "testing" 11 | 12 | paho "github.com/eclipse/paho.mqtt.golang" 13 | 14 | "github.com/nebhale/teslamate-discovery/ha" 15 | . "github.com/nebhale/teslamate-discovery/mqtt" 16 | ) 17 | 18 | func TestMQTT_Publish(t *testing.T) { 19 | type fields struct { 20 | Client stubPubSub 21 | } 22 | type args struct { 23 | ctx context.Context 24 | discoveryPrefix string 25 | v []interface{} 26 | } 27 | tests := []struct { 28 | name string 29 | fields fields 30 | args args 31 | want []string 32 | wantErr bool 33 | }{ 34 | { 35 | name: "known", 36 | fields: fields{ 37 | Client: stubPubSub{ 38 | publishTokens: []paho.Token{&stubToken{}}, 39 | }, 40 | }, 41 | args: args{ 42 | ctx: context.Background(), 43 | discoveryPrefix: "test-discovery-prefix", 44 | v: []interface{}{ 45 | ha.BinarySensor{ 46 | UniqueId: "test-unique-id", 47 | }, 48 | ha.DeviceTracker{ 49 | UniqueId: "test-unique-id", 50 | }, 51 | ha.Sensor{ 52 | UniqueId: "test-unique-id", 53 | }, 54 | }, 55 | }, 56 | want: []string{ 57 | "test-discovery-prefix/binary_sensor/test-unique-id/config", 58 | "test-discovery-prefix/device_tracker/test-unique-id/config", 59 | "test-discovery-prefix/sensor/test-unique-id/config", 60 | }, 61 | }, 62 | { 63 | name: "unknown", 64 | fields: fields{ 65 | Client: stubPubSub{ 66 | publishTokens: []paho.Token{&stubToken{}}, 67 | }, 68 | }, 69 | args: args{ 70 | ctx: context.Background(), 71 | discoveryPrefix: "test-discovery-prefix", 72 | v: []interface{}{""}, 73 | }, 74 | wantErr: true, 75 | }, 76 | { 77 | name: "error", 78 | fields: fields{ 79 | Client: stubPubSub{ 80 | publishTokens: []paho.Token{ 81 | &stubToken{err: fmt.Errorf("publish error")}, 82 | }, 83 | }, 84 | }, 85 | args: args{ 86 | ctx: context.Background(), 87 | discoveryPrefix: "test-discovery-prefix", 88 | v: []interface{}{ 89 | ha.BinarySensor{ 90 | UniqueId: "test-unique-id", 91 | }, 92 | }, 93 | }, 94 | wantErr: true, 95 | }, 96 | } 97 | for _, tt := range tests { 98 | t.Run(tt.name, func(t *testing.T) { 99 | m := &MQTT{ 100 | Client: &tt.fields.Client, 101 | } 102 | if err := m.Publish(tt.args.ctx, tt.args.discoveryPrefix, tt.args.v...); (err != nil) != tt.wantErr { 103 | t.Errorf("MQTT.Publish() error = %v, wantErr %v", err, tt.wantErr) 104 | } 105 | if tt.wantErr { 106 | return 107 | } 108 | 109 | var topics []string 110 | for _, v := range tt.fields.Client.publishArgs { 111 | topics = append(topics, v.topic) 112 | } 113 | 114 | if !reflect.DeepEqual(topics, tt.want) { 115 | t.Errorf("MQTT.PublishDiscovery() topics = %v, want %v", topics, tt.want) 116 | return 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestMQTT_Subscribe(t *testing.T) { 123 | type fields struct { 124 | Client stubPubSub 125 | } 126 | type args struct { 127 | ctx context.Context 128 | topic string 129 | } 130 | tests := []struct { 131 | name string 132 | fields fields 133 | args args 134 | want paho.Message 135 | wantErr bool 136 | }{ 137 | { 138 | name: "default", 139 | fields: fields{ 140 | Client: stubPubSub{ 141 | subscribeHandler: func(callback paho.MessageHandler) { 142 | callback(nil, &stubMessage{messageId: 1}) 143 | }, 144 | subscribeTokens: []paho.Token{&stubToken{}}, 145 | }, 146 | }, 147 | args: args{ 148 | ctx: context.Background(), 149 | topic: "test-topic", 150 | }, 151 | want: &stubMessage{messageId: 1}, 152 | }, 153 | { 154 | name: "error", 155 | fields: fields{ 156 | Client: stubPubSub{ 157 | subscribeTokens: []paho.Token{ 158 | &stubToken{err: fmt.Errorf("subscribe error")}, 159 | }, 160 | }, 161 | }, 162 | args: args{ 163 | ctx: context.Background(), 164 | topic: "test-topic", 165 | }, 166 | wantErr: true, 167 | }, 168 | } 169 | for _, tt := range tests { 170 | t.Run(tt.name, func(t *testing.T) { 171 | m := &MQTT{ 172 | Client: &tt.fields.Client, 173 | } 174 | ch, err := m.Subscribe(tt.args.ctx, tt.args.topic) 175 | if (err != nil) != tt.wantErr { 176 | t.Errorf("MQTT.Subscribe() error = %v, wantErr %v", err, tt.wantErr) 177 | return 178 | } 179 | if tt.wantErr { 180 | return 181 | } 182 | 183 | got := <-ch 184 | if !reflect.DeepEqual(got, tt.want) { 185 | t.Errorf("MQTT.Subscribe() = %v, want %v", got, tt.want) 186 | } 187 | }) 188 | } 189 | } 190 | 191 | func TestMQTT_Unsubscribe(t *testing.T) { 192 | type fields struct { 193 | Client stubPubSub 194 | } 195 | type args struct { 196 | ctx context.Context 197 | topic string 198 | } 199 | tests := []struct { 200 | name string 201 | fields fields 202 | args args 203 | want []string 204 | wantErr bool 205 | }{ 206 | { 207 | name: "default", 208 | fields: fields{ 209 | Client: stubPubSub{ 210 | unsubscribeTokens: []paho.Token{&stubToken{}}, 211 | }, 212 | }, 213 | args: args{ 214 | ctx: context.Background(), 215 | topic: "test-topic", 216 | }, 217 | want: []string{"test-topic"}, 218 | }, 219 | { 220 | name: "error", 221 | fields: fields{ 222 | Client: stubPubSub{ 223 | unsubscribeTokens: []paho.Token{ 224 | &stubToken{err: fmt.Errorf("unsubscribe error")}, 225 | }, 226 | }, 227 | }, 228 | args: args{ 229 | ctx: context.Background(), 230 | topic: "test-topic", 231 | }, 232 | wantErr: true, 233 | }, 234 | } 235 | for _, tt := range tests { 236 | t.Run(tt.name, func(t *testing.T) { 237 | m := &MQTT{ 238 | Client: &tt.fields.Client, 239 | } 240 | if err := m.Unsubscribe(tt.args.ctx, tt.args.topic); (err != nil) != tt.wantErr { 241 | t.Errorf("MQTT.Unsubscribe() error = %v, wantErr %v", err, tt.wantErr) 242 | } 243 | if tt.wantErr { 244 | return 245 | } 246 | 247 | args := tt.fields.Client.unsubscribeArgs[0] 248 | if !reflect.DeepEqual(args.topics, tt.want) { 249 | t.Errorf("MQTT.PublishDiscovery() topics = %v, want %v", args.topics, tt.want) 250 | return 251 | } 252 | }) 253 | } 254 | } 255 | 256 | func TestBrokerURI(t *testing.T) { 257 | type args struct { 258 | config Config 259 | } 260 | tests := []struct { 261 | name string 262 | args args 263 | want string 264 | }{ 265 | { 266 | name: "default", 267 | args: args{ 268 | config: Config{ 269 | Scheme: "test-scheme", 270 | Host: "test-host", 271 | Port: 4242, 272 | }, 273 | }, 274 | want: "test-scheme://test-host:4242", 275 | }, 276 | } 277 | for _, tt := range tests { 278 | t.Run(tt.name, func(t *testing.T) { 279 | if got := BrokerURI(tt.args.config); got != tt.want { 280 | t.Errorf("BrokerURI() = %v, want %v", got, tt.want) 281 | } 282 | }) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /mqtt/publish_discovery_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "testing" 11 | 12 | paho "github.com/eclipse/paho.mqtt.golang" 13 | 14 | "github.com/nebhale/teslamate-discovery/ha" 15 | . "github.com/nebhale/teslamate-discovery/mqtt" 16 | "github.com/nebhale/teslamate-discovery/units" 17 | ) 18 | 19 | func TestMQTT_PublishDiscovery(t *testing.T) { 20 | d := ha.Device{ 21 | Identifiers: []string{"test-id"}, 22 | } 23 | 24 | type fields struct { 25 | Client stubPubSub 26 | } 27 | type args struct { 28 | ctx context.Context 29 | id string 30 | device ha.Device 31 | haCfg ha.Config 32 | unitsCfg units.Config 33 | } 34 | tests := []struct { 35 | name string 36 | fields fields 37 | args args 38 | want []string 39 | wantErr bool 40 | }{ 41 | { 42 | name: "publish", 43 | fields: fields{ 44 | Client: stubPubSub{ 45 | publishTokens: []paho.Token{&stubToken{}}, 46 | }, 47 | }, 48 | args: args{ 49 | ctx: context.Background(), 50 | id: "test-id", 51 | device: d, 52 | haCfg: ha.Config{DiscoveryPrefix: "test-discovery-prefix"}, 53 | unitsCfg: units.Config{}, 54 | }, 55 | want: []string{ 56 | "test-discovery-prefix/sensor/test-id/charge_current_request/config", 57 | "test-discovery-prefix/sensor/test-id/charge_current_request_max/config", 58 | "test-discovery-prefix/sensor/test-id/charge_energy_added/config", 59 | "test-discovery-prefix/sensor/test-id/limit/config", 60 | "test-discovery-prefix/sensor/test-id/charger_current/config", 61 | "test-discovery-prefix/binary_sensor/test-id/charging/config", 62 | "test-discovery-prefix/binary_sensor/test-id/plug/config", 63 | "test-discovery-prefix/sensor/test-id/charger_phases/config", 64 | "test-discovery-prefix/sensor/test-id/charger_power/config", 65 | "test-discovery-prefix/sensor/test-id/charger_voltage/config", 66 | "test-discovery-prefix/sensor/test-id/start_time/config", 67 | "test-discovery-prefix/sensor/test-id/time_to_charged/config", 68 | "test-discovery-prefix/sensor/test-id/inside_temp/config", 69 | "test-discovery-prefix/binary_sensor/test-id/climate/config", 70 | "test-discovery-prefix/binary_sensor/test-id/preconditioning/config", 71 | "test-discovery-prefix/sensor/test-id/outside_temp/config", 72 | "test-discovery-prefix/sensor/test-id/elevation/config", 73 | "test-discovery-prefix/sensor/test-id/geofence/config", 74 | "test-discovery-prefix/sensor/test-id/heading/config", 75 | "test-discovery-prefix/device_tracker/test-id/location/config", 76 | "test-discovery-prefix/sensor/test-id/power/config", 77 | "test-discovery-prefix/sensor/test-id/speed/config", 78 | "test-discovery-prefix/sensor/test-id/exterior_color/config", 79 | "test-discovery-prefix/sensor/test-id/spoiler_type/config", 80 | "test-discovery-prefix/sensor/test-id/display_name/config", 81 | "test-discovery-prefix/sensor/test-id/battery/config", 82 | "test-discovery-prefix/sensor/test-id/usable_battery/config", 83 | "test-discovery-prefix/sensor/test-id/center_display/config", 84 | "test-discovery-prefix/binary_sensor/test-id/charge_port/config", 85 | "test-discovery-prefix/binary_sensor/test-id/doors/config", 86 | "test-discovery-prefix/binary_sensor/test-id/door_driver_front/config", 87 | "test-discovery-prefix/binary_sensor/test-id/door_driver_rear/config", 88 | "test-discovery-prefix/binary_sensor/test-id/frunk/config", 89 | "test-discovery-prefix/binary_sensor/test-id/health/config", 90 | "test-discovery-prefix/binary_sensor/test-id/locked/config", 91 | "test-discovery-prefix/binary_sensor/test-id/occupied/config", 92 | "test-discovery-prefix/sensor/test-id/odometer/config", 93 | "test-discovery-prefix/binary_sensor/test-id/door_passenger_front/config", 94 | "test-discovery-prefix/binary_sensor/test-id/door_passenger_rear/config", 95 | "test-discovery-prefix/sensor/test-id/range/config", 96 | "test-discovery-prefix/binary_sensor/test-id/sentry_mode/config", 97 | "test-discovery-prefix/sensor/test-id/shift_state/config", 98 | "test-discovery-prefix/sensor/test-id/state/config", 99 | "test-discovery-prefix/sensor/test-id/since/config", 100 | "test-discovery-prefix/sensor/test-id/tire_pressure_front_left/config", 101 | "test-discovery-prefix/sensor/test-id/tire_pressure_front_right/config", 102 | "test-discovery-prefix/sensor/test-id/tire_pressure_rear_left/config", 103 | "test-discovery-prefix/sensor/test-id/tire_pressure_rear_right/config", 104 | "test-discovery-prefix/binary_sensor/test-id/tire_soft_front_left/config", 105 | "test-discovery-prefix/binary_sensor/test-id/tire_soft_front_right/config", 106 | "test-discovery-prefix/binary_sensor/test-id/tire_soft_rear_left/config", 107 | "test-discovery-prefix/binary_sensor/test-id/tire_soft_rear_right/config", 108 | "test-discovery-prefix/binary_sensor/test-id/trunk/config", 109 | "test-discovery-prefix/binary_sensor/test-id/update/config", 110 | "test-discovery-prefix/binary_sensor/test-id/windows/config", 111 | "test-discovery-prefix/sensor/test-id/version/config", 112 | }, 113 | }, 114 | { 115 | name: "publish error", 116 | fields: fields{ 117 | Client: stubPubSub{ 118 | publishTokens: []paho.Token{ 119 | &stubToken{err: fmt.Errorf("publish error")}, 120 | }, 121 | }, 122 | }, 123 | args: args{ 124 | ctx: context.Background(), 125 | id: "test-id", 126 | device: d, 127 | haCfg: ha.Config{DiscoveryPrefix: "test-discovery-prefix"}, 128 | unitsCfg: units.Config{}, 129 | }, 130 | wantErr: true, 131 | }, 132 | } 133 | for _, tt := range tests { 134 | t.Run(tt.name, func(t *testing.T) { 135 | m := &MQTT{ 136 | Client: &tt.fields.Client, 137 | } 138 | if err := m.PublishDiscovery(tt.args.ctx, tt.args.id, tt.args.device, tt.args.haCfg, tt.args.unitsCfg); (err != nil) != tt.wantErr { 139 | t.Errorf("MQTT.PublishDiscovery() error = %v, wantErr %v", err, tt.wantErr) 140 | return 141 | } 142 | if tt.wantErr { 143 | return 144 | } 145 | 146 | var topics []string 147 | for _, v := range tt.fields.Client.publishArgs { 148 | topics = append(topics, v.topic) 149 | } 150 | 151 | if !reflect.DeepEqual(topics, tt.want) { 152 | t.Errorf("MQTT.PublishDiscovery() topics = %v, want %v", topics, tt.want) 153 | return 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestStateTopic(t *testing.T) { 160 | type args struct { 161 | device ha.Device 162 | suffix string 163 | } 164 | tests := []struct { 165 | name string 166 | args args 167 | want string 168 | }{ 169 | { 170 | name: "default", 171 | args: args{ 172 | device: ha.Device{Identifiers: []string{"test"}}, 173 | suffix: "/topic", 174 | }, 175 | want: "test/topic", 176 | }, 177 | } 178 | for _, tt := range tests { 179 | t.Run(tt.name, func(t *testing.T) { 180 | if got := StateTopic(tt.args.device, tt.args.suffix); got != tt.want { 181 | t.Errorf("StateTopic() = %v, want %v", got, tt.want) 182 | } 183 | }) 184 | } 185 | } 186 | 187 | func TestUniqueId(t *testing.T) { 188 | type args struct { 189 | device ha.Device 190 | suffix string 191 | } 192 | tests := []struct { 193 | name string 194 | args args 195 | want string 196 | }{ 197 | { 198 | name: "default", 199 | args: args{ 200 | device: ha.Device{Identifiers: []string{"test"}}, 201 | suffix: "/uniqueId", 202 | }, 203 | want: "test/uniqueId", 204 | }, 205 | { 206 | name: "default", 207 | args: args{ 208 | device: ha.Device{Identifiers: []string{"test/test"}}, 209 | suffix: "/uniqueId", 210 | }, 211 | want: "test_test/uniqueId", 212 | }, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | if got := UniqueId(tt.args.device, tt.args.suffix); got != tt.want { 217 | t.Errorf("UniqueId() = %v, want %v", got, tt.want) 218 | } 219 | }) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /units/system_of_measurement_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package units_test 5 | 6 | import ( 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | . "github.com/nebhale/teslamate-discovery/units" 13 | ) 14 | 15 | func TestSystemOfMeasurement_DistanceLongUnits(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | s SystemOfMeasurement 19 | want string 20 | }{ 21 | { 22 | name: "imperial", 23 | s: Imperial, 24 | want: "mi", 25 | }, 26 | { 27 | name: "metric", 28 | s: Metric, 29 | want: "km", 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := tt.s.DistanceLongUnits(); got != tt.want { 35 | t.Errorf("SystemOfMeasurement.DistanceLongUnits() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestSystemOfMeasurement_DistanceLongValueTemplate(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | s SystemOfMeasurement 45 | want string 46 | }{ 47 | { 48 | name: "imperial", 49 | s: Imperial, 50 | want: "{{ (value | float(0) / 1.609344) | round(1) }}", 51 | }, 52 | { 53 | name: "metric", 54 | s: Metric, 55 | want: "{{ value | round(1) }}", 56 | }, 57 | } 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | if got := tt.s.DistanceLongValueTemplate(); got != tt.want { 61 | t.Errorf("SystemOfMeasurement.DistanceLongValueTemplate() = %v, want %v", got, tt.want) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func TestSystemOfMeasurement_DistanceShortUnits(t *testing.T) { 68 | tests := []struct { 69 | name string 70 | s SystemOfMeasurement 71 | want string 72 | }{ 73 | { 74 | name: "imperial", 75 | s: Imperial, 76 | want: "ft", 77 | }, 78 | { 79 | name: "metric", 80 | s: Metric, 81 | want: "m", 82 | }, 83 | } 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | if got := tt.s.DistanceShortUnits(); got != tt.want { 87 | t.Errorf("SystemOfMeasurement.DistanceShortUnits() = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestSystemOfMeasurement_DistanceShortValueTemplate(t *testing.T) { 94 | tests := []struct { 95 | name string 96 | s SystemOfMeasurement 97 | want string 98 | }{ 99 | { 100 | name: "imperial", 101 | s: Imperial, 102 | want: "{{ (value | float(0) * 3.280839) | round(1) }}", 103 | }, 104 | { 105 | name: "metric", 106 | s: Metric, 107 | want: "{{ value | round(1) }}", 108 | }, 109 | } 110 | for _, tt := range tests { 111 | t.Run(tt.name, func(t *testing.T) { 112 | if got := tt.s.DistanceShortValueTemplate(); got != tt.want { 113 | t.Errorf("SystemOfMeasurement.DistanceShortValueTemplate() = %v, want %v", got, tt.want) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestSystemOfMeasurement_PressureUnits(t *testing.T) { 120 | tests := []struct { 121 | name string 122 | s SystemOfMeasurement 123 | want string 124 | }{ 125 | { 126 | name: "imperial", 127 | s: Imperial, 128 | want: "psi", 129 | }, 130 | { 131 | name: "metric", 132 | s: Metric, 133 | want: "bar", 134 | }, 135 | } 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | if got := tt.s.PressureUnits(); got != tt.want { 139 | t.Errorf("SystemOfMeasurement.PressureUnits() = %v, want %v", got, tt.want) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestSystemOfMeasurement_PressureValueTemplate(t *testing.T) { 146 | tests := []struct { 147 | name string 148 | s SystemOfMeasurement 149 | want string 150 | }{ 151 | { 152 | name: "imperial", 153 | s: Imperial, 154 | want: "{{ (value | float(0) * 14.503773) | round(1) }}", 155 | }, 156 | { 157 | name: "metric", 158 | s: Metric, 159 | want: "{{ value | round(1) }}", 160 | }, 161 | } 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | if got := tt.s.PressureValueTemplate(); got != tt.want { 165 | t.Errorf("SystemOfMeasurement.PressureValueTemplate() = %v, want %v", got, tt.want) 166 | } 167 | }) 168 | } 169 | } 170 | 171 | func TestSystemOfMeasurement_Set(t *testing.T) { 172 | type args struct { 173 | v string 174 | } 175 | tests := []struct { 176 | name string 177 | args args 178 | want SystemOfMeasurement 179 | wantErr bool 180 | }{ 181 | { 182 | name: "imperial", 183 | args: args{v: "imperial"}, 184 | want: Imperial, 185 | }, 186 | { 187 | name: "metric", 188 | args: args{v: "metric"}, 189 | want: Metric, 190 | }, 191 | { 192 | name: "unknown", 193 | args: args{v: "unknown"}, 194 | wantErr: true, 195 | }, 196 | } 197 | for _, tt := range tests { 198 | t.Run(tt.name, func(t *testing.T) { 199 | var s SystemOfMeasurement 200 | err := s.Set(tt.args.v) 201 | if (err != nil) != tt.wantErr { 202 | t.Errorf("SystemOfMeasurement.Set() error = %v, wantErr %v", err, tt.wantErr) 203 | return 204 | } 205 | if tt.wantErr { 206 | return 207 | } 208 | 209 | if s != tt.want { 210 | t.Errorf("SystemOfMeasurement.Set() = %v, want %v", s, tt.want) 211 | } 212 | }) 213 | } 214 | } 215 | 216 | func TestSystemOfMeasurement_String(t *testing.T) { 217 | tests := []struct { 218 | name string 219 | s SystemOfMeasurement 220 | want string 221 | }{ 222 | { 223 | name: "imperial", 224 | s: Imperial, 225 | want: "imperial", 226 | }, 227 | { 228 | name: "metric", 229 | s: Metric, 230 | want: "metric", 231 | }, 232 | } 233 | for _, tt := range tests { 234 | t.Run(tt.name, func(t *testing.T) { 235 | if got := tt.s.String(); got != tt.want { 236 | t.Errorf("SystemOfMeasurement.String() = %v, want %v", got, tt.want) 237 | } 238 | }) 239 | } 240 | } 241 | 242 | func TestSystemOfMeasurement_SpeedUnits(t *testing.T) { 243 | tests := []struct { 244 | name string 245 | s SystemOfMeasurement 246 | want string 247 | }{ 248 | { 249 | name: "imperial", 250 | s: Imperial, 251 | want: "mph", 252 | }, 253 | { 254 | name: "metric", 255 | s: Metric, 256 | want: "kph", 257 | }, 258 | } 259 | for _, tt := range tests { 260 | t.Run(tt.name, func(t *testing.T) { 261 | if got := tt.s.SpeedUnits(); got != tt.want { 262 | t.Errorf("SystemOfMeasurement.SpeedUnits() = %v, want %v", got, tt.want) 263 | } 264 | }) 265 | } 266 | } 267 | 268 | func TestSystemOfMeasurement_SpeedValueTemplate(t *testing.T) { 269 | tests := []struct { 270 | name string 271 | s SystemOfMeasurement 272 | want string 273 | }{ 274 | { 275 | name: "imperial", 276 | s: Imperial, 277 | want: "{{ (value | float(0) / 1.609344) | round(1) }}", 278 | }, 279 | { 280 | name: "metric", 281 | s: Metric, 282 | want: "{{ value | round(1) }}", 283 | }, 284 | } 285 | for _, tt := range tests { 286 | t.Run(tt.name, func(t *testing.T) { 287 | if got := tt.s.SpeedValueTemplate(); got != tt.want { 288 | t.Errorf("SystemOfMeasurement.SpeedValueTemplate() = %v, want %v", got, tt.want) 289 | } 290 | }) 291 | } 292 | } 293 | 294 | func TestSystemOfMeasurement_Type(t *testing.T) { 295 | tests := []struct { 296 | name string 297 | s SystemOfMeasurement 298 | want string 299 | }{ 300 | { 301 | name: "imperial", 302 | s: Imperial, 303 | want: "string", 304 | }, 305 | { 306 | name: "metric", 307 | s: Metric, 308 | want: "string", 309 | }, 310 | } 311 | for _, tt := range tests { 312 | t.Run(tt.name, func(t *testing.T) { 313 | if got := tt.s.Type(); got != tt.want { 314 | t.Errorf("SystemOfMeasurement.Type() = %v, want %v", got, tt.want) 315 | } 316 | }) 317 | } 318 | } 319 | 320 | func TestSystemOfMeasurementCompletion(t *testing.T) { 321 | type args struct { 322 | cmd *cobra.Command 323 | args []string 324 | toComplete string 325 | } 326 | tests := []struct { 327 | name string 328 | args args 329 | want []string 330 | want1 cobra.ShellCompDirective 331 | }{ 332 | { 333 | name: "always", 334 | args: args{}, 335 | want: []string{"imperial", "metric"}, 336 | want1: cobra.ShellCompDirectiveDefault, 337 | }, 338 | } 339 | for _, tt := range tests { 340 | t.Run(tt.name, func(t *testing.T) { 341 | got, got1 := SystemOfMeasurementCompletion(tt.args.cmd, tt.args.args, tt.args.toComplete) 342 | if !reflect.DeepEqual(got, tt.want) { 343 | t.Errorf("SystemOfMeasurementCompletion() got = %v, want %v", got, tt.want) 344 | } 345 | if !reflect.DeepEqual(got1, tt.want1) { 346 | t.Errorf("SystemOfMeasurementCompletion() got1 = %v, want %v", got1, tt.want1) 347 | } 348 | }) 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /hack/goreleaser/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nebhale/teslamate-discovery/hack/goreleaser 2 | 3 | go 1.25.5 4 | 5 | require github.com/goreleaser/goreleaser/v2 v2.13.1 6 | 7 | require ( 8 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 9 | cel.dev/expr v0.25.1 // indirect 10 | cloud.google.com/go v0.121.6 // indirect 11 | cloud.google.com/go/auth v0.16.4 // indirect 12 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 13 | cloud.google.com/go/compute/metadata v0.8.0 // indirect 14 | cloud.google.com/go/iam v1.5.2 // indirect 15 | cloud.google.com/go/kms v1.23.2 // indirect 16 | cloud.google.com/go/longrunning v0.6.7 // indirect 17 | cloud.google.com/go/monitoring v1.24.2 // indirect 18 | cloud.google.com/go/storage v1.56.0 // indirect 19 | code.gitea.io/sdk/gitea v0.22.1 // indirect 20 | dario.cat/mergo v1.0.2 // indirect 21 | github.com/42wim/httpsig v1.2.3 // indirect 22 | github.com/AlekSi/pointer v1.2.0 // indirect 23 | github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect 24 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect 25 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect 26 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect 27 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect 28 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect 29 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 // indirect 30 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect 31 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 // indirect 32 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 33 | github.com/Azure/go-autorest/autorest v0.11.30 // indirect 34 | github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect 35 | github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect 36 | github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect 37 | github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect 38 | github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect 39 | github.com/Azure/go-autorest/logger v0.2.2 // indirect 40 | github.com/Azure/go-autorest/tracing v0.6.1 // indirect 41 | github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect 42 | github.com/BurntSushi/toml v1.5.0 // indirect 43 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect 44 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect 45 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect 46 | github.com/Masterminds/goutils v1.1.1 // indirect 47 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 48 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 49 | github.com/Microsoft/go-winio v0.6.2 // indirect 50 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 51 | github.com/agnivade/levenshtein v1.2.1 // indirect 52 | github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a // indirect 53 | github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 // indirect 54 | github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect 55 | github.com/anchore/quill v0.5.1 // indirect 56 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 57 | github.com/atc0005/go-teams-notify/v2 v2.14.0 // indirect 58 | github.com/avast/retry-go/v4 v4.7.0 // indirect 59 | github.com/aws/aws-sdk-go v1.55.7 // indirect 60 | github.com/aws/aws-sdk-go-v2 v1.40.1 // indirect 61 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 62 | github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect 63 | github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect 64 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect 65 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3 // indirect 66 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect 67 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect 68 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 69 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect 70 | github.com/aws/aws-sdk-go-v2/service/ecr v1.51.2 // indirect 71 | github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.2 // indirect 72 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 73 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect 74 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect 75 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect 76 | github.com/aws/aws-sdk-go-v2/service/kms v1.43.0 // indirect 77 | github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 // indirect 78 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect 79 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect 80 | github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect 81 | github.com/aws/smithy-go v1.24.0 // indirect 82 | github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.11.0 // indirect 83 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 84 | github.com/bahlo/generic-list-go v0.2.0 // indirect 85 | github.com/blacktop/go-dwarf v1.0.10 // indirect 86 | github.com/blacktop/go-macho v1.1.238 // indirect 87 | github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect 88 | github.com/blang/semver v3.5.1+incompatible // indirect 89 | github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 // indirect 90 | github.com/buger/jsonparser v1.1.1 // indirect 91 | github.com/caarlos0/env/v11 v11.3.1 // indirect 92 | github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect 93 | github.com/caarlos0/go-shellwords v1.0.12 // indirect 94 | github.com/caarlos0/go-version v0.2.2 // indirect 95 | github.com/caarlos0/log v0.5.2 // indirect 96 | github.com/carlmjohnson/versioninfo v0.22.5 // indirect 97 | github.com/cavaliergopher/cpio v1.0.1 // indirect 98 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 99 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 100 | github.com/charmbracelet/bubbletea v1.3.0 // indirect 101 | github.com/charmbracelet/colorprofile v0.3.2 // indirect 102 | github.com/charmbracelet/fang v0.4.3 // indirect 103 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 104 | github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 105 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 106 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 107 | github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 108 | github.com/charmbracelet/x/term v0.2.1 // indirect 109 | github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect 110 | github.com/cloudflare/circl v1.6.1 // indirect 111 | github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect 112 | github.com/containerd/errdefs v1.0.0 // indirect 113 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 114 | github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect 115 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 116 | github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect 117 | github.com/cyphar/filepath-securejoin v0.5.1 // indirect 118 | github.com/davidmz/go-pageant v1.0.2 // indirect 119 | github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb // indirect 120 | github.com/dghubble/oauth1 v0.7.3 // indirect 121 | github.com/dghubble/sling v1.4.0 // indirect 122 | github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect 123 | github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect 124 | github.com/dimchansky/utfbom v1.1.1 // indirect 125 | github.com/distribution/reference v0.6.0 // indirect 126 | github.com/docker/cli v29.0.3+incompatible // indirect 127 | github.com/docker/distribution v2.8.3+incompatible // indirect 128 | github.com/docker/docker v28.5.2+incompatible // indirect 129 | github.com/docker/docker-credential-helpers v0.9.4 // indirect 130 | github.com/docker/go-connections v0.6.0 // indirect 131 | github.com/docker/go-units v0.5.0 // indirect 132 | github.com/dustin/go-humanize v1.0.1 // indirect 133 | github.com/emirpasic/gods v1.18.1 // indirect 134 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 135 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 136 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 137 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 138 | github.com/felixge/httpsnoop v1.0.4 // indirect 139 | github.com/fsnotify/fsnotify v1.9.0 // indirect 140 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 141 | github.com/github/smimesign v0.2.0 // indirect 142 | github.com/go-chi/chi/v5 v5.2.2 // indirect 143 | github.com/go-fed/httpsig v1.1.0 // indirect 144 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 145 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 146 | github.com/go-git/go-git/v5 v5.16.1 // indirect 147 | github.com/go-jose/go-jose/v4 v4.1.3 // indirect 148 | github.com/go-logr/logr v1.4.3 // indirect 149 | github.com/go-logr/stdr v1.2.2 // indirect 150 | github.com/go-openapi/analysis v0.23.0 // indirect 151 | github.com/go-openapi/errors v0.22.2 // indirect 152 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 153 | github.com/go-openapi/jsonreference v0.21.0 // indirect 154 | github.com/go-openapi/loads v0.22.0 // indirect 155 | github.com/go-openapi/runtime v0.28.0 // indirect 156 | github.com/go-openapi/spec v0.21.0 // indirect 157 | github.com/go-openapi/strfmt v0.23.0 // indirect 158 | github.com/go-openapi/swag v0.23.1 // indirect 159 | github.com/go-openapi/validate v0.24.0 // indirect 160 | github.com/go-restruct/restruct v1.2.0-alpha // indirect 161 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect 162 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 163 | github.com/gobwas/glob v0.2.3 // indirect 164 | github.com/gogo/protobuf v1.3.2 // indirect 165 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 166 | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 167 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 168 | github.com/google/certificate-transparency-go v1.3.1 // indirect 169 | github.com/google/go-containerregistry v0.20.7 // indirect 170 | github.com/google/go-github/v78 v78.0.0 // indirect 171 | github.com/google/go-querystring v1.1.0 // indirect 172 | github.com/google/ko v0.18.0 // indirect 173 | github.com/google/rpmpack v0.7.1 // indirect 174 | github.com/google/s2a-go v0.1.9 // indirect 175 | github.com/google/safetext v0.0.0-20240722112252-5a72de7e7962 // indirect 176 | github.com/google/uuid v1.6.0 // indirect 177 | github.com/google/wire v0.7.0 // indirect 178 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 179 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 180 | github.com/goreleaser/chglog v0.7.3 // indirect 181 | github.com/goreleaser/fileglob v1.4.0 // indirect 182 | github.com/goreleaser/nfpm/v2 v2.44.0 // indirect 183 | github.com/gorilla/websocket v1.5.3 // indirect 184 | github.com/hashicorp/errwrap v1.1.0 // indirect 185 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 186 | github.com/hashicorp/go-multierror v1.1.1 // indirect 187 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 188 | github.com/hashicorp/go-version v1.7.0 // indirect 189 | github.com/hashicorp/golang-lru v1.0.2 // indirect 190 | github.com/huandu/xstrings v1.5.0 // indirect 191 | github.com/in-toto/attestation v1.1.1 // indirect 192 | github.com/in-toto/in-toto-golang v0.9.0 // indirect 193 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 194 | github.com/invopop/jsonschema v0.13.0 // indirect 195 | github.com/ipfs/bbloom v0.0.4 // indirect 196 | github.com/ipfs/go-block-format v0.2.0 // indirect 197 | github.com/ipfs/go-cid v0.4.1 // indirect 198 | github.com/ipfs/go-datastore v0.6.0 // indirect 199 | github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 200 | github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 201 | github.com/ipfs/go-ipfs-util v0.0.3 // indirect 202 | github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 203 | github.com/ipfs/go-ipld-format v0.6.0 // indirect 204 | github.com/ipfs/go-log v1.0.5 // indirect 205 | github.com/ipfs/go-log/v2 v2.5.1 // indirect 206 | github.com/ipfs/go-metrics-interface v0.0.1 // indirect 207 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 208 | github.com/jbenet/goprocess v0.1.4 // indirect 209 | github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect 210 | github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect 211 | github.com/josharian/intern v1.0.0 // indirect 212 | github.com/kevinburke/ssh_config v1.2.0 // indirect 213 | github.com/klauspost/compress v1.18.2 // indirect 214 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 215 | github.com/klauspost/pgzip v1.2.6 // indirect 216 | github.com/kylelemons/godebug v1.1.0 // indirect 217 | github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 // indirect 218 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 219 | github.com/mailru/easyjson v0.9.0 // indirect 220 | github.com/mattn/go-isatty v0.0.20 // indirect 221 | github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect 222 | github.com/mattn/go-mastodon v0.0.10 // indirect 223 | github.com/mattn/go-runewidth v0.0.16 // indirect 224 | github.com/minio/sha256-simd v1.0.1 // indirect 225 | github.com/mitchellh/copystructure v1.2.0 // indirect 226 | github.com/mitchellh/go-homedir v1.1.0 // indirect 227 | github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect 228 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 229 | github.com/moby/docker-image-spec v1.3.1 // indirect 230 | github.com/modelcontextprotocol/registry v1.3.10 // indirect 231 | github.com/mr-tron/base58 v1.2.0 // indirect 232 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 233 | github.com/muesli/cancelreader v0.2.2 // indirect 234 | github.com/muesli/mango v0.2.0 // indirect 235 | github.com/muesli/mango-cobra v1.3.0 // indirect 236 | github.com/muesli/mango-pflag v0.1.0 // indirect 237 | github.com/muesli/roff v0.1.0 // indirect 238 | github.com/muesli/termenv v0.16.0 // indirect 239 | github.com/multiformats/go-base32 v0.1.0 // indirect 240 | github.com/multiformats/go-base36 v0.2.0 // indirect 241 | github.com/multiformats/go-multibase v0.2.0 // indirect 242 | github.com/multiformats/go-multihash v0.2.3 // indirect 243 | github.com/multiformats/go-varint v0.0.7 // indirect 244 | github.com/oklog/ulid v1.3.1 // indirect 245 | github.com/opencontainers/go-digest v1.0.0 // indirect 246 | github.com/opencontainers/image-spec v1.1.1 // indirect 247 | github.com/opentracing/opentracing-go v1.2.0 // indirect 248 | github.com/pborman/uuid v1.2.1 // indirect 249 | github.com/pelletier/go-toml v1.9.5 // indirect 250 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 251 | github.com/pjbgf/sha1cd v0.3.2 // indirect 252 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 253 | github.com/pkg/errors v0.9.1 // indirect 254 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 255 | github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 256 | github.com/rivo/uniseg v0.4.7 // indirect 257 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 258 | github.com/sagikazarmark/locafero v0.9.0 // indirect 259 | github.com/sassoftware/relic v7.2.1+incompatible // indirect 260 | github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect 261 | github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect 262 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 263 | github.com/shibumi/go-pathspec v1.3.0 // indirect 264 | github.com/shopspring/decimal v1.4.0 // indirect 265 | github.com/sigstore/cosign/v2 v2.5.0 // indirect 266 | github.com/sigstore/protobuf-specs v0.5.0 // indirect 267 | github.com/sigstore/rekor v1.4.1-0.20250814000724-cdd95725eb11 // indirect 268 | github.com/sigstore/sigstore v1.9.5 // indirect 269 | github.com/sigstore/sigstore-go v0.7.1 // indirect 270 | github.com/sigstore/timestamp-authority v1.2.5 // indirect 271 | github.com/sirupsen/logrus v1.9.3 // indirect 272 | github.com/skeema/knownhosts v1.3.1 // indirect 273 | github.com/slack-go/slack v0.17.3 // indirect 274 | github.com/sourcegraph/conc v0.3.0 // indirect 275 | github.com/spaolacci/murmur3 v1.1.0 // indirect 276 | github.com/spf13/afero v1.14.0 // indirect 277 | github.com/spf13/cast v1.7.1 // indirect 278 | github.com/spf13/cobra v1.10.2 // indirect 279 | github.com/spf13/pflag v1.0.9 // indirect 280 | github.com/spf13/viper v1.20.1 // indirect 281 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 282 | github.com/subosito/gotenv v1.6.0 // indirect 283 | github.com/theupdateframework/go-tuf v0.7.0 // indirect 284 | github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect 285 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect 286 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect 287 | github.com/transparency-dev/merkle v0.0.2 // indirect 288 | github.com/ulikunitz/xz v0.5.15 // indirect 289 | github.com/vbatts/tar-split v0.12.2 // indirect 290 | github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect 291 | github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c // indirect 292 | github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect 293 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 294 | github.com/xanzy/ssh-agent v0.3.3 // indirect 295 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 296 | github.com/zeebo/errs v1.4.0 // indirect 297 | gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect 298 | gitlab.com/gitlab-org/api/client-go v1.6.0 // indirect 299 | go.mongodb.org/mongo-driver v1.17.3 // indirect 300 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 301 | go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect 302 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect 303 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 304 | go.opentelemetry.io/otel v1.38.0 // indirect 305 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 306 | go.opentelemetry.io/otel/sdk v1.38.0 // indirect 307 | go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect 308 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 309 | go.uber.org/atomic v1.11.0 // indirect 310 | go.uber.org/multierr v1.11.0 // indirect 311 | go.uber.org/zap v1.27.0 // indirect 312 | go.yaml.in/yaml/v2 v2.4.2 // indirect 313 | go.yaml.in/yaml/v3 v3.0.4 // indirect 314 | gocloud.dev v0.44.0 // indirect 315 | golang.org/x/crypto v0.45.0 // indirect 316 | golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect 317 | golang.org/x/mod v0.30.0 // indirect 318 | golang.org/x/net v0.47.0 // indirect 319 | golang.org/x/oauth2 v0.33.0 // indirect 320 | golang.org/x/sync v0.18.0 // indirect 321 | golang.org/x/sys v0.38.0 // indirect 322 | golang.org/x/term v0.37.0 // indirect 323 | golang.org/x/text v0.31.0 // indirect 324 | golang.org/x/time v0.14.0 // indirect 325 | golang.org/x/tools v0.39.0 // indirect 326 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 327 | google.golang.org/api v0.247.0 // indirect 328 | google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect 329 | google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect 330 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect 331 | google.golang.org/grpc v1.75.0 // indirect 332 | google.golang.org/protobuf v1.36.10 // indirect 333 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 334 | gopkg.in/mail.v2 v2.3.1 // indirect 335 | gopkg.in/warnings.v0 v0.1.2 // indirect 336 | gopkg.in/yaml.v3 v3.0.1 // indirect 337 | k8s.io/klog/v2 v2.130.1 // indirect 338 | lukechampine.com/blake3 v1.2.1 // indirect 339 | sigs.k8s.io/kind v0.27.0 // indirect 340 | sigs.k8s.io/yaml v1.6.0 // indirect 341 | software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect 342 | ) 343 | -------------------------------------------------------------------------------- /mqtt/publish_discovery.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ben Hale 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package mqtt 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | 11 | "github.com/iancoleman/strcase" 12 | 13 | "github.com/nebhale/teslamate-discovery/ha" 14 | "github.com/nebhale/teslamate-discovery/units" 15 | ) 16 | 17 | func (m *MQTT) PublishDiscovery(ctx context.Context, id string, device ha.Device, haCfg ha.Config, 18 | unitsCfg units.Config) error { 19 | 20 | fmt.Printf("Configuring %s\n", device.Name) 21 | 22 | v := []interface{}{ 23 | 24 | // Charge 25 | ha.Sensor{ 26 | Device: device, 27 | DeviceClass: ha.Current, 28 | Name: "Charge Current Request", 29 | StateTopic: StateTopic(device, "/charge_current_request"), 30 | UniqueId: UniqueId(device, "/charge_current_request"), 31 | UnitOfMeasurement: "A", 32 | }, 33 | ha.Sensor{ 34 | Device: device, 35 | DeviceClass: ha.Current, 36 | Name: "Charge Current Request (Max)", 37 | StateTopic: StateTopic(device, "/charge_current_request_max"), 38 | UniqueId: UniqueId(device, "/charge_current_request_max"), 39 | UnitOfMeasurement: "A", 40 | }, 41 | ha.Sensor{ 42 | Device: device, 43 | DeviceClass: ha.Energy, 44 | Name: "Energy Added", 45 | StateTopic: StateTopic(device, "/charge_energy_added"), 46 | UniqueId: UniqueId(device, "/charge_energy_added"), 47 | UnitOfMeasurement: "kWh", 48 | ValueTemplate: units.RoundingValueTemplate, 49 | }, 50 | ha.Sensor{ 51 | Device: device, 52 | Icon: "mdi:battery-charging-90", 53 | Name: "Limit", 54 | StateClass: ha.Measurement, 55 | StateTopic: StateTopic(device, "/charge_limit_soc"), 56 | UniqueId: UniqueId(device, "/limit"), 57 | UnitOfMeasurement: "%", 58 | }, 59 | ha.Sensor{ 60 | Device: device, 61 | DeviceClass: ha.Current, 62 | Name: "Charger Current", 63 | StateTopic: StateTopic(device, "/charger_actual_current"), 64 | UniqueId: UniqueId(device, "/charger_current"), 65 | UnitOfMeasurement: "A", 66 | }, 67 | ha.BinarySensor{ 68 | Device: device, 69 | DeviceClass: ha.BatteryCharging, 70 | Name: "Charging", 71 | StateTopic: StateTopic(device, "/state"), 72 | UniqueId: UniqueId(device, "/charging"), 73 | ValueTemplate: `{{ "ON" if value == "charging" else "OFF" }}`, 74 | }, 75 | ha.BinarySensor{ 76 | Device: device, 77 | DeviceClass: ha.Plug, 78 | Name: "Plug", 79 | PayloadOff: "false", 80 | PayloadOn: "true", 81 | StateTopic: StateTopic(device, "/plugged_in"), 82 | UniqueId: UniqueId(device, "/plug"), 83 | }, 84 | ha.Sensor{ 85 | Device: device, 86 | Icon: "mdi:sine-wave", 87 | Name: "Charger Phases", 88 | StateTopic: StateTopic(device, "/charger_phases"), 89 | UniqueId: UniqueId(device, "/charger_phases"), 90 | }, 91 | ha.Sensor{ 92 | Device: device, 93 | DeviceClass: ha.Power, 94 | Name: "Charger Power", 95 | StateTopic: StateTopic(device, "/charger_power"), 96 | UniqueId: UniqueId(device, "/charger_power"), 97 | UnitOfMeasurement: "kW", 98 | }, 99 | ha.Sensor{ 100 | Device: device, 101 | DeviceClass: ha.Voltage, 102 | Name: "Charger Voltage", 103 | StateTopic: StateTopic(device, "/charger_voltage"), 104 | UniqueId: UniqueId(device, "/charger_voltage"), 105 | UnitOfMeasurement: "V", 106 | }, 107 | ha.Sensor{ 108 | Device: device, 109 | DeviceClass: ha.Timestamp, 110 | Name: "Scheduled Start Time", 111 | StateTopic: StateTopic(device, "/scheduled_charging_start_time"), 112 | UniqueId: UniqueId(device, "/start_time"), 113 | }, 114 | ha.Sensor{ 115 | Device: device, 116 | DeviceClass: ha.Duration, 117 | Icon: "mdi:timer", 118 | Name: "Time to Charged", 119 | StateTopic: StateTopic(device, "/time_to_full_charge"), 120 | UniqueId: UniqueId(device, "/time_to_charged"), 121 | UnitOfMeasurement: "h", 122 | }, 123 | 124 | // Climate 125 | ha.Sensor{ 126 | Device: device, 127 | DeviceClass: ha.Temperature, 128 | Name: "Inside Temp", 129 | StateClass: ha.Measurement, 130 | StateTopic: StateTopic(device, "/inside_temp"), 131 | UniqueId: UniqueId(device, "/inside_temp"), 132 | UnitOfMeasurement: "°C", 133 | ValueTemplate: units.RoundingValueTemplate, 134 | }, 135 | ha.BinarySensor{ 136 | Device: device, 137 | DeviceClass: ha.Running, 138 | Icon: "mdi:fan", 139 | Name: "Climate", 140 | PayloadOff: "false", 141 | PayloadOn: "true", 142 | StateTopic: StateTopic(device, "/is_climate_on"), 143 | UniqueId: UniqueId(device, "/climate"), 144 | }, 145 | ha.BinarySensor{ 146 | Device: device, 147 | DeviceClass: ha.Running, 148 | Icon: "mdi:fan", 149 | Name: "Preconditioning", 150 | PayloadOff: "false", 151 | PayloadOn: "true", 152 | StateTopic: StateTopic(device, "/is_preconditioning"), 153 | UniqueId: UniqueId(device, "/preconditioning"), 154 | }, 155 | ha.Sensor{ 156 | Device: device, 157 | DeviceClass: ha.Temperature, 158 | Name: "Outside Temp", 159 | StateClass: ha.Measurement, 160 | StateTopic: StateTopic(device, "/outside_temp"), 161 | UniqueId: UniqueId(device, "/outside_temp"), 162 | UnitOfMeasurement: "°C", 163 | ValueTemplate: units.RoundingValueTemplate, 164 | }, 165 | 166 | // Location 167 | ha.Sensor{ 168 | Device: device, 169 | Icon: "mdi:image-filter-hdr", 170 | Name: "Elevation", 171 | StateTopic: StateTopic(device, "/elevation"), 172 | UniqueId: UniqueId(device, "/elevation"), 173 | UnitOfMeasurement: unitsCfg.Distance.DistanceShortUnits(), 174 | ValueTemplate: unitsCfg.Distance.DistanceShortValueTemplate(), 175 | }, 176 | ha.Sensor{ 177 | Device: device, 178 | Icon: "mdi:earth", 179 | Name: "Geofence", 180 | StateTopic: StateTopic(device, "/geofence"), 181 | UniqueId: UniqueId(device, "/geofence"), 182 | }, 183 | ha.Sensor{ 184 | Device: device, 185 | Icon: "mdi:compass", 186 | Name: "Heading", 187 | StateTopic: StateTopic(device, "/heading"), 188 | UniqueId: UniqueId(device, "/heading"), 189 | UnitOfMeasurement: "°", 190 | }, 191 | ha.DeviceTracker{ 192 | Device: device, 193 | Icon: "mdi:car", 194 | Name: "", 195 | JSONAttributesTopic: StateTopic(device, "/location"), 196 | SourceType: "gps", 197 | StateTopic: StateTopic(device, "/location"), 198 | UniqueId: UniqueId(device, "/location"), 199 | ValueTemplate: fmt.Sprintf(`{{ "home" if "home" in (states("sensor.%s_geofence") | lower) else "not_home" }}`, strcase.ToSnake(device.Name)), 200 | }, 201 | ha.Sensor{ 202 | Device: device, 203 | DeviceClass: ha.Power, 204 | Name: "Power", 205 | StateTopic: StateTopic(device, "/power"), 206 | UniqueId: UniqueId(device, "/power"), 207 | UnitOfMeasurement: "kW", 208 | }, 209 | ha.Sensor{ 210 | Device: device, 211 | Icon: "mdi:speedometer", 212 | Name: "Speed", 213 | StateTopic: StateTopic(device, "/speed"), 214 | UniqueId: UniqueId(device, "/speed"), 215 | UnitOfMeasurement: unitsCfg.Distance.SpeedUnits(), 216 | ValueTemplate: unitsCfg.Distance.SpeedValueTemplate(), 217 | }, 218 | 219 | // State 220 | ha.Sensor{ 221 | Device: device, 222 | Icon: "mdi:format-color-fill", 223 | Name: "Exterior Color", 224 | StateTopic: StateTopic(device, "/exterior_color"), 225 | UniqueId: UniqueId(device, "/exterior_color"), 226 | }, 227 | ha.Sensor{ 228 | Device: device, 229 | Icon: "mdi:weather-windy", 230 | Name: "Spoiler Type", 231 | StateTopic: StateTopic(device, "/spoiler_type"), 232 | UniqueId: UniqueId(device, "/spoiler_type"), 233 | }, 234 | ha.Sensor{ 235 | Device: device, 236 | Icon: "mdi:form-textbox", 237 | Name: "Display Name", 238 | StateTopic: StateTopic(device, "/display_name"), 239 | UniqueId: UniqueId(device, "/display_name"), 240 | }, 241 | ha.Sensor{ 242 | Device: device, 243 | DeviceClass: ha.BatteryCharge, 244 | Name: "Battery", 245 | StateClass: ha.Measurement, 246 | StateTopic: StateTopic(device, "/battery_level"), 247 | UniqueId: UniqueId(device, "/battery"), 248 | UnitOfMeasurement: "%", 249 | }, 250 | ha.Sensor{ 251 | Device: device, 252 | DeviceClass: ha.BatteryCharge, 253 | Name: "Usable Battery", 254 | StateClass: ha.Measurement, 255 | StateTopic: StateTopic(device, "/usable_battery_level"), 256 | UniqueId: UniqueId(device, "/usable_battery"), 257 | UnitOfMeasurement: "%", 258 | }, 259 | ha.Sensor{ 260 | Device: device, 261 | Icon: "mdi:television", 262 | Name: "Center Display", 263 | StateTopic: StateTopic(device, "/center_display_state"), 264 | UniqueId: UniqueId(device, "/center_display"), 265 | ValueTemplate: `{% if value == "0" %}Off{% elif value == "2" %}Standby{% elif value == "3" %}Charging{% elif value == "4" %}On{% elif value == "5" %}Big Charging{% elif value == "6" %}Ready to Unlock{% elif value == "7" %}Sentry Mode{% elif value == "8" %}Dog Mode{% elif value == "9" %}Media{% else %}Unknown{% endif %}`, 266 | }, 267 | ha.BinarySensor{ 268 | Device: device, 269 | DeviceClass: ha.Door, 270 | Icon: "mdi:ev-plug-tesla", 271 | Name: "Charge Port", 272 | PayloadOff: "false", 273 | PayloadOn: "true", 274 | StateTopic: StateTopic(device, "/charge_port_door_open"), 275 | UniqueId: UniqueId(device, "/charge_port"), 276 | }, 277 | ha.BinarySensor{ 278 | Device: device, 279 | DeviceClass: ha.Door, 280 | Icon: "mdi:car-door", 281 | Name: "Doors", 282 | PayloadOff: "false", 283 | PayloadOn: "true", 284 | StateTopic: StateTopic(device, "/doors_open"), 285 | UniqueId: UniqueId(device, "/doors"), 286 | }, 287 | ha.BinarySensor{ 288 | Device: device, 289 | DeviceClass: ha.Door, 290 | Icon: "mdi:car", 291 | Name: "Door (Driver Front)", 292 | PayloadOff: "false", 293 | PayloadOn: "true", 294 | StateTopic: StateTopic(device, "/driver_front_door_open"), 295 | UniqueId: UniqueId(device, "/door_driver_front"), 296 | }, 297 | ha.BinarySensor{ 298 | Device: device, 299 | DeviceClass: ha.Door, 300 | Icon: "mdi:car", 301 | Name: "Door (Driver Rear)", 302 | PayloadOff: "false", 303 | PayloadOn: "true", 304 | StateTopic: StateTopic(device, "/driver_rear_door_open"), 305 | UniqueId: UniqueId(device, "/door_driver_rear"), 306 | }, 307 | ha.BinarySensor{ 308 | Device: device, 309 | DeviceClass: ha.Door, 310 | Icon: "mdi:car", 311 | Name: "Frunk", 312 | PayloadOff: "false", 313 | PayloadOn: "true", 314 | StateTopic: StateTopic(device, "/frunk_open"), 315 | UniqueId: UniqueId(device, "/frunk"), 316 | }, 317 | ha.BinarySensor{ 318 | Device: device, 319 | DeviceClass: ha.Problem, 320 | Icon: "mdi:heart-pulse", 321 | Name: "Health", 322 | PayloadOff: "true", 323 | PayloadOn: "false", 324 | StateTopic: StateTopic(device, "/healthy"), 325 | UniqueId: UniqueId(device, "/health"), 326 | }, 327 | ha.BinarySensor{ 328 | Device: device, 329 | DeviceClass: ha.Lock, 330 | Name: "Locked", 331 | PayloadOff: "true", 332 | PayloadOn: "false", 333 | StateTopic: StateTopic(device, "/locked"), 334 | UniqueId: UniqueId(device, "/locked"), 335 | }, 336 | ha.BinarySensor{ 337 | Device: device, 338 | DeviceClass: ha.Occupancy, 339 | Icon: "mdi:account", 340 | Name: "Occupied", 341 | PayloadOff: "false", 342 | PayloadOn: "true", 343 | StateTopic: StateTopic(device, "/is_user_present"), 344 | UniqueId: UniqueId(device, "/occupied"), 345 | }, 346 | ha.Sensor{ 347 | Device: device, 348 | Icon: "mdi:counter", 349 | Name: "Odometer", 350 | StateClass: ha.TotalIncreasing, 351 | StateTopic: StateTopic(device, "/odometer"), 352 | UniqueId: UniqueId(device, "/odometer"), 353 | UnitOfMeasurement: unitsCfg.Distance.DistanceLongUnits(), 354 | ValueTemplate: unitsCfg.Distance.DistanceLongValueTemplate(), 355 | }, 356 | ha.BinarySensor{ 357 | Device: device, 358 | DeviceClass: ha.Door, 359 | Icon: "mdi:car", 360 | Name: "Door (Passenger Front)", 361 | PayloadOff: "false", 362 | PayloadOn: "true", 363 | StateTopic: StateTopic(device, "/passenger_front_door_open"), 364 | UniqueId: UniqueId(device, "/door_passenger_front"), 365 | }, 366 | ha.BinarySensor{ 367 | Device: device, 368 | DeviceClass: ha.Door, 369 | Icon: "mdi:car", 370 | Name: "Door (Passenger Rear)", 371 | PayloadOff: "false", 372 | PayloadOn: "true", 373 | StateTopic: StateTopic(device, "/passenger_rear_door_open"), 374 | UniqueId: UniqueId(device, "/door_passenger_rear"), 375 | }, 376 | ha.Sensor{ 377 | Device: device, 378 | Icon: "mdi:map-marker-distance", 379 | Name: "Range", 380 | StateClass: ha.Measurement, 381 | StateTopic: StateTopic(device, fmt.Sprintf(`/%s_battery_range_km`, unitsCfg.RangeType.Prefix())), 382 | UniqueId: UniqueId(device, "/range"), 383 | UnitOfMeasurement: unitsCfg.Distance.DistanceLongUnits(), 384 | ValueTemplate: unitsCfg.Distance.DistanceLongValueTemplate(), 385 | }, 386 | ha.BinarySensor{ 387 | Device: device, 388 | Icon: "mdi:cctv", 389 | Name: "Sentry Mode", 390 | PayloadOff: "false", 391 | PayloadOn: "true", 392 | StateTopic: StateTopic(device, "/sentry_mode"), 393 | UniqueId: UniqueId(device, "/sentry_mode"), 394 | }, 395 | ha.Sensor{ 396 | Device: device, 397 | Icon: "mdi:car-shift-pattern", 398 | Name: "Shift State", 399 | StateTopic: StateTopic(device, "/shift_state"), 400 | UniqueId: UniqueId(device, "/shift_state"), 401 | }, 402 | ha.Sensor{ 403 | Device: device, 404 | Icon: "mdi:car-connected", 405 | Name: "State", 406 | StateTopic: StateTopic(device, "/state"), 407 | UniqueId: UniqueId(device, "/state"), 408 | }, 409 | ha.Sensor{ 410 | Device: device, 411 | Icon: "mdi:timer-sand", 412 | Name: "Last Seen", 413 | StateTopic: StateTopic(device, "/since"), 414 | UniqueId: UniqueId(device, "/since"), 415 | }, 416 | ha.Sensor{ 417 | Device: device, 418 | DeviceClass: ha.Pressure, 419 | Icon: "mdi:gauge", 420 | Name: "Tire Pressure (Front Left)", 421 | StateClass: ha.Measurement, 422 | StateTopic: StateTopic(device, "/tpms_pressure_fl"), 423 | UniqueId: UniqueId(device, "/tire_pressure_front_left"), 424 | UnitOfMeasurement: unitsCfg.Pressure.PressureUnits(), 425 | ValueTemplate: unitsCfg.Pressure.PressureValueTemplate(), 426 | }, 427 | ha.Sensor{ 428 | Device: device, 429 | DeviceClass: ha.Pressure, 430 | Icon: "mdi:gauge", 431 | Name: "Tire Pressure (Front Right)", 432 | StateClass: ha.Measurement, 433 | StateTopic: StateTopic(device, "/tpms_pressure_fr"), 434 | UniqueId: UniqueId(device, "/tire_pressure_front_right"), 435 | UnitOfMeasurement: unitsCfg.Pressure.PressureUnits(), 436 | ValueTemplate: unitsCfg.Pressure.PressureValueTemplate(), 437 | }, 438 | ha.Sensor{ 439 | Device: device, 440 | DeviceClass: ha.Pressure, 441 | Icon: "mdi:gauge", 442 | Name: "Tire Pressure (Rear Left)", 443 | StateClass: ha.Measurement, 444 | StateTopic: StateTopic(device, "/tpms_pressure_rl"), 445 | UniqueId: UniqueId(device, "/tire_pressure_rear_left"), 446 | UnitOfMeasurement: unitsCfg.Pressure.PressureUnits(), 447 | ValueTemplate: unitsCfg.Pressure.PressureValueTemplate(), 448 | }, 449 | ha.Sensor{ 450 | Device: device, 451 | DeviceClass: ha.Pressure, 452 | Icon: "mdi:gauge", 453 | Name: "Tire Pressure (Rear Right)", 454 | StateClass: ha.Measurement, 455 | StateTopic: StateTopic(device, "/tpms_pressure_rr"), 456 | UniqueId: UniqueId(device, "/tire_pressure_rear_right"), 457 | UnitOfMeasurement: unitsCfg.Pressure.PressureUnits(), 458 | ValueTemplate: unitsCfg.Pressure.PressureValueTemplate(), 459 | }, 460 | ha.BinarySensor{ 461 | Device: device, 462 | DeviceClass: ha.Problem, 463 | Icon: "mdi:car-tire-alert", 464 | Name: "Tire Soft (Front Left)", 465 | PayloadOff: "false", 466 | PayloadOn: "true", 467 | StateTopic: StateTopic(device, "/tpms_soft_warning_fl"), 468 | UniqueId: UniqueId(device, "/tire_soft_front_left"), 469 | }, 470 | ha.BinarySensor{ 471 | Device: device, 472 | DeviceClass: ha.Problem, 473 | Icon: "mdi:car-tire-alert", 474 | Name: "Tire Soft (Front Right)", 475 | PayloadOff: "false", 476 | PayloadOn: "true", 477 | StateTopic: StateTopic(device, "/tpms_soft_warning_fr"), 478 | UniqueId: UniqueId(device, "/tire_soft_front_right"), 479 | }, 480 | ha.BinarySensor{ 481 | Device: device, 482 | DeviceClass: ha.Problem, 483 | Icon: "mdi:car-tire-alert", 484 | Name: "Tire Soft (Rear Left)", 485 | PayloadOff: "false", 486 | PayloadOn: "true", 487 | StateTopic: StateTopic(device, "/tpms_soft_warning_rl"), 488 | UniqueId: UniqueId(device, "/tire_soft_rear_left"), 489 | }, 490 | ha.BinarySensor{ 491 | Device: device, 492 | DeviceClass: ha.Problem, 493 | Icon: "mdi:car-tire-alert", 494 | Name: "Tire Soft (Rear Right)", 495 | PayloadOff: "false", 496 | PayloadOn: "true", 497 | StateTopic: StateTopic(device, "/tpms_soft_warning_rr"), 498 | UniqueId: UniqueId(device, "/tire_soft_rear_right"), 499 | }, 500 | ha.BinarySensor{ 501 | Device: device, 502 | DeviceClass: ha.Door, 503 | Icon: "mdi:car", 504 | Name: "Trunk", 505 | PayloadOff: "false", 506 | PayloadOn: "true", 507 | StateTopic: StateTopic(device, "/trunk_open"), 508 | UniqueId: UniqueId(device, "/trunk"), 509 | }, 510 | ha.BinarySensor{ 511 | Device: device, 512 | DeviceClass: ha.Update, 513 | Name: "Update", 514 | PayloadOff: "false", 515 | PayloadOn: "true", 516 | StateTopic: StateTopic(device, "/update_available"), 517 | UniqueId: UniqueId(device, "/update"), 518 | }, 519 | ha.BinarySensor{ 520 | Device: device, 521 | DeviceClass: ha.Window, 522 | Icon: "mdi:car-door", 523 | Name: "Windows", 524 | PayloadOff: "false", 525 | PayloadOn: "true", 526 | StateTopic: StateTopic(device, "/windows_open"), 527 | UniqueId: UniqueId(device, "/windows"), 528 | }, 529 | ha.Sensor{ 530 | Device: device, 531 | Icon: "mdi:numeric", 532 | Name: "Version", 533 | StateTopic: StateTopic(device, "/version"), 534 | UniqueId: UniqueId(device, "/version"), 535 | }, 536 | } 537 | 538 | return m.Publish(ctx, haCfg.DiscoveryPrefix, v...) 539 | } 540 | 541 | func StateTopic(device ha.Device, suffix string) string { 542 | return fmt.Sprintf("%s%s", device.Identifiers[0], suffix) 543 | } 544 | 545 | func UniqueId(device ha.Device, suffix string) string { 546 | return fmt.Sprintf("%s%s", strings.ReplaceAll(device.Identifiers[0], "/", "_"), suffix) 547 | } 548 | --------------------------------------------------------------------------------