diff --git a/CHANGELOG.md b/CHANGELOG.md index 3393187..8632fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes are listed here. `Unreleased` is what `main` and the Docker ### Added +- Vehicle status on the dashboard: connection and sleep state, battery and charging, climate, lock state, last contact and last error per vehicle, each with the time its data is from. It updates itself every 10 seconds from what the proxy already knows and never contacts the vehicle on its own; **Refresh** fetches current data without waking it, **Wake & refresh** wakes it first. New endpoints `GET /api/proxy/1/status` and `POST /api/proxy/1/status/refresh`. ([#168](https://github.com/wimaha/TeslaBleHttpProxy/issues/168)) - `trustedOrigins` environment variable for reverse proxies over plain HTTP that rewrite the `Host` header, and for web pages on other origins that call the API. See [environment variables](docs/environment_variables.md#trustedorigins). ([#170](https://github.com/wimaha/TeslaBleHttpProxy/pull/170)) - Docker image `:dev`, built from `main` on every push. ([#171](https://github.com/wimaha/TeslaBleHttpProxy/pull/171)) - Docker image `:pr-` for every pull request from a branch of this repository, to test a change on a car before it is merged. See [Test images for pull requests](README.md#test-images-for-pull-requests). diff --git a/README.md b/README.md index 7a7153b..6f0d501 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The program stores the received requests in a queue and processes them one by on - [Vehicle Data](#vehicle-data) - [Body Controller State](#body-controller-state) - [Version of Proxy](#version-of-proxy) + - [Status of Proxy and Vehicles](#status-of-proxy-and-vehicles) - [Troubleshooting](#troubleshooting) ## How to install @@ -275,6 +276,20 @@ Get version of proxy: The response will contain the version of the proxy. +### Status of Proxy and Vehicles + +*(unreleased)* + +The dashboard (`http://localhost:8080/dashboard`) shows the status of the proxy and of each vehicle it has talked to: connection, sleep state, battery and charging, climate, lock state, and the last successful contact and last error. Every block shows when its data was fetched. Opening or auto-updating the dashboard never contacts the vehicle; it only shows what the proxy already knows, for example from evcc polling `vehicle_data`. **Refresh** fetches current data without waking the vehicle; **Wake & refresh** wakes it first (after a confirmation). + +The same information as JSON, without contacting the vehicle: +`http://localhost:8080/api/proxy/1/status` + +To fetch current `charge_state` and `climate_state` for the dashboard (bypasses the cache, the result is cached like any `vehicle_data` response): +`POST http://localhost:8080/api/proxy/1/status/refresh` with form fields `vin` and `wakeup` (`true` wakes the vehicle if it is asleep, default `false`). + +**No login:** like the rest of the API, the dashboard and these endpoints have no authentication. Anyone who can reach the proxy on your network can see the VIN, battery, charging and climate data, and can wake the vehicle or send commands. Only websites opened in a browser are blocked from doing this (see [Dashboard buttons return 403 Forbidden](#dashboard-buttons-return-403-forbidden)). Run the proxy only in a network you trust, and do not expose its port to the internet; if you need remote access, put it behind a VPN or a reverse proxy with authentication. + ## Troubleshooting ### Vehicle Requirements diff --git a/html/dashboard.html b/html/dashboard.html index 47895df..7c093e9 100644 --- a/html/dashboard.html +++ b/html/dashboard.html @@ -13,6 +13,11 @@

TeslaBleHttpProxy

View Logs +

Vehicle Status

+

Shows what the proxy already knows; updating this page never contacts the vehicle. Refresh fetches current data without waking the vehicle.

+
+ +

Key Management

You can generate multiple keys with different roles. The active key is used for all vehicle operations.

@@ -120,4 +125,5 @@

Setup Vehicle

return confirm(message); } + {{end}} diff --git a/internal/api/handlers/status.go b/internal/api/handlers/status.go new file mode 100644 index 0000000..ffc33e0 --- /dev/null +++ b/internal/api/handlers/status.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "regexp" + "slices" + "strings" + "time" + + "github.com/wimaha/TeslaBleHttpProxy/config" + "github.com/wimaha/TeslaBleHttpProxy/internal/api/models" + "github.com/wimaha/TeslaBleHttpProxy/internal/ble/control" + "github.com/wimaha/TeslaBleHttpProxy/internal/logging" +) + +// The dashboard status is assembled from what the proxy already knows: the command loop's view of +// each connection and the cached responses. Reading it never contacts the vehicle; only the +// refresh endpoint does, through the command queue. + +var startTime = time.Now() + +// statusEndpoints are the cached vehicle_data endpoints the dashboard shows. Location and drive +// data are left out on purpose, even when cached. +var statusEndpoints = []string{"charge_state", "climate_state"} + +type proxyStatus struct { + Version string `json:"version"` + StartedAt time.Time `json:"started_at"` + UptimeSeconds int64 `json:"uptime_seconds"` + ActiveKeyRole string `json:"active_key_role"` + BleControlRunning bool `json:"ble_control_running"` + QueueLength int `json:"queue_length"` + ConnectionTimeout int `json:"connection_timeout"` + ReleaseAdapterWhenIdle bool `json:"release_adapter_when_idle"` + VehicleDataCacheTime int `json:"vehicle_data_cache_time"` +} + +type vehicleStatus struct { + control.VehicleConnection + ChargeState *cachedEntry `json:"charge_state"` + ClimateState *cachedEntry `json:"climate_state"` + BodyController *cachedEntry `json:"body_controller"` +} + +type statusResponse struct { + Proxy proxyStatus `json:"proxy"` + Vehicles []vehicleStatus `json:"vehicles"` + Now time.Time `json:"now"` +} + +func buildStatus() statusResponse { + bc := control.Instance() + proxy := proxyStatus{ + Version: config.Version, + StartedAt: startTime, + UptimeSeconds: int64(time.Since(startTime).Seconds()), + ActiveKeyRole: control.GetActiveKeyRole(), + BleControlRunning: bc != nil, + } + if config.AppConfig != nil { + proxy.ConnectionTimeout = config.AppConfig.ConnectionTimeout + proxy.ReleaseAdapterWhenIdle = config.AppConfig.ReleaseAdapterWhenIdle + proxy.VehicleDataCacheTime = config.AppConfig.VehicleDataCacheTime + } + + byVin := map[string]*vehicleStatus{} + vehicle := func(vin string) *vehicleStatus { + if v, ok := byVin[vin]; ok { + return v + } + v := &vehicleStatus{VehicleConnection: control.VehicleConnection{Vin: vin}} + byVin[vin] = v + return v + } + if bc != nil { + proxy.QueueLength = bc.QueueLength() + for _, c := range bc.Vehicles() { + vehicle(c.Vin).VehicleConnection = c + } + } + for _, vin := range cachedVins() { + v := vehicle(vin) + v.ChargeState = latestVehicleData(vin, statusEndpoints[0]) + v.ClimateState = latestVehicleData(vin, statusEndpoints[1]) + v.BodyController = latestBodyControllerState(vin) + } + + vehicles := make([]vehicleStatus, 0, len(byVin)) + for _, v := range byVin { + vehicles = append(vehicles, *v) + } + slices.SortFunc(vehicles, func(a, b vehicleStatus) int { return strings.Compare(a.Vin, b.Vin) }) + return statusResponse{Proxy: proxy, Vehicles: vehicles, Now: time.Now()} +} + +// Status answers GET /api/proxy/1/status for the dashboard. It never contacts the vehicle. +func Status(w http.ResponseWriter, r *http.Request) { + data, err := json.Marshal(buildStatus()) + response := models.Ret{Response: models.Response{Result: err == nil, Reason: "The request was successfully processed.", Response: data}} + if err != nil { + logging.Error("Failed to build status", "error", err) + response.Response.Reason = err.Error() + response.Response.Response = nil + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(response); err != nil { + logging.Error("failed to send response", "error", err) + } +} + +var vinPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`) + +// RefreshStatus answers POST /api/proxy/1/status/refresh (form fields vin, wakeup). It fetches +// the dashboard's vehicle data over BLE through the command queue, bypassing the cache, and +// wakes the vehicle only if wakeup=true. The result is cached like any vehicle_data response. +func RefreshStatus(w http.ResponseWriter, r *http.Request) { + vin := strings.ToUpper(strings.TrimSpace(r.FormValue("vin"))) + wakeup := r.FormValue("wakeup") == "true" + + var response models.Response + response.Vin = vin + response.Command = "vehicle_data" + defer commonDefer(w, &response) + logging.Info("Dashboard refresh", "VIN", vin, "Wakeup", wakeup) + + if !vinPattern.MatchString(vin) { + response.Reason = "A VIN has 17 characters (letters and digits, no I, O or Q)." + return + } + bc := bleControl(&response) + if bc == nil { + return + } + fetchVehicleData(r.Context(), bc, vin, statusEndpoints, 0, wakeup, &response) +} diff --git a/internal/api/handlers/status_e2e_test.go b/internal/api/handlers/status_e2e_test.go new file mode 100644 index 0000000..2668372 --- /dev/null +++ b/internal/api/handlers/status_e2e_test.go @@ -0,0 +1,224 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/wimaha/TeslaBleHttpProxy/internal/api/models" +) + +// The dashboard status: reading it never contacts the car, refreshing goes through the queue and +// wakes the car only on request. + +type statusVehicle struct { + Vin string `json:"vin"` + SleepStatus string `json:"sleep_status"` + LastSuccess *struct{ Command string } `json:"last_success"` + Fields map[string]json.RawMessage `json:"-"` +} + +func (p *proxy) status() (map[string]json.RawMessage, []statusVehicle) { + p.t.Helper() + code, resp := p.do("GET", "/api/proxy/1/status", "") + wantStatus(p.t, code, resp, http.StatusOK) + var raw struct { + Proxy map[string]json.RawMessage `json:"proxy"` + Vehicles []map[string]json.RawMessage `json:"vehicles"` + } + if err := json.Unmarshal(resp.Response, &raw); err != nil { + p.t.Fatalf("status %s: %v", resp.Response, err) + } + vehicles := make([]statusVehicle, len(raw.Vehicles)) + for i, fields := range raw.Vehicles { + b, _ := json.Marshal(fields) + if err := json.Unmarshal(b, &vehicles[i]); err != nil { + p.t.Fatal(err) + } + vehicles[i].Fields = fields + } + return raw.Proxy, vehicles +} + +func (p *proxy) vehicle(vin string) *statusVehicle { + p.t.Helper() + _, vehicles := p.status() + for i := range vehicles { + if vehicles[i].Vin == vin { + return &vehicles[i] + } + } + return nil +} + +func (p *proxy) refresh(vin string, wakeup bool, headers map[string]string) (int, models.Response) { + p.t.Helper() + form := url.Values{"vin": {vin}, "wakeup": {map[bool]string{true: "true", false: "false"}[wakeup]}} + req, err := http.NewRequest("POST", p.server.URL+"/api/proxy/1/status/refresh", strings.NewReader(form.Encode())) + if err != nil { + p.t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + p.t.Fatal(err) + } + defer res.Body.Close() + var ret models.Ret + if res.StatusCode != http.StatusForbidden { + if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { + p.t.Fatalf("decoding refresh answer: %v", err) + } + } + return res.StatusCode, ret.Response +} + +func TestHTTPStatusNeverContactsCar(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + + proxy, _ := p.status() + if string(proxy["ble_control_running"]) != "true" { + t.Errorf("ble_control_running = %s, want true", proxy["ble_control_running"]) + } + if v := p.vehicle(vin); v != nil { + t.Errorf("unknown vehicle listed: %+v", v) + } + if n := len(p.car.Calls()); n != 0 { + t.Errorf("car got %v, want no contact", p.car.Methods()) + } +} + +func TestHTTPStatusShowsCachedDataWithoutLocation(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + p.car.BatteryLevel = 64 + + code, resp := p.do("GET", "/api/1/vehicles/"+vin+"/vehicle_data?endpoints=charge_state;climate_state;drive_state", "") + wantStatus(t, code, resp, http.StatusOK) + calls := len(p.car.Calls()) + + v := p.vehicle(vin) + if v == nil { + t.Fatal("vehicle missing from status") + } + var charge struct { + Data models.ChargeState `json:"data"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal(v.Fields["charge_state"], &charge); err != nil { + t.Fatalf("charge_state %s: %v", v.Fields["charge_state"], err) + } + if charge.Data.BatteryLevel != 64 || charge.UpdatedAt == "" { + t.Errorf("charge_state = %+v, want battery 64 with updated_at", charge) + } + if string(v.Fields["climate_state"]) == "null" { + t.Error("climate_state missing") + } + if _, ok := v.Fields["drive_state"]; ok { + t.Error("drive_state must not be part of the status") + } + if v.LastSuccess == nil || v.LastSuccess.Command != "vehicle_data" { + t.Errorf("last_success = %+v, want vehicle_data", v.LastSuccess) + } + if n := len(p.car.Calls()); n != calls { + t.Errorf("reading the status contacted the car: %v", p.car.Methods()[calls:]) + } +} + +func TestHTTPStatusShowsBodyControllerState(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + code, resp := p.do("GET", "/api/1/vehicles/"+vin+"/body_controller_state", "") + wantStatus(t, code, resp, http.StatusOK) + + v := p.vehicle(vin) + if v == nil || string(v.Fields["body_controller"]) == "null" { + t.Fatalf("body_controller missing from status: %+v", v) + } + if !strings.Contains(string(v.Fields["body_controller"]), "VEHICLELOCKSTATE_LOCKED") { + t.Errorf("body_controller = %s", v.Fields["body_controller"]) + } +} + +func TestHTTPRefreshDoesNotWakeSleepingCar(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + p.car.Asleep = true + + code, resp := p.refresh(vin, false, nil) + + wantStatus(t, code, resp, http.StatusServiceUnavailable) + if !strings.Contains(resp.Reason, "sleeping") { + t.Errorf("reason = %q, want sleeping", resp.Reason) + } + if n := p.car.Count("Wakeup"); n != 0 { + t.Errorf("Wakeup count = %d, want 0", n) + } + if v := p.vehicle(vin); v == nil || v.SleepStatus != "asleep" { + t.Errorf("status = %+v, want sleep_status asleep", v) + } +} + +func TestHTTPRefreshWithWakeup(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + p.car.Asleep = true + + code, resp := p.refresh(vin, true, nil) + + wantStatus(t, code, resp, http.StatusOK) + if n := p.car.Count("Wakeup"); n != 1 { + t.Errorf("Wakeup count = %d, want 1", n) + } + v := p.vehicle(vin) + if v == nil || string(v.Fields["charge_state"]) == "null" || v.SleepStatus != "awake" { + t.Errorf("status after refresh = %+v", v) + } +} + +// Refresh always asks the car, even when the cache is fresh. +func TestHTTPRefreshBypassesCache(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + code, resp := p.do("GET", "/api/1/vehicles/"+vin+"/vehicle_data", "") + wantStatus(t, code, resp, http.StatusOK) + before := p.car.Count("GetState") + + code, resp = p.refresh(vin, false, nil) + + wantStatus(t, code, resp, http.StatusOK) + if p.car.Count("GetState") == before { + t.Error("refresh was answered from the cache") + } +} + +func TestHTTPRefreshRejects(t *testing.T) { + vin := newVin() + p := startProxy(t, vin) + + code, resp := p.refresh("NOTAVIN", false, nil) + wantStatus(t, code, resp, http.StatusServiceUnavailable) + if !strings.Contains(resp.Reason, "17 characters") { + t.Errorf("reason = %q", resp.Reason) + } + if code, _ := p.refresh(vin, true, map[string]string{"Sec-Fetch-Site": "cross-site"}); code != http.StatusForbidden { + t.Errorf("cross-site refresh: status %d, want 403", code) + } + res, err := http.Get(p.server.URL + "/api/proxy/1/status/refresh") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET refresh: status %d, want 405", res.StatusCode) + } + if n := len(p.car.Calls()); n != 0 { + t.Errorf("car got %v, want no contact", p.car.Methods()) + } +} diff --git a/internal/api/handlers/status_test.go b/internal/api/handlers/status_test.go new file mode 100644 index 0000000..19e5c9a --- /dev/null +++ b/internal/api/handlers/status_test.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "encoding/json" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// Expired cache entries are not used to answer vehicle_data, but the dashboard still shows them +// with their age instead of fetching them again. +func TestStatusShowsExpiredCacheEntries(t *testing.T) { + vin := "5YJ3E1EA1JF999999" + old := time.Now().Add(-2 * time.Hour) + vehicleDataCacheMux.Lock() + vehicleDataCache[generateVehicleDataCacheKey(vin, "charge_state")] = &vehicleDataCacheEntry{data: json.RawMessage(`{"battery_level":42}`), timestamp: old} + vehicleDataCache[generateVehicleDataCacheKey(vin, "location_data")] = &vehicleDataCacheEntry{data: json.RawMessage(`{"latitude":1}`), timestamp: old} + vehicleDataCacheMux.Unlock() + t.Cleanup(func() { + vehicleDataCacheMux.Lock() + delete(vehicleDataCache, generateVehicleDataCacheKey(vin, "charge_state")) + delete(vehicleDataCache, generateVehicleDataCacheKey(vin, "location_data")) + vehicleDataCacheMux.Unlock() + }) + + var found *vehicleStatus + st := buildStatus() + for i := range st.Vehicles { + if st.Vehicles[i].Vin == vin { + found = &st.Vehicles[i] + } + } + if found == nil || found.ChargeState == nil { + t.Fatalf("expired charge_state not in status: %+v", found) + } + if !found.ChargeState.UpdatedAt.Equal(old) { + t.Errorf("updated_at = %s, want %s", found.ChargeState.UpdatedAt, old) + } + b, _ := json.Marshal(found) + if strings.Contains(string(b), "latitude") { + t.Errorf("location data leaked into the status: %s", b) + } +} + +// The status section renders data only through status.js, which must not insert data as HTML. +func TestDashboardStatusSection(t *testing.T) { + rec := httptest.NewRecorder() + ShowDashboard(os.DirFS("../../.."))(rec, httptest.NewRequest("GET", "/dashboard", nil)) + body := rec.Body.String() + for _, want := range []string{`id="vehicle-status"`, `src="static/js/status.js"`} { + if !strings.Contains(body, want) { + t.Errorf("dashboard is missing %s", want) + } + } + + script, err := os.ReadFile("../../../static/js/status.js") + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"} { + if strings.Contains(string(script), forbidden) { + t.Errorf("status.js uses %s; set values with textContent instead", forbidden) + } + } +} diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index 11224d7..caa12e0 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -31,6 +31,60 @@ var ( vehicleDataCacheMux sync.RWMutex ) +// bodyControllerCache keeps the last successful body_controller_state per VIN for the dashboard. +// It is never used to answer body_controller_state itself. +var ( + bodyControllerCache = make(map[string]*vehicleDataCacheEntry) + bodyControllerCacheMux sync.RWMutex +) + +// cachedEntry is a cached response and when it was fetched. +type cachedEntry struct { + Data json.RawMessage `json:"data"` + UpdatedAt time.Time `json:"updated_at"` +} + +// latestVehicleData returns the last cached data of an endpoint, however old, or nil. +func latestVehicleData(vin, endpoint string) *cachedEntry { + vehicleDataCacheMux.RLock() + defer vehicleDataCacheMux.RUnlock() + if e, ok := vehicleDataCache[generateVehicleDataCacheKey(vin, endpoint)]; ok { + return &cachedEntry{Data: e.data, UpdatedAt: e.timestamp} + } + return nil +} + +// latestBodyControllerState returns the last successful body_controller_state, or nil. +func latestBodyControllerState(vin string) *cachedEntry { + bodyControllerCacheMux.RLock() + defer bodyControllerCacheMux.RUnlock() + if e, ok := bodyControllerCache[vin]; ok { + return &cachedEntry{Data: e.data, UpdatedAt: e.timestamp} + } + return nil +} + +// cachedVins returns every VIN with cached vehicle data or body controller state. +func cachedVins() []string { + seen := map[string]bool{} + vehicleDataCacheMux.RLock() + for key := range vehicleDataCache { + vin, _, _ := strings.Cut(key, ":") + seen[vin] = true + } + vehicleDataCacheMux.RUnlock() + bodyControllerCacheMux.RLock() + for vin := range bodyControllerCache { + seen[vin] = true + } + bodyControllerCacheMux.RUnlock() + vins := make([]string, 0, len(seen)) + for vin := range seen { + vins = append(vins, vin) + } + return vins +} + func commonDefer(w http.ResponseWriter, response *models.Response) { var ret models.Ret ret.Response = *response @@ -175,7 +229,13 @@ func VehicleData(w http.ResponseWriter, r *http.Request) { } cacheTime := time.Duration(config.AppConfig.VehicleDataCacheTime) * time.Second + fetchVehicleData(r.Context(), bc, vin, endpoints, cacheTime, r.URL.Query().Get("wakeup") == "true", &response) +} +// fetchVehicleData fills response with the endpoints: from the cache where an entry is younger +// than cacheTime, the rest over BLE, which is then cached. If the BLE request fails, it falls +// back to what is cached. wakeup wakes the car if it is asleep. +func fetchVehicleData(ctx context.Context, bc *control.BleControl, vin string, endpoints []string, cacheTime time.Duration, wakeup bool, response *models.Response) { // Check cache for each endpoint vehicleDataCacheMux.RLock() cachedData := make(map[string]json.RawMessage) // endpoint -> cached data @@ -227,11 +287,10 @@ func VehicleData(w http.ResponseWriter, r *http.Request) { var apiResponse models.ApiResponse wg := sync.WaitGroup{} apiResponse.Wait = &wg - apiResponse.Ctx = r.Context() + apiResponse.Ctx = ctx wg.Add(1) - autoWakeup := r.URL.Query().Get("wakeup") == "true" - bc.PushCommand(command, vin, map[string]interface{}{"endpoints": endpoints}, &apiResponse, autoWakeup) + bc.PushCommand("vehicle_data", vin, map[string]interface{}{"endpoints": endpoints}, &apiResponse, wakeup) wg.Wait() @@ -348,6 +407,9 @@ func BodyControllerState(w http.ResponseWriter, r *http.Request) { } if apiResponse.Result { + bodyControllerCacheMux.Lock() + bodyControllerCache[vin] = &vehicleDataCacheEntry{data: apiResponse.Response, timestamp: time.Now()} + bodyControllerCacheMux.Unlock() SetCacheControl(w, config.AppConfig.CacheMaxAge) response.Result = true response.Reason = "The request was successfully processed." diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 248cd99..c31cb59 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -24,6 +24,8 @@ func SetupRoutes(static fs.FS, html fs.FS, trustedOrigins []string) http.Handler router.HandleFunc("/api/1/vehicles/{vin}/vehicle_data", handlers.VehicleData).Methods("GET") router.HandleFunc("/api/1/vehicles/{vin}/body_controller_state", handlers.BodyControllerState).Methods("GET") router.HandleFunc("/api/proxy/1/version", handlers.Version).Methods("GET") + router.HandleFunc("/api/proxy/1/status", handlers.Status).Methods("GET") + router.HandleFunc("/api/proxy/1/status/refresh", handlers.RefreshStatus).Methods("POST") router.HandleFunc("/dashboard", handlers.ShowDashboard(html)).Methods("GET") router.HandleFunc("/logs", handlers.ShowLogViewer(html)).Methods("GET") router.HandleFunc("/api/logs", handlers.GetLogs).Methods("GET") diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index e1f9a85..c4d641a 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -124,6 +124,9 @@ type BleControl struct { closeRequest chan struct{} // see RequestClose + // What the loop knows about each vehicle, for the dashboard + status statusTracker + // Cache to track when each vehicle was last confirmed awake lastAwakeTime map[string]time.Time awakeTimeMu sync.RWMutex @@ -323,6 +326,7 @@ func (bc *BleControl) markVehicleAwake(vin string) { bc.awakeTimeMu.Lock() bc.lastAwakeTime[vin] = time.Now() bc.awakeTimeMu.Unlock() + bc.status.sleepStatus(vin, SleepStatusAwake) } func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *commands.Command) *commands.Command { @@ -340,6 +344,7 @@ func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *command err = errStopped } logging.Error("Cannot connect to vehicle", "Error", err) + bc.status.failed(firstCommand.Vin, firstCommand.Command, err) failCommand(firstCommand, err) return nil } @@ -386,6 +391,8 @@ func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *command if err == nil { //Successful - cancel the connection attempt context since we're done with it cancel() + bc.status.connected(firstCommand.Vin, true) + defer bc.status.connected(firstCommand.Vin, false) // runs after Disconnect defer car.Disconnect() cmd := bc.operateConnection(car, firstCommand) return cmd @@ -570,6 +577,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com } else { sleepStatus := vs.GetVehicleSleepStatus().String() if strings.Contains(sleepStatus, "ASLEEP") { + bc.status.sleepStatus(firstCommand.Vin, SleepStatusAsleep) logging.Debug("Vehicle is asleep") if firstCommand.AutoWakeup { logging.Debug("Waking up vehicle as requested ...") @@ -758,6 +766,12 @@ func (bc *BleControl) ExecuteCommand(car commands.Vehicle, command *commands.Com var lastErr error defer func() { + // Record the final outcome; a command handed back for a retry is not final yet. + if retErr == nil { + bc.status.succeeded(command.Vin, command.Command) + } else if retryCommand == nil { + bc.status.failed(command.Vin, command.Command, retErr) + } if command.Response != nil { if retErr != nil { command.Response.Error = retErr.Error() diff --git a/internal/ble/control/control_lifecycle_test.go b/internal/ble/control/control_lifecycle_test.go index 75a5a71..1ffee53 100644 --- a/internal/ble/control/control_lifecycle_test.go +++ b/internal/ble/control/control_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "strings" "sync" "sync/atomic" "testing" @@ -392,3 +393,41 @@ func TestLockBLEGivesUp(t *testing.T) { t.Errorf("lockBLE took %s, want about 100ms", d) } } + +func vehicleConnection(bc *BleControl, vin string) VehicleConnection { + for _, v := range bc.Vehicles() { + if v.Vin == vin { + return v + } + } + return VehicleConnection{} +} + +// The dashboard's view of a connection follows the loop: connected while open, the outcome of +// each command, and errors. +func TestVehiclesTracksConnection(t *testing.T) { + car := fakecar.New(vin1) + // A long timeout keeps the connection open until RequestClose, however slow the machine. + bc := newControl(t, time.Minute, car) + go bc.Loop() + + resp, answered := pushWait(bc, commands.Command{Command: "flash_lights", Vin: vin1}) + waitAnswered(t, "flash_lights", answered) + mustSucceed(t, resp) + v := vehicleConnection(bc, vin1) + if !v.Connected || v.ConnectedSince.IsZero() { + t.Errorf("while connected: %+v", v) + } + if v.LastSuccess == nil || v.LastSuccess.Command != "flash_lights" { + t.Errorf("last_success = %+v", v.LastSuccess) + } + bc.RequestClose() + eventually(t, "disconnected after RequestClose", func() bool { return !vehicleConnection(bc, vin1).Connected }) + + resp, answered = pushWait(bc, commands.Command{Command: "flash_lights", Vin: vin2}) + waitAnswered(t, "unreachable car", answered) + mustFail(t, resp, "not in range") + if e := vehicleConnection(bc, vin2).LastError; e == nil || e.Command != "flash_lights" || !strings.Contains(e.Message, "not in range") { + t.Errorf("last_error = %+v", e) + } +} diff --git a/internal/ble/control/status.go b/internal/ble/control/status.go new file mode 100644 index 0000000..832ecbb --- /dev/null +++ b/internal/ble/control/status.go @@ -0,0 +1,111 @@ +package control + +import ( + "sort" + "sync" + "time" +) + +// Sleep states as the command loop last saw them. +const ( + SleepStatusAwake = "awake" + SleepStatusAsleep = "asleep" +) + +// Event is something the command loop did with a vehicle, for the dashboard. +type Event struct { + At time.Time `json:"at"` + Command string `json:"command"` + Message string `json:"message,omitempty"` +} + +// VehicleConnection is what the command loop knows about one vehicle. It is filled where the +// loop learns it anyway and never causes a BLE request of its own. +type VehicleConnection struct { + Vin string `json:"vin"` + Connected bool `json:"connected"` + ConnectedSince time.Time `json:"connected_since,omitzero"` + SleepStatus string `json:"sleep_status,omitempty"` // SleepStatusAwake, SleepStatusAsleep or empty if unknown + SleepStatusAt time.Time `json:"sleep_status_at,omitzero"` + LastSuccess *Event `json:"last_success,omitempty"` + LastError *Event `json:"last_error,omitempty"` +} + +type statusTracker struct { + mu sync.Mutex + vehicles map[string]*VehicleConnection +} + +func (t *statusTracker) update(vin string, f func(v *VehicleConnection)) { + t.mu.Lock() + defer t.mu.Unlock() + if t.vehicles == nil { + t.vehicles = make(map[string]*VehicleConnection) + } + v, ok := t.vehicles[vin] + if !ok { + v = &VehicleConnection{Vin: vin} + t.vehicles[vin] = v + } + f(v) +} + +func (t *statusTracker) connected(vin string, connected bool) { + t.update(vin, func(v *VehicleConnection) { + v.Connected = connected + if connected { + v.ConnectedSince = time.Now() + } else { + v.ConnectedSince = time.Time{} + } + }) +} + +func (t *statusTracker) succeeded(vin, command string) { + t.update(vin, func(v *VehicleConnection) { + v.LastSuccess = &Event{At: time.Now(), Command: command} + }) +} + +func (t *statusTracker) failed(vin, command string, err error) { + t.update(vin, func(v *VehicleConnection) { + v.LastError = &Event{At: time.Now(), Command: command, Message: err.Error()} + }) +} + +func (t *statusTracker) sleepStatus(vin, status string) { + t.update(vin, func(v *VehicleConnection) { + v.SleepStatus = status + v.SleepStatusAt = time.Now() + }) +} + +func (t *statusTracker) snapshot() []VehicleConnection { + t.mu.Lock() + defer t.mu.Unlock() + out := make([]VehicleConnection, 0, len(t.vehicles)) + for _, v := range t.vehicles { + c := *v + if v.LastSuccess != nil { + e := *v.LastSuccess + c.LastSuccess = &e + } + if v.LastError != nil { + e := *v.LastError + c.LastError = &e + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Vin < out[j].Vin }) + return out +} + +// Vehicles returns what the command loop knows about each vehicle it talked to since it started. +func (bc *BleControl) Vehicles() []VehicleConnection { + return bc.status.snapshot() +} + +// QueueLength returns the number of commands waiting for the loop. +func (bc *BleControl) QueueLength() int { + return len(bc.commandStack) +} diff --git a/static/css/style.css b/static/css/style.css index b2d5f7e..e3e5794 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -667,3 +667,293 @@ input:checked + .slider:before { width: 100% !important; } } + +/* Vehicle status (static/js/status.js) */ +.status-proxy { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 13px; + color: #666; + margin-bottom: 12px; +} + +.status-ok { + color: #28a745; +} + +.status-bad { + color: #d32f2f; +} + +.status-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.status-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; +} + +.status-vin { + font-family: monospace; + font-size: 15px; + font-weight: 600; + user-select: all; +} + +.status-badge { + font-size: 14px; + font-weight: 600; +} + +.badge-since { + font-weight: normal; + color: #888; + font-size: 12px; +} + +.badge-connected { + color: #28a745; +} + +.badge-awake { + color: #1976d2; +} + +.badge-asleep { + color: #7b5ea7; +} + +.badge-unknown { + color: #999; +} + +.status-actions { + display: flex; + gap: 8px; + margin: 12px 0 4px; +} + +.status-button { + background-color: #1976d2; + color: #fff; + border: 1px solid #1976d2; + cursor: pointer; +} + +.status-button:hover:not(:disabled) { + background-color: #1565c0; +} + +.status-button-secondary { + background-color: transparent; + color: #1976d2; +} + +.status-button-secondary:hover:not(:disabled) { + background-color: #e3f2fd; +} + +.status-actions button:disabled, +.status-vin-form button:disabled { + opacity: 0.5; + cursor: default; +} + +.status-note { + font-size: 13px; + padding: 8px 10px; + border-radius: 4px; + margin-top: 8px; +} + +.note-ok { + background-color: #e8f5e9; + color: #1b5e20; +} + +.note-info { + background-color: #e3f2fd; + color: #0d47a1; +} + +.note-error { + background-color: #ffebee; + color: #b71c1c; +} + +.status-block { + border-top: 1px solid #eee; + margin-top: 12px; + padding-top: 12px; +} + +.status-block h4 { + margin-bottom: 6px; +} + +.status-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 14px; + padding: 3px 0; +} + +.status-label { + color: #666; +} + +.status-value { + font-weight: 600; + text-align: right; +} + +.status-history .status-value { + font-weight: normal; + font-size: 13px; +} + +.status-error-text { + color: #b71c1c; + font-size: 12px; + word-break: break-word; +} + +.status-empty { + font-size: 13px; + color: #999; +} + +.status-freshness { + font-size: 12px; + color: #888; + margin-top: 6px; +} + +.status-stale { + color: #b26a00; +} + +.soc-bar { + position: relative; + height: 10px; + border-radius: 5px; + background-color: #e0e0e0; + margin: 4px 0 8px; + overflow: visible; +} + +.soc-fill { + height: 100%; + border-radius: 5px; + background-color: #4caf50; +} + +.soc-limit { + position: absolute; + top: -3px; + width: 2px; + height: 16px; + background-color: #333; +} + +.status-vin-form { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.status-vin-form input { + flex: 1; + min-width: 0; + padding: 8px; + font-family: monospace; + text-transform: uppercase; + border: 1px solid #ccc; + border-radius: 4px; +} + +@media (prefers-color-scheme: dark) { + .status-proxy, + .status-label, + .status-freshness, + .badge-since { + color: #aaa; + } + + .status-card { + border-color: #444; + } + + .status-block { + border-top-color: #333; + } + + .soc-bar { + background-color: #333; + } + + .soc-limit { + background-color: #eee; + } + + .status-stale { + color: #ffb74d; + } + + .status-error-text { + color: #ef9a9a; + } + + .note-ok { + background-color: #1b3a1b; + color: #a5d6a7; + } + + .note-info { + background-color: #0d2a4a; + color: #90caf9; + } + + .note-error { + background-color: #4a1a1a; + color: #ffcdd2; + } + + .badge-awake { + color: #64b5f6; + } + + .badge-asleep { + color: #b39ddb; + } + + .status-button-secondary { + color: #90caf9; + border-color: #90caf9; + } + + .status-button-secondary:hover:not(:disabled) { + background-color: #0d2a4a; + } + + .status-vin-form input { + background-color: #111; + color: #fff; + border-color: #555; + } +} + +@media (max-width: 768px) { + .status-actions button { + flex: 1; + } +} diff --git a/static/js/status.js b/static/js/status.js new file mode 100644 index 0000000..44972b1 --- /dev/null +++ b/static/js/status.js @@ -0,0 +1,397 @@ +// Vehicle status on the dashboard. It reads /api/proxy/1/status, which never contacts the +// vehicle; only the Refresh buttons do. All values are set with textContent, never as HTML. +(function () { + 'use strict'; + + var POLL_MS = 10000; + var root = document.getElementById('vehicle-status'); + if (!root) { + return; + } + var status = null; + var notes = {}; // VIN -> {text, kind} from the last refresh + var busy = {}; // VIN -> true while a refresh runs + + function el(tag, className, text) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + if (text !== undefined && text !== null) { + e.textContent = text; + } + return e; + } + + function parseTime(value) { + if (!value) { + return null; + } + var d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + // "14:32:05", or with the date if it is not from today. + function absolute(d) { + var now = new Date(); + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString(); + } + return d.toLocaleString(); + } + + function relative(d) { + var s = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000)); + if (s < 60) { + return s + ' s ago'; + } + var m = Math.floor(s / 60); + if (m < 60) { + return m + ' min ago'; + } + var h = Math.floor(m / 60); + if (h < 48) { + return h + ' h ' + (m % 60) + ' min ago'; + } + return Math.floor(h / 24) + ' days ago'; + } + + // A time element whose relative part is kept current by tick(). + function timeEl(d, prefix) { + var e = el('span', 'status-time'); + e.dataset.at = d.toISOString(); + e.dataset.prefix = prefix || ''; + e.title = d.toLocaleString(); + updateTime(e); + return e; + } + + function updateTime(e) { + var d = new Date(e.dataset.at); + e.textContent = e.dataset.prefix + absolute(d) + ' (' + relative(d) + ')'; + } + + function tick() { + root.querySelectorAll('.status-time').forEach(updateTime); + } + + function row(label, value) { + var r = el('div', 'status-row'); + r.appendChild(el('span', 'status-label', label)); + var v = el('span', 'status-value'); + if (value instanceof Node) { + v.appendChild(value); + } else { + v.textContent = value; + } + r.appendChild(v); + return r; + } + + function yesNo(b) { + return b ? 'On' : 'Off'; + } + + // Fields the car did not send come through as "" or empty. + function known(s) { + return s && s !== ''; + } + + function words(s) { + // "VEHICLELOCKSTATE_LOCKED" -> "Locked", "Charging" stays "Charging" + if (!s) { + return '–'; + } + var last = s.split('_').pop().toLowerCase(); + return last.charAt(0).toUpperCase() + last.slice(1); + } + + // The "data from" line of a block; marked when older than the vehicle data cache time. + function freshness(entry) { + var d = parseTime(entry.updated_at); + var line = el('div', 'status-freshness'); + if (!d) { + return line; + } + line.appendChild(timeEl(d, 'Data from ')); + var maxAge = status.proxy.vehicle_data_cache_time; + if (maxAge > 0 && Date.now() - d.getTime() > maxAge * 1000) { + line.classList.add('status-stale'); + var label = el('span', 'status-stale-label', ' · outdated'); + label.title = 'Older than vehicleDataCacheTime (' + maxAge + ' s). The dashboard does not fetch new data by itself; use Refresh.'; + line.appendChild(label); + } + return line; + } + + function block(title, entry, fill) { + var b = el('div', 'status-block'); + b.appendChild(el('h4', null, title)); + if (!entry || !entry.data) { + b.appendChild(el('div', 'status-empty', 'No data yet.')); + return b; + } + fill(b, entry.data); + b.appendChild(freshness(entry)); + return b; + } + + function socBar(level, limit) { + var bar = el('div', 'soc-bar'); + var fill = el('div', 'soc-fill'); + fill.style.width = Math.max(0, Math.min(100, level)) + '%'; + bar.appendChild(fill); + if (limit > 0) { + var mark = el('div', 'soc-limit'); + mark.style.left = Math.min(100, limit) + '%'; + mark.title = 'Charge limit ' + limit + ' %'; + bar.appendChild(mark); + } + return bar; + } + + function fillCharge(b, c) { + b.appendChild(socBar(c.battery_level, c.charge_limit_soc)); + b.appendChild(row('State of charge', c.battery_level + ' %')); + b.appendChild(row('Charge limit', c.charge_limit_soc + ' %')); + if (c.battery_range > 0) { + b.appendChild(row('Range', Math.round(c.battery_range * 1.609344) + ' km (' + Math.round(c.battery_range) + ' mi)')); + } + b.appendChild(row('Charging', known(c.charging_state) ? c.charging_state : '–')); + if (c.charging_state === 'Charging') { + b.appendChild(row('Power', c.charger_power + ' kW')); + var amps = c.charger_actual_current + ' A'; + if (c.charger_phases > 0) { + amps += ' × ' + c.charger_phases + (c.charger_phases === 1 ? ' phase' : ' phases'); + } + b.appendChild(row('Current', amps)); + if (c.minutes_to_full_charge > 0) { + b.appendChild(row('Time to limit', Math.floor(c.minutes_to_full_charge / 60) + ' h ' + (c.minutes_to_full_charge % 60) + ' min')); + } + } + if (known(c.conn_charge_cable)) { + b.appendChild(row('Cable', c.conn_charge_cable)); + } + } + + function fillClimate(b, c) { + b.appendChild(row('Climate', yesNo(c.is_climate_on))); + b.appendChild(row('Preconditioning', yesNo(c.is_preconditioning))); + b.appendChild(row('Inside', c.inside_temp.toFixed(1) + ' °C')); + b.appendChild(row('Outside', c.outside_temp.toFixed(1) + ' °C')); + } + + function fillBody(b, v) { + b.appendChild(row('Lock', words(v.vehicle_lock_state))); + b.appendChild(row('Sleep', words(v.vehicle_sleep_status))); + b.appendChild(row('User present', words(v.user_presence))); + } + + // The newest knowledge about sleep: from the command loop or from body_controller_state. + function sleepInfo(v) { + var best = null; + var at = parseTime(v.sleep_status_at); + if (v.sleep_status && at) { + best = {asleep: v.sleep_status === 'asleep', at: at}; + } + if (v.body_controller && v.body_controller.data) { + var s = v.body_controller.data.vehicle_sleep_status || ''; + var bat = parseTime(v.body_controller.updated_at); + if (bat && (s.indexOf('ASLEEP') >= 0 || s.indexOf('AWAKE') >= 0) && (!best || bat > best.at)) { + best = {asleep: s.indexOf('ASLEEP') >= 0, at: bat}; + } + } + return best; + } + + function badge(v) { + var b = el('span', 'status-badge'); + var sleep = sleepInfo(v); + if (v.connected) { + // A VCSEC-only connection (body_controller_state) is possible while the car sleeps. + b.classList.add('badge-connected'); + b.appendChild(document.createTextNode('● Connected')); + if (sleep) { + b.appendChild(document.createTextNode(sleep.asleep ? ' · Asleep' : ' · Awake')); + } + var since = parseTime(v.connected_since); + if (since) { + b.appendChild(el('span', 'badge-since', ' since ' + absolute(since))); + } + return b; + } + if (sleep) { + b.classList.add(sleep.asleep ? 'badge-asleep' : 'badge-awake'); + b.appendChild(document.createTextNode(sleep.asleep ? '☾ Asleep' : '○ Awake')); + b.appendChild(el('span', 'badge-since', ' as of ' + absolute(sleep.at))); + return b; + } + b.classList.add('badge-unknown'); + b.textContent = '○ Not connected'; + return b; + } + + function refreshButtons(vin) { + var box = el('div', 'status-actions'); + var plain = el('button', 'status-button small-button', 'Refresh'); + plain.type = 'button'; + plain.title = 'Fetch current data over Bluetooth. Does not wake the vehicle.'; + plain.addEventListener('click', function () { refresh(vin, false); }); + var wake = el('button', 'status-button status-button-secondary small-button', 'Wake & refresh'); + wake.type = 'button'; + wake.title = 'Wake the vehicle if it is asleep, then fetch current data.'; + wake.addEventListener('click', function () { + if (confirm('Wake the vehicle ' + vin + '?\n\nWaking uses energy and keeps the car awake for a while.')) { + refresh(vin, true); + } + }); + if (busy[vin] || !status.proxy.ble_control_running) { + plain.disabled = true; + wake.disabled = true; + } + box.appendChild(plain); + box.appendChild(wake); + return box; + } + + function card(v) { + var c = el('div', 'status-card'); + var head = el('div', 'status-card-head'); + head.appendChild(el('span', 'status-vin', v.vin)); + head.appendChild(badge(v)); + c.appendChild(head); + c.appendChild(refreshButtons(v.vin)); + if (busy[v.vin]) { + c.appendChild(el('div', 'status-note note-info', 'Refreshing …')); + } else if (notes[v.vin]) { + c.appendChild(el('div', 'status-note note-' + notes[v.vin].kind, notes[v.vin].text)); + } + + c.appendChild(block('Battery & charging', v.charge_state, fillCharge)); + c.appendChild(block('Climate', v.climate_state, fillClimate)); + if (v.body_controller) { + c.appendChild(block('Vehicle', v.body_controller, fillBody)); + } + + var foot = el('div', 'status-block status-history'); + var ok = v.last_success && parseTime(v.last_success.at); + foot.appendChild(row('Last successful contact', ok ? timeEl(ok, v.last_success.command + ' · ') : '– (since the proxy started)')); + var err = v.last_error && parseTime(v.last_error.at); + if (err) { + var e = el('span'); + e.appendChild(timeEl(err, v.last_error.command + ' · ')); + e.appendChild(el('div', 'status-error-text', v.last_error.message)); + foot.appendChild(row('Last error', e)); + } + c.appendChild(foot); + return c; + } + + function emptyState() { + var box = el('div', 'status-card'); + box.appendChild(el('p', 'description-text', 'No vehicle data yet. It appears as soon as evcc or another client requests vehicle_data, or when you refresh a vehicle here.')); + var form = el('form', 'status-vin-form'); + var input = el('input'); + input.name = 'vin'; + input.placeholder = 'VIN'; + input.maxLength = 17; + input.required = true; + input.autocomplete = 'off'; + form.appendChild(input); + var go = el('button', 'status-button small-button', 'Refresh'); + go.type = 'submit'; + go.disabled = !status.proxy.ble_control_running; + form.appendChild(go); + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + refresh(input.value.trim().toUpperCase(), false); + }); + box.appendChild(form); + var vin = Object.keys(notes)[0]; + if (vin && notes[vin]) { + box.appendChild(el('div', 'status-note note-' + notes[vin].kind, vin + ': ' + notes[vin].text)); + } + return box; + } + + function proxyLine(p) { + var line = el('div', 'status-proxy'); + line.appendChild(el('span', null, 'Up since ' + absolute(new Date(p.started_at)))); + var roles = {charging_manager: 'Charging Manager', owner: 'Owner'}; + line.appendChild(el('span', null, 'Key: ' + (roles[p.active_key_role] || p.active_key_role || '–'))); + line.appendChild(el('span', p.ble_control_running ? 'status-ok' : 'status-bad', p.ble_control_running ? 'Bluetooth ready' : 'Bluetooth not ready (no key?)')); + if (p.queue_length > 0) { + line.appendChild(el('span', null, p.queue_length + ' queued')); + } + return line; + } + + function render() { + root.replaceChildren(); + if (!status) { + root.appendChild(el('p', 'description-text', 'Loading …')); + return; + } + root.appendChild(proxyLine(status.proxy)); + if (status.vehicles.length === 0) { + root.appendChild(emptyState()); + } + status.vehicles.forEach(function (v) { + root.appendChild(card(v)); + }); + } + + function load() { + return fetch('/api/proxy/1/status', {cache: 'no-store'}) + .then(function (res) { return res.json(); }) + .then(function (ret) { + if (ret.response && ret.response.result) { + status = ret.response.response; + // Do not wipe what someone is typing (VIN field); the next poll renders. + var active = document.activeElement; + if (!(active && active.tagName === 'INPUT' && root.contains(active))) { + render(); + } + } + }) + .catch(function () { /* keep the last state; the next poll tries again */ }); + } + + function refresh(vin, wakeup) { + busy[vin] = true; + delete notes[vin]; + render(); + var body = new URLSearchParams({vin: vin, wakeup: wakeup ? 'true' : 'false'}); + fetch('/api/proxy/1/status/refresh', {method: 'POST', body: body}) + .then(function (res) { return res.json(); }) + .then(function (ret) { + var r = ret.response || {}; + if (r.result) { + notes[vin] = {kind: 'ok', text: 'Updated.'}; + } else if (/sleep/i.test(r.reason || '')) { + notes[vin] = {kind: 'info', text: 'The vehicle is asleep. Use “Wake & refresh” to wake it.'}; + } else { + notes[vin] = {kind: 'error', text: r.reason || 'Refresh failed.'}; + } + }) + .catch(function (e) { + notes[vin] = {kind: 'error', text: 'Refresh failed: ' + e}; + }) + .then(function () { + delete busy[vin]; + return load(); + }); + } + + function poll() { + if (document.visibilityState === 'visible') { + load(); + } + } + + render(); + load(); + setInterval(poll, POLL_MS); + setInterval(tick, 1000); + document.addEventListener('visibilitychange', poll); +})();