Go port of the read path of pybyd 0.0.75 (the client behind jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the signing/checkcode layer, login, vehicle list, and realtime readings. Scraping runs on a cron schedule rather than per Prometheus request because each reading wakes the car's T-Box and drains the traction battery; /metrics serves the last result. Every numeric field of the realtime payload becomes a gauge, so the exporter does not need a hand-maintained field list that goes stale when BYD changes the payload. Tests pin the wire format against pybyd byte-for-byte and drive the client end to end against a fake cloud that speaks the real protocol. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
commit
52e1aaa0c0
30 files changed
+3501
No files matched your search
@@ -0,0 +1,165 @@
|
||||
// Package metrics renders a Prometheus text exposition by hand.
|
||||
//
|
||||
// A client library would buy registration, collectors and concurrency handling
|
||||
// that a snapshot of ~200 gauges does not need — the whole format is three line
|
||||
// shapes, and this way the metric names can come from the payload at runtime.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
|
||||
)
|
||||
|
||||
// Labels is a metric's label set. Empty values are dropped on render.
|
||||
type Labels map[string]string
|
||||
|
||||
type sample struct {
|
||||
labels Labels
|
||||
value float64
|
||||
}
|
||||
|
||||
// Set is one scrape's worth of gauges, grouped by metric name so that a name
|
||||
// shared across vehicles emits a single TYPE line.
|
||||
type Set struct {
|
||||
byName map[string][]sample
|
||||
}
|
||||
|
||||
func New() *Set { return &Set{byName: map[string][]sample{}} }
|
||||
|
||||
func (s *Set) Add(name string, labels Labels, value float64) {
|
||||
s.byName[name] = append(s.byName[name], sample{labels, value})
|
||||
}
|
||||
|
||||
// AddPayload emits every numeric field of a BYD payload as prefix + snake(key).
|
||||
//
|
||||
// Nothing here is a hand-maintained field list: BYD changes the realtime
|
||||
// payload between firmware versions, and new fields should appear on their own.
|
||||
// Non-numeric fields (strings, nested objects) are skipped — a metric can only
|
||||
// hold a float.
|
||||
func (s *Set) AddPayload(prefix string, labels Labels, payload map[string]any) {
|
||||
for k, v := range payload {
|
||||
f, ok := jsonv.Num(v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name := prefix + Snake(k); ValidName(name) {
|
||||
s.Add(name, labels, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len reports how many samples the set holds.
|
||||
func (s *Set) Len() int {
|
||||
n := 0
|
||||
for _, samples := range s.byName {
|
||||
n += len(samples)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// String renders the exposition. Names are sorted, and samples within a name
|
||||
// are sorted by vin, so the output is stable between scrapes and diffable.
|
||||
func (s *Set) String() string {
|
||||
names := make([]string, 0, len(s.byName))
|
||||
for n := range s.byName {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, "# TYPE %s gauge\n", n)
|
||||
samples := s.byName[n]
|
||||
sort.SliceStable(samples, func(i, j int) bool {
|
||||
return samples[i].labels["vin"] < samples[j].labels["vin"]
|
||||
})
|
||||
for _, sm := range samples {
|
||||
b.WriteString(n)
|
||||
b.WriteString(formatLabels(sm.labels))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(formatValue(sm.value))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatValue keeps whole numbers readable: a unix timestamp should render as
|
||||
// 1757000000, not 1.757e+09. Both are valid Prometheus, one is greppable.
|
||||
func formatValue(v float64) string {
|
||||
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func formatLabels(labels Labels) string {
|
||||
keys := make([]string, 0, len(labels))
|
||||
for k, v := range labels {
|
||||
if v != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
// Quotes are added here rather than with %q, which would escape the
|
||||
// backslashes escapeLabel just added.
|
||||
parts = append(parts, k+`="`+escapeLabel(labels[k])+`"`)
|
||||
}
|
||||
return "{" + strings.Join(parts, ",") + "}"
|
||||
}
|
||||
|
||||
func escapeLabel(v string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(v)
|
||||
}
|
||||
|
||||
// ValidName reports whether s is a legal Prometheus metric name.
|
||||
func ValidName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, c := range s {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
|
||||
case c >= '0' && c <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Snake converts camelCase to snake_case: "leftFrontTirepressure" becomes
|
||||
// "left_front_tirepressure" and "evSOC" becomes "ev_soc".
|
||||
func Snake(s string) string {
|
||||
var b strings.Builder
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
isUpper := r >= 'A' && r <= 'Z'
|
||||
if isUpper && i > 0 {
|
||||
prev := runes[i-1]
|
||||
prevLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9')
|
||||
// A capital starting a new word inside a run of capitals, as the C
|
||||
// in "evSOCValue".
|
||||
capThenWord := prev >= 'A' && prev <= 'Z' && i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
|
||||
if prevLowerOrDigit || capThenWord {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
if isUpper {
|
||||
r += 'a' - 'A'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnake(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"soc": "soc",
|
||||
"enduranceMileage": "endurance_mileage",
|
||||
"leftFrontTirepressure": "left_front_tirepressure",
|
||||
"mainSettingTempNew": "main_setting_temp_new",
|
||||
"evSOC": "ev_soc",
|
||||
"evSOCValue": "ev_soc_value",
|
||||
"tboxVersion": "tbox_version",
|
||||
"gear2State": "gear2_state",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := Snake(in); got != want {
|
||||
t.Errorf("Snake(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidName(t *testing.T) {
|
||||
valid := []string{"byd_soc", "_x", "a1"}
|
||||
invalid := []string{"", "1abc", "byd-soc", "byd soc", "byd.soc"}
|
||||
for _, s := range valid {
|
||||
if !ValidName(s) {
|
||||
t.Errorf("ValidName(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if ValidName(s) {
|
||||
t.Errorf("ValidName(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPayloadCoercion covers the shapes BYD actually sends: JSON numbers,
|
||||
// numbers quoted as strings, booleans, and strings that are not numbers at all.
|
||||
func TestAddPayloadCoercion(t *testing.T) {
|
||||
var payload map[string]any
|
||||
raw := `{"elecPercent":82,"speed":"13.5","charging":true,"vin":"LC0","nested":{"a":1},"missing":null}`
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New()
|
||||
s.AddPayload("byd_", Labels{"vin": "LC0"}, payload)
|
||||
|
||||
want := map[string]float64{
|
||||
"byd_elec_percent": 82,
|
||||
"byd_speed": 13.5,
|
||||
"byd_charging": 1,
|
||||
}
|
||||
for name, value := range want {
|
||||
samples := s.byName[name]
|
||||
if len(samples) != 1 {
|
||||
t.Errorf("%s: got %d samples, want 1", name, len(samples))
|
||||
continue
|
||||
}
|
||||
if samples[0].value != value {
|
||||
t.Errorf("%s = %v, want %v", name, samples[0].value, value)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"byd_vin", "byd_nested", "byd_missing"} {
|
||||
if _, ok := s.byName[name]; ok {
|
||||
t.Errorf("%s should not have become a metric", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringFormat(t *testing.T) {
|
||||
s := New()
|
||||
s.Add("byd_soc", Labels{"vin": "B", "name": "Second"}, 50)
|
||||
s.Add("byd_soc", Labels{"vin": "A", "name": "First"}, 82)
|
||||
s.Add("byd_scrape_success", nil, 1)
|
||||
|
||||
got := s.String()
|
||||
want := strings.Join([]string{
|
||||
"# TYPE byd_scrape_success gauge",
|
||||
"byd_scrape_success 1",
|
||||
"# TYPE byd_soc gauge",
|
||||
`byd_soc{name="First",vin="A"} 82`,
|
||||
`byd_soc{name="Second",vin="B"} 50`,
|
||||
"",
|
||||
}, "\n")
|
||||
if got != want {
|
||||
t.Errorf("String() =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringEmitsOneTypeLinePerName guards the rule that repeating a TYPE line
|
||||
// for the same metric makes the exposition invalid for strict parsers.
|
||||
func TestStringEmitsOneTypeLinePerName(t *testing.T) {
|
||||
s := New()
|
||||
for _, vin := range []string{"A", "B", "C"} {
|
||||
s.Add("byd_soc", Labels{"vin": vin}, 1)
|
||||
}
|
||||
if n := strings.Count(s.String(), "# TYPE byd_soc gauge"); n != 1 {
|
||||
t.Errorf("got %d TYPE lines for byd_soc, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringIsStable(t *testing.T) {
|
||||
build := func() string {
|
||||
s := New()
|
||||
s.AddPayload("byd_", Labels{"vin": "A"}, map[string]any{
|
||||
"soc": 1.0, "range": 2.0, "speed": 3.0, "temp": 4.0,
|
||||
})
|
||||
return s.String()
|
||||
}
|
||||
// Map iteration order differs per run; the rendered output must not.
|
||||
first := build()
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := build(); got != first {
|
||||
t.Fatalf("output is not stable across runs:\n%s\nvs\n%s", got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelEscaping(t *testing.T) {
|
||||
s := New()
|
||||
s.Add("byd_x", Labels{"name": `He said "hi"\`, "empty": ""}, 1)
|
||||
|
||||
got := s.String()
|
||||
if !strings.Contains(got, `name="He said \"hi\"\\"`) {
|
||||
t.Errorf("quotes and backslashes not escaped: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "empty") {
|
||||
t.Errorf("empty labels should be dropped: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLen(t *testing.T) {
|
||||
s := New()
|
||||
if s.Len() != 0 {
|
||||
t.Errorf("Len() = %d on a new set, want 0", s.Len())
|
||||
}
|
||||
s.Add("a", nil, 1)
|
||||
s.Add("a", Labels{"vin": "x"}, 2)
|
||||
s.Add("b", nil, 3)
|
||||
if s.Len() != 3 {
|
||||
t.Errorf("Len() = %d, want 3", s.Len())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user