Compare commits

...

7 Commits

18 changed files with 1267 additions and 86 deletions
@@ -7,10 +7,12 @@ import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.os.Build
import android.os.ParcelUuid import android.os.ParcelUuid
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.json.JSONObject import org.json.JSONObject
@@ -33,22 +35,40 @@ class BleClient(private val context: Context) {
private val incoming = StringBuilder() private val incoming = StringBuilder()
private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>() private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>()
private val writes = ArrayDeque<ByteArray>() private val writes = ArrayDeque<ByteArray>()
private var writeInProgress = false
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val reconnect: Runnable = Runnable { connect() }
private val restartScan: Runnable = Runnable {
if (!stopped && state.value == ConnectionState.SCANNING) {
bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
scheduleReconnect(1_000)
}
}
private var stopped = true private var stopped = true
fun connect() { fun connect() {
stopped = false stopped = false
val adapter = bluetoothManager.adapter ?: return handler.removeCallbacks(reconnect)
handler.removeCallbacks(restartScan)
val adapter = bluetoothManager.adapter ?: run {
state.value = ConnectionState.DISCONNECTED
scheduleReconnect()
return
}
adapter.bluetoothLeScanner?.stopScan(scanCallback)
state.value = ConnectionState.SCANNING state.value = ConnectionState.SCANNING
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
adapter.bluetoothLeScanner?.startScan(listOf(filter), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback) adapter.bluetoothLeScanner?.startScan(listOf(filter), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback)
handler.postDelayed(restartScan, 15_000)
} }
fun disconnect() { fun disconnect() {
stopped = true stopped = true
handler.removeCallbacksAndMessages(null) handler.removeCallbacks(reconnect)
handler.removeCallbacks(restartScan)
bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback) bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
gatt?.close(); gatt = null; rx = null gatt?.close(); gatt = null; rx = null
resetWrites()
state.value = ConnectionState.STOPPED state.value = ConnectionState.STOPPED
} }
@@ -61,65 +81,152 @@ class BleClient(private val context: Context) {
enqueue((message.toString() + "\n").toByteArray()) enqueue((message.toString() + "\n").toByteArray())
return try { return try {
withTimeout(20_000) { deferred.await() } withTimeout(20_000) { deferred.await() }
} catch (error: TimeoutCancellationException) {
handler.post { gatt?.disconnect() }
throw error
} finally { } finally {
pending.remove(id) pending.remove(id)
} }
} }
private val scanCallback = object : ScanCallback() { private val scanCallback: ScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
handler.removeCallbacks(restartScan)
bluetoothManager.adapter.bluetoothLeScanner?.stopScan(this) bluetoothManager.adapter.bluetoothLeScanner?.stopScan(this)
state.value = ConnectionState.CONNECTING state.value = ConnectionState.CONNECTING
gatt?.close()
gatt = result.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) gatt = result.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} }
override fun onScanFailed(errorCode: Int) { state.value = ConnectionState.DISCONNECTED } override fun onScanFailed(errorCode: Int) {
handler.removeCallbacks(restartScan)
state.value = ConnectionState.DISCONNECTED
scheduleReconnect()
}
} }
private val gattCallback = object : BluetoothGattCallback() { private val gattCallback: BluetoothGattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { this@BleClient.gatt = gatt; gatt.requestMtu(247); gatt.discoverServices() } if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
this@BleClient.gatt = gatt
// Android GATT operations are serialized. Starting service
// discovery while the MTU request is still pending can leave
// the client in CONNECTING forever on some phones.
if (!gatt.requestMtu(247)) gatt.discoverServices()
}
else { else {
rx = null; gatt.close(); state.value = ConnectionState.DISCONNECTED rx = null; gatt.close()
if (!stopped) handler.postDelayed({ connect() }, 3_000) if (this@BleClient.gatt === gatt) this@BleClient.gatt = null
resetWrites(); state.value = ConnectionState.DISCONNECTED
scheduleReconnect()
} }
} }
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
gatt.discoverServices()
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(SERVICE_UUID) ?: return val service = gatt.getService(SERVICE_UUID)
rx = service.getCharacteristic(RX_UUID) val nextRx = service?.getCharacteristic(RX_UUID)
val tx = service.getCharacteristic(TX_UUID) ?: return val tx = service?.getCharacteristic(TX_UUID)
gatt.setCharacteristicNotification(tx, true) val descriptor = tx?.getDescriptor(CCC_UUID)
tx.getDescriptor(CCC_UUID)?.let { descriptor -> if (status != BluetoothGatt.GATT_SUCCESS || nextRx == null || tx == null || descriptor == null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.disconnect()
return
}
rx = nextRx
if (!gatt.setCharacteristicNotification(tx, true)) {
gatt.disconnect()
return
}
val descriptorStarted = if (Build.VERSION.SDK_INT >= 33) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == BluetoothStatusCodes.SUCCESS
} else {
descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
gatt.writeDescriptor(descriptor) gatt.writeDescriptor(descriptor)
} }
state.value = ConnectionState.CONNECTED if (!descriptorStarted) gatt.disconnect()
} }
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (descriptor.uuid == CCC_UUID && status == BluetoothGatt.GATT_SUCCESS) {
state.value = ConnectionState.CONNECTED
} else {
gatt.disconnect()
}
}
@Deprecated("Used on Android 12 and earlier")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
incoming.append(characteristic.value.toString(Charsets.UTF_8)) handleIncoming(characteristic.value)
while (incoming.contains("\n")) { }
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1) override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
runCatching { JSONObject(line) }.getOrNull()?.let { response -> handleIncoming(value)
pending.remove(response.optString("id"))?.let { deferred -> }
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data")) override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed")) if (status == BluetoothGatt.GATT_SUCCESS) writeNext() else {
} synchronized(this@BleClient) { writeInProgress = false }
gatt.disconnect()
}
}
}
private fun handleIncoming(value: ByteArray) {
incoming.append(value.toString(Charsets.UTF_8))
while (incoming.contains("\n")) {
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1)
runCatching { JSONObject(line) }.getOrNull()?.let { response ->
pending.remove(response.optString("id"))?.let { deferred ->
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data"))
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed"))
} }
} }
} }
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { writeNext() }
} }
@Synchronized private fun enqueue(bytes: ByteArray) { @Synchronized private fun enqueue(bytes: ByteArray) {
bytes.asList().chunked(180).forEach { chunk -> writes.add(chunk.toByteArray()) } val chunks = bytes.asList().chunked(180)
if (writes.size == bytes.asList().chunked(180).size) writeNext() chunks.forEach { chunk -> writes.add(chunk.toByteArray()) }
if (!writeInProgress) {
writeInProgress = true
writeNext()
}
} }
@Synchronized private fun writeNext() { @Synchronized private fun writeNext() {
val characteristic = rx ?: return val characteristic = rx
val next = writes.removeFirstOrNull() ?: return val currentGatt = gatt
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT if (characteristic == null || currentGatt == null) {
characteristic.value = next writeInProgress = false
gatt?.writeCharacteristic(characteristic) return
}
val next = writes.removeFirstOrNull()
if (next == null) {
writeInProgress = false
return
}
val started = if (Build.VERSION.SDK_INT >= 33) {
currentGatt.writeCharacteristic(characteristic, next, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothStatusCodes.SUCCESS
} else {
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = next
currentGatt.writeCharacteristic(characteristic)
}
if (!started) {
writeInProgress = false
currentGatt.disconnect()
}
}
@Synchronized private fun resetWrites() {
writes.clear()
writeInProgress = false
}
private fun scheduleReconnect(delayMillis: Long = 3_000) {
if (stopped) return
handler.removeCallbacks(reconnect)
handler.postDelayed(reconnect, delayMillis)
} }
} }
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
---
title: Pi Car Companion - First Test Drive
aliases:
- First Test Drive Notes
project: Pi Car Companion
type: field-notes
status: Follow-up required
date: Unknown
source: https://git.molberg.cloud/alex/pi-car-companion
tags:
- pi-car-companion
- mg4
- first-test-drive
- telemetry
- android
- networking
---
# Pi Car Companion: First Test Drive
> [!info] Purpose
> Notes from the first real-world test drive of Pi Car Companion. This document is a pickup point for later project work. It records observations and questions, not an instruction to implement every item immediately.
## Scope and handling
> [!important] Do not over-implement
> The larger investigations and feature requests below must not be started automatically. They are notes for later pickup.
>
> Small, explicit, low-risk fixes for a specific listed problem may be made without separate approval. The inverted wheel animation in the web UI is an example of this kind of fix.
>
> Power, networking, ADB reliability, telemetry accuracy, Android Auto, battery-service access, new phone-app features, shell access, power controls, and map-provider changes require separate investigation and approval before implementation.
## Quick overview
| Area | Observation | Status |
|---|---|---|
| Power | Raspberry Pi desktop showed a low-voltage warning while powered by the power bank | Investigate later |
| ADB | Connection dropped during the drive | Investigate later |
| Speed | Readings were a few km/h low | Investigate later |
| Peak speed | Trip report missed the observed top speed | Investigate later |
| Wi-Fi | Hotspot disconnected repeatedly | Investigate later |
| Phone GPS | GPS should stop when the Pi is unreachable | Feature request |
| Phone app | More controls and trip features requested | Feature request |
| Trip map | OpenStreetMap `Referer` handling or another map solution needed | Investigate later |
| Android Auto | Repeated disconnects and unstable connection | Investigate later |
| Battery | Battery data could not be read | Investigate later |
| Web UI | Wheel animation direction is inverted | **Simple fix candidate** |
| Phone app | Wheel animation requested | Feature request |
## Detailed notes
### Raspberry Pi low-voltage warning while using the power bank
- The Raspberry Pi showed a desktop-environment notification saying: **"Low voltage. Please check power supply."**
- This happened while the Pi was running from the power bank.
- The power bank itself did not report the warning.
- Possible relation to the Pi, the AWUS1900, Wi-Fi instability, or other USB hardware is unknown.
**Follow-up question:** Is the power bank providing stable and sufficient power to the Raspberry Pi under load?
### ADB connection drops
- The ADB connection dropped during the test drive.
- The problem was visible in the phone app and in the trip GPS data.
**Follow-up questions:**
- Was the ADB connection drop caused by power, Wi-Fi, USB, or the infotainment system?
- How does the dropout affect trip recording and phone-app state?
### Speed and peak-speed data
- Speed was a few km/h off:
- Approximately `65 km/h` in reality.
- Approximately `63-64 km/h` reported.
- Peak speed may not be logged quickly enough:
- Real top speed: `126 km/h`.
- Trip report: `116 km/h`.
- Phone app: `124 km/h`.
**Follow-up questions:**
- Is the difference caused by sampling interval, timestamp alignment, GPS smoothing, or trip aggregation?
- Why was the phone-app value closer to the observed top speed than the trip report?
### Wi-Fi hotspot instability
- The Wi-Fi hotspot disconnected constantly.
- A power-supply problem is suspected as one possible cause.
- The AWUS1900 may be rebooting because of insufficient power.
**Follow-up questions:**
- Does the AWUS1900 reset when the Raspberry Pi reports low voltage while powered by the power bank?
- Do Wi-Fi disconnects occur at the same time as ADB and Android Auto disconnects?
- Is the built-in Pi Wi-Fi adapter still stable when the AWUS1900 drops?
### Phone-app behavior when the Pi is unreachable
The phone app should eventually be able to:
- Stop sending GPS data when there is no connection to the Pi.
- Hide its notification while disconnected.
- Enter a low-power sleep state until the Pi connection returns.
- Resume GPS updates and the notification after reconnecting.
> [!note] Scope
> This is a behavior change in the Android companion, not a simple documentation or UI correction. Leave it as a later feature unless explicitly picked up.
### Additional phone-app features requested
- Edit shortcut apps.
- Possibly provide shell access.
- Reboot or power off the Pi.
- View trip data.
- Add a Settings page containing:
- Hotspot settings.
- Software update controls.
> [!warning] Safety and scope
> Shell access, reboot, and power-off are operational features with security and safety implications. Do not implement them from this note alone. Any future implementation must preserve the project's restricted, authenticated design.
### Trip-report map
- The trip-report map needs a `Referer` header for OpenStreetMap requests.
- Consider an alternative map provider.
- Consider whether map data could be pre-baked into the Android app and web UI.
**Follow-up questions:**
- Can the existing map integration send the required header correctly?
- Would an alternative provider or offline map data better fit the local-first design?
- What attribution and licensing requirements would apply?
### Android Auto disconnects
- Android Auto repeatedly disconnected.
- The connection was unstable.
Possible causes mentioned during the test:
- ADB instability.
- Wi-Fi interference.
- Bluetooth interference.
- Power instability.
No cause has been confirmed.
### Battery telemetry unavailable
- Battery data could not be read during the test.
The cause is unknown. The UI should continue to show battery values as unavailable rather than substituting unrelated Android head-unit battery data.
### Wheel animation
- The wheel animation is inverted in the web UI.
- The phone app should also receive a wheel animation.
> [!tip] Simple fix candidate
> Correcting the direction of the existing web UI wheel animation is an explicitly listed, small UI fix and may be handled independently. Adding a new wheel animation to the phone app is a separate feature and should not be assumed to be included in that permission.
## Later pickup checklist
### Reliability investigation
- [ ] Investigate the low-voltage warning from the power bank.
- [ ] Determine whether the AWUS1900 is rebooting or losing power.
- [ ] Investigate ADB connection drops.
- [ ] Investigate repeated Wi-Fi hotspot disconnects.
- [ ] Investigate Android Auto disconnects and possible Wi-Fi/Bluetooth interference.
### Telemetry investigation
- [ ] Review the small speed discrepancy.
- [ ] Review peak-speed sampling and trip aggregation.
- [ ] Investigate why battery data cannot be read.
### Phone-app feature work
- [ ] Design disconnected GPS behavior.
- [ ] Consider shortcut editing.
- [ ] Consider trip data support.
- [ ] Consider a phone-app Settings page.
- [ ] Evaluate whether shell, reboot, and power-off controls are appropriate.
- [ ] Consider adding wheel animation to the phone app.
### Map work
- [ ] Decide between fixing the OpenStreetMap request headers, using another provider, or supporting offline/pre-baked map data.
### Explicitly allowed simple fix
- [ ] Correct the inverted wheel animation in the web UI.
## English rewrite
- **Raspberry Pi:** The desktop environment showed a low-voltage warning saying, **"Low voltage. Please check power supply."** while the Pi was running from the power bank.
- **ADB connection dropped.**
- This was visible in the phone app and in the trip GPS data.
- **Speed was a few km/h off.**
- Approximately `65 km/h` was shown as `63-64 km/h`.
- **Peak speed may not be logged quickly enough.**
- Real top speed: `126 km/h`.
- Trip report: `116 km/h`.
- Phone app: `124 km/h`.
- **The Wi-Fi hotspot disconnected constantly.**
- Could this be a power-supply problem?
- Could the AWUS1900 be rebooting because of insufficient power?
- **The phone app should stop sending GPS data when it has no connection to the Pi.**
- Hide the notification and enter a low-power sleep state until the Pi connection returns.
- **The phone app needs more features.**
- Edit shortcut apps.
- Shell access?
- Reboot or power off the Pi.
- Trip data.
- Settings page:
- Hotspot settings.
- Software updates.
- **The trip-report map needs a `Referer` header for OpenStreetMap.**
- Alternative map provider?
- Could map data be pre-baked into the Android app and web UI?
- **Android Auto repeatedly disconnected and had an unstable connection.**
- Was it caused by ADB?
- Wi-Fi/Bluetooth interference?
- **Battery data could not be read.**
- **The wheel animation is inverted in the web UI.**
- Add wheel animation to the phone app.
## Original notes in Danish
- Raspberry Pi desktop environment showed a low-voltage warning ("Low voltage. Please check power supply") while running through the power bank
- ADB connection dropper ud
- Ses på telefon app, og trip GPS data
- Speed er få km/h off (65 -> 63-64 km/h)
- Peak speed logges ikke hurtigt nok?
- Ramte real 126 km/h top speed (trip siger 116 km/h)
- Telefon app viste 124 km/h
- WiFi hotspot disconnecter konstant.
- Power supply problem?
- Måske fordi AWUS 1900 rebooter pga lav strøm?
- Telefon app skal kunne stoppe med gps data når ingen forbindelse til pi
- skjul notifikation og gå i dvale indtil reconnect med pi
- Telefon app skal have flere features
- edit shortcut apps
- shell?
- Reboot/power off pi
- Trip data
- Settings page
- Hotspot settings
- Software Update
- Trip report map skal have Referer header til openstreetmap.
- Alternativ kort?
- Prebaked ind i App og web ui?
- Android Auto blev ved med at disconnect og havde sporty connection
- Skyldes ADB?
- WiFi/Bluetooth interference?
- Battery data kan ikke læses
- Wheel animation er inverted på web ui
- Tilføj wheel animation på telefon app
## Related project
- [[Pi Car Companion]]
- Repository: [alex/pi-car-companion](https://git.molberg.cloud/alex/pi-car-companion)
## Changelog
- Added as a project note under `notes/`.
- Preserved the original Danish notes.
- Added a structured English rewrite.
- Marked broad work as later pickup and separated the simple web wheel-animation fix.
> [!note] No implementation was made from this note.
+25
View File
@@ -1732,6 +1732,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1739,6 +1746,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.1", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -3616,6 +3633,12 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5137,10 +5160,12 @@
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"leaflet": "^1.9.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
+35 -30
View File
@@ -21,8 +21,6 @@ DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
GATT_MANAGER = "org.bluez.GattManager1" GATT_MANAGER = "org.bluez.GattManager1"
GATT_SERVICE = "org.bluez.GattService1" GATT_SERVICE = "org.bluez.GattService1"
GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1" GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1"
AD_MANAGER = "org.bluez.LEAdvertisingManager1"
ADVERTISEMENT = "org.bluez.LEAdvertisement1"
SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001" SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001"
RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001" RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001"
@@ -103,19 +101,30 @@ class Characteristic(dbus.service.Object):
class TxCharacteristic(Characteristic): class TxCharacteristic(Characteristic):
def __init__(self, bus, service): def __init__(self, bus, service):
super().__init__(bus, 1, TX_UUID, ["notify"], service) super().__init__(bus, 1, TX_UUID, ["indicate"], service)
self.notifying = False self.notifying = False
self.queue = deque() self.queue = deque()
self.sending = False self.sending = False
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}") @dbus.service.method(GATT_CHARACTERISTIC)
def StartNotify(self, _options): def StartNotify(self):
self.notifying = True self.notifying = True
if self.queue and not self.sending:
self.sending = True
GLib.idle_add(self._send_next)
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}") @dbus.service.method(GATT_CHARACTERISTIC)
def StopNotify(self, _options): def StopNotify(self):
self.notifying = False self.notifying = False
self.queue.clear() self.queue.clear()
self.sending = False
@dbus.service.method(GATT_CHARACTERISTIC)
def Confirm(self):
if self.queue:
GLib.idle_add(self._send_next)
else:
self.sending = False
def send(self, message): def send(self, message):
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8") encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
@@ -123,7 +132,7 @@ class TxCharacteristic(Characteristic):
self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES]) self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES])
if not self.sending: if not self.sending:
self.sending = True self.sending = True
GLib.timeout_add(20, self._send_next) GLib.idle_add(self._send_next)
def _send_next(self): def _send_next(self):
if not self.notifying or not self.queue: if not self.notifying or not self.queue:
@@ -132,7 +141,7 @@ class TxCharacteristic(Characteristic):
chunk = self.queue.popleft() chunk = self.queue.popleft()
value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y") value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y")
self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, []) self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, [])
return bool(self.queue) return False
class RxCharacteristic(Characteristic): class RxCharacteristic(Characteristic):
@@ -216,26 +225,10 @@ def call_api(method, path, body, token):
raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error
class Advertisement(dbus.service.Object):
def __init__(self, bus):
self.path = "/org/pi_car_companion/advertisement0"
super().__init__(bus, self.path)
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != ADVERTISEMENT:
raise InvalidArgs()
return {"Type": "peripheral", "ServiceUUIDs": dbus.Array([SERVICE_UUID], signature="s"), "LocalName": "Pi Car"}
@dbus.service.method(ADVERTISEMENT)
def Release(self):
pass
def find_adapter(bus): def find_adapter(bus):
objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects() objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects()
for path, interfaces in objects.items(): for path, interfaces in objects.items():
if GATT_MANAGER in interfaces and AD_MANAGER in interfaces: if GATT_MANAGER in interfaces:
return path return path
raise RuntimeError("No Bluetooth LE adapter with GATT server support was found") raise RuntimeError("No Bluetooth LE adapter with GATT server support was found")
@@ -253,10 +246,22 @@ def main():
service.add_characteristic(rx) service.add_characteristic(rx)
service.add_characteristic(tx) service.add_characteristic(tx)
application.add_service(service) application.add_service(service)
advertisement = Advertisement(bus) loop = GLib.MainLoop()
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(application.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error)) registration_errors = []
dbus.Interface(adapter, AD_MANAGER).RegisterAdvertisement(advertisement.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
GLib.MainLoop().run() def registration_failed(error):
registration_errors.append(error)
loop.quit()
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(
application.path,
{},
reply_handler=lambda: None,
error_handler=registration_failed,
)
loop.run()
if registration_errors:
raise registration_errors[0]
if __name__ == "__main__": if __name__ == "__main__":
@@ -57,8 +57,6 @@ case "${1:-}" in
discoverable) discoverable)
power_on power_on
"${BLUETOOTHCTL}" discoverable-timeout 180 >/dev/null "${BLUETOOTHCTL}" discoverable-timeout 180 >/dev/null
"${BLUETOOTHCTL}" pairable-timeout 180 >/dev/null
"${BLUETOOTHCTL}" pairable on >/dev/null
"${BLUETOOTHCTL}" discoverable on >/dev/null "${BLUETOOTHCTL}" discoverable on >/dev/null
;; ;;
*) *)
+1 -1
View File
@@ -168,7 +168,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
reply.header("X-Frame-Options", "DENY"); reply.header("X-Frame-Options", "DENY");
reply.header( reply.header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self'"
); );
}); });
+46
View File
@@ -101,6 +101,52 @@ function migrate(database: CompanionDatabase): void {
network_type TEXT network_type TEXT
); );
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES mobile_devices(id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
ended_at INTEGER,
last_moving_at INTEGER NOT NULL,
start_latitude REAL NOT NULL,
start_longitude REAL NOT NULL,
end_latitude REAL NOT NULL,
end_longitude REAL NOT NULL,
distance_km REAL NOT NULL DEFAULT 0,
moving_seconds REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0,
speed_sum REAL NOT NULL DEFAULT 0,
speed_count INTEGER NOT NULL DEFAULT 0,
peak_speed_kph REAL,
peak_speed_at INTEGER,
peak_speed_latitude REAL,
peak_speed_longitude REAL,
vehicle_battery_start REAL,
vehicle_battery_end REAL,
vehicle_consumption_start_kwh REAL,
vehicle_consumption_end_kwh REAL,
phone_battery_start REAL,
phone_battery_end REAL
);
CREATE INDEX IF NOT EXISTS trips_started_idx ON trips(started_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS trips_active_device_idx ON trips(device_id) WHERE ended_at IS NULL;
CREATE TABLE IF NOT EXISTS trip_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
captured_at INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
accuracy_meters REAL NOT NULL,
altitude_meters REAL,
speed_kph REAL,
bearing_degrees REAL,
vehicle_battery_percent REAL,
UNIQUE(trip_id, captured_at)
);
CREATE INDEX IF NOT EXISTS trip_points_trip_time_idx ON trip_points(trip_id, captured_at);
CREATE TABLE IF NOT EXISTS car_telemetry_samples ( CREATE TABLE IF NOT EXISTS car_telemetry_samples (
recorded_at INTEGER PRIMARY KEY, recorded_at INTEGER PRIMARY KEY,
speed_kph REAL, speed_kph REAL,
+46 -14
View File
@@ -8,6 +8,7 @@ import type { CarTelemetryRecorder } from "./car-telemetry.js";
import type { CompanionDatabase } from "./database.js"; import type { CompanionDatabase } from "./database.js";
import { hashToken, randomToken } from "./security.js"; import { hashToken, randomToken } from "./security.js";
import { collectSystemStatus } from "./status.js"; import { collectSystemStatus } from "./status.js";
import { TripStore } from "./trips.js";
const pairingSchema = z.object({ const pairingSchema = z.object({
code: z.string().regex(/^\d{6}$/), code: z.string().regex(/^\d{6}$/),
@@ -71,6 +72,8 @@ export function registerMobileRoutes(
carTelemetry: CarTelemetryRecorder, carTelemetry: CarTelemetryRecorder,
requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
): void { ): void {
const trips = new TripStore(database);
app.post("/api/mobile/pairing", async (request, reply) => { app.post("/api/mobile/pairing", async (request, reply) => {
if (!requireWebUser(request, reply)) return; if (!requireWebUser(request, reply)) return;
const code = String(randomInt(100_000, 1_000_000)); const code = String(randomInt(100_000, 1_000_000));
@@ -94,6 +97,22 @@ export function registerMobileRoutes(
return { devices }; return { devices };
}); });
app.get("/api/trips", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
return trips.list();
});
app.get<{ Params: { id: string } }>("/api/trips/:id", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const id = Number(request.params.id);
if (!Number.isInteger(id) || id < 1) {
return reply.code(400).send({ error: { code: "INVALID_TRIP", message: "That trip is invalid" } });
}
const trip = trips.detail(id);
if (!trip) return reply.code(404).send({ error: { code: "TRIP_NOT_FOUND", message: "Trip not found" } });
return trip;
});
app.get("/api/mobile/bluetooth", async (request, reply) => { app.get("/api/mobile/bluetooth", async (request, reply) => {
if (!requireWebUser(request, reply)) return; if (!requireWebUser(request, reply)) return;
return readBluetoothStatus(); return readBluetoothStatus();
@@ -170,20 +189,33 @@ export function registerMobileRoutes(
const parsed = locationSchema.safeParse(request.body); const parsed = locationSchema.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } }); if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
const value = parsed.data; const value = parsed.data;
database.prepare( const trip = database.transaction(() => {
`INSERT INTO mobile_phone_state database.prepare(
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters, `INSERT INTO mobile_phone_state
speed_kph, bearing_degrees, battery_percent, network_type) (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET speed_kph, bearing_degrees, battery_percent, network_type)
received_at = excluded.received_at, captured_at = excluded.captured_at, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters, received_at = excluded.received_at, captured_at = excluded.captured_at,
altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph, latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent, altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
network_type = excluded.network_type` bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters, network_type = excluded.network_type`
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null, ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
value.batteryPercent ?? null, value.networkType ?? null); value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
return { accepted: true }; value.batteryPercent ?? null, value.networkType ?? null);
return trips.record({
deviceId: device.id,
capturedAt: value.capturedAt,
latitude: value.latitude,
longitude: value.longitude,
accuracyMeters: value.accuracyMeters,
altitudeMeters: value.altitudeMeters ?? null,
speedKph: value.speedKph ?? null,
bearingDegrees: value.bearingDegrees ?? null,
phoneBatteryPercent: value.batteryPercent ?? null
});
})();
return { accepted: true, trip };
}); });
app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => { app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
+264
View File
@@ -0,0 +1,264 @@
import type { CompanionDatabase } from "./database.js";
const START_SPEED_KPH = 5;
const STOP_TIMEOUT_MS = 3 * 60_000;
const GAP_TIMEOUT_MS = 10 * 60_000;
const MAX_LOCATION_ACCURACY_METERS = 100;
const CAR_SAMPLE_WINDOW_MS = 30_000;
export type LocationSample = {
deviceId: string;
capturedAt: string;
latitude: number;
longitude: number;
accuracyMeters: number;
altitudeMeters?: number | null;
speedKph?: number | null;
bearingDegrees?: number | null;
phoneBatteryPercent?: number | null;
};
type CarSample = {
speed_kph: number | null;
battery_percent: number | null;
total_consumption_kwh: number | null;
};
type ActiveTrip = {
id: number;
started_at: number;
last_moving_at: number;
end_latitude: number;
end_longitude: number;
};
type PreviousPoint = {
captured_at: number;
latitude: number;
longitude: number;
speed_kph: number | null;
};
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function haversineKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number {
const radians = Math.PI / 180;
const lat1 = a.latitude * radians;
const lat2 = b.latitude * radians;
const deltaLat = (b.latitude - a.latitude) * radians;
const deltaLon = (b.longitude - a.longitude) * radians;
const value = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 6_371 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
export class TripStore {
constructor(private readonly database: CompanionDatabase) {}
record(sample: LocationSample): { tripId: number | null; active: boolean } {
const capturedAt = Date.parse(sample.capturedAt);
if (!Number.isFinite(capturedAt) || sample.accuracyMeters > MAX_LOCATION_ACCURACY_METERS) {
return { tripId: null, active: false };
}
const car = this.database.prepare(
`SELECT speed_kph, battery_percent, total_consumption_kwh
FROM car_telemetry_samples WHERE recorded_at BETWEEN ? AND ?
ORDER BY ABS(recorded_at - ?) LIMIT 1`
).get(capturedAt - CAR_SAMPLE_WINDOW_MS, capturedAt + CAR_SAMPLE_WINDOW_MS, capturedAt) as CarSample | undefined;
const speedKph = finite(car?.speed_kph) ?? finite(sample.speedKph);
const moving = (speedKph ?? 0) >= START_SPEED_KPH;
let active = this.activeTrip(sample.deviceId);
if (active && capturedAt <= active.started_at) return { tripId: active.id, active: true };
if (active && capturedAt - active.last_moving_at >= GAP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
active = undefined;
}
if (!active && !moving) return { tripId: null, active: false };
if (!active) {
const result = this.database.prepare(
`INSERT INTO trips
(device_id, started_at, last_moving_at, start_latitude, start_longitude,
end_latitude, end_longitude, peak_speed_kph, peak_speed_at,
peak_speed_latitude, peak_speed_longitude, vehicle_battery_start,
vehicle_battery_end, vehicle_consumption_start_kwh, vehicle_consumption_end_kwh,
phone_battery_start, phone_battery_end)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sample.deviceId, capturedAt, capturedAt, sample.latitude, sample.longitude,
sample.latitude, sample.longitude, speedKph, capturedAt, sample.latitude, sample.longitude,
finite(car?.battery_percent), finite(car?.battery_percent), finite(car?.total_consumption_kwh),
finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), finite(sample.phoneBatteryPercent)
);
active = {
id: Number(result.lastInsertRowid),
started_at: capturedAt,
last_moving_at: capturedAt,
end_latitude: sample.latitude,
end_longitude: sample.longitude
};
}
const previous = this.database.prepare(
`SELECT captured_at, latitude, longitude, speed_kph FROM trip_points
WHERE trip_id = ? ORDER BY captured_at DESC LIMIT 1`
).get(active.id) as PreviousPoint | undefined;
const elapsedSeconds = previous ? Math.max(0, (capturedAt - previous.captured_at) / 1_000) : 0;
const distanceKm = previous && elapsedSeconds <= GAP_TIMEOUT_MS / 1_000
? haversineKm(previous, sample)
: 0;
const plausibleDistanceKm = Math.max(0.25, elapsedSeconds / 3_600 * 250);
const segmentMoving = moving || (previous?.speed_kph ?? 0) >= START_SPEED_KPH;
const acceptedDistanceKm = segmentMoving && distanceKm <= plausibleDistanceKm ? distanceKm : 0;
const movingSeconds = segmentMoving && previous ? Math.min(elapsedSeconds, 30) : 0;
const transaction = this.database.transaction(() => {
const inserted = this.database.prepare(
`INSERT OR IGNORE INTO trip_points
(trip_id, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, vehicle_battery_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
active!.id, capturedAt, sample.latitude, sample.longitude, sample.accuracyMeters,
sample.altitudeMeters ?? null, speedKph, sample.bearingDegrees ?? null,
finite(car?.battery_percent)
);
if (inserted.changes === 0) return;
this.database.prepare(
`UPDATE trips SET
last_moving_at = CASE WHEN ? THEN ? ELSE last_moving_at END,
end_latitude = CASE WHEN ? THEN ? ELSE end_latitude END,
end_longitude = CASE WHEN ? THEN ? ELSE end_longitude END,
distance_km = distance_km + ?, moving_seconds = moving_seconds + ?,
point_count = point_count + 1,
speed_sum = speed_sum + ?, speed_count = speed_count + ?,
peak_speed_at = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_at END,
peak_speed_latitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_latitude END,
peak_speed_longitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_longitude END,
peak_speed_kph = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_kph END,
vehicle_battery_end = COALESCE(?, vehicle_battery_end),
vehicle_consumption_end_kwh = COALESCE(?, vehicle_consumption_end_kwh),
phone_battery_end = COALESCE(?, phone_battery_end)
WHERE id = ?`
).run(
moving ? 1 : 0, capturedAt, moving ? 1 : 0, sample.latitude, moving ? 1 : 0, sample.longitude,
acceptedDistanceKm, movingSeconds, moving && speedKph !== null ? speedKph : 0, moving && speedKph !== null ? 1 : 0,
speedKph, speedKph, capturedAt,
speedKph, speedKph, sample.latitude,
speedKph, speedKph, sample.longitude,
speedKph, speedKph, speedKph,
finite(car?.battery_percent), finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), active!.id
);
});
transaction();
if (!moving && capturedAt - active.last_moving_at >= STOP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
return { tripId: active.id, active: false };
}
return { tripId: active.id, active: true };
}
list() {
this.finalizeStale();
const rows = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id
ORDER BY started_at DESC`
).all() as Record<string, unknown>[];
return { trips: rows.map((row) => this.summary(row)) };
}
detail(id: number) {
this.finalizeStale();
const row = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id WHERE trips.id = ?`
).get(id) as Record<string, unknown> | undefined;
if (!row) return null;
const endedAt = finite(row.ended_at);
const points = this.database.prepare(
`SELECT captured_at AS capturedAt, latitude, longitude, accuracy_meters AS accuracyMeters,
altitude_meters AS altitudeMeters, speed_kph AS speedKph, bearing_degrees AS bearingDegrees,
vehicle_battery_percent AS vehicleBatteryPercent
FROM trip_points WHERE trip_id = ? AND captured_at <= ? ORDER BY captured_at`
).all(id, endedAt ?? Date.now());
return {
...this.summary(row),
points,
pins: [
{ type: "start", latitude: Number(row.start_latitude), longitude: Number(row.start_longitude), capturedAt: Number(row.started_at) },
finite(row.peak_speed_latitude) === null ? null : {
type: "peak-speed", latitude: Number(row.peak_speed_latitude), longitude: Number(row.peak_speed_longitude),
capturedAt: finite(row.peak_speed_at), speedKph: finite(row.peak_speed_kph)
},
{ type: "end", latitude: Number(row.end_latitude), longitude: Number(row.end_longitude), capturedAt: endedAt }
].filter(Boolean)
};
}
private activeTrip(deviceId: string): ActiveTrip | undefined {
return this.database.prepare(
`SELECT id, started_at, last_moving_at, end_latitude, end_longitude
FROM trips WHERE device_id = ? AND ended_at IS NULL`
).get(deviceId) as ActiveTrip | undefined;
}
private finish(id: number, endedAt: number, latitude: number, longitude: number): void {
this.database.transaction(() => {
this.database.prepare("DELETE FROM trip_points WHERE trip_id = ? AND captured_at > ?").run(id, endedAt);
this.database.prepare(
`UPDATE trips SET ended_at = ?, end_latitude = ?, end_longitude = ?,
point_count = (SELECT COUNT(*) FROM trip_points WHERE trip_id = ?)
WHERE id = ? AND ended_at IS NULL`
).run(endedAt, latitude, longitude, id, id);
})();
}
private finalizeStale(): void {
const cutoff = Date.now() - STOP_TIMEOUT_MS;
const stale = this.database.prepare(
`SELECT id, last_moving_at, end_latitude, end_longitude FROM trips
WHERE ended_at IS NULL AND last_moving_at < ?`
).all(cutoff) as Array<{ id: number; last_moving_at: number; end_latitude: number; end_longitude: number }>;
for (const trip of stale) this.finish(trip.id, trip.last_moving_at, trip.end_latitude, trip.end_longitude);
}
private summary(row: Record<string, unknown>) {
const startedAt = Number(row.started_at);
const endedAt = finite(row.ended_at);
const vehicleStart = finite(row.vehicle_battery_start);
const vehicleEnd = finite(row.vehicle_battery_end);
const consumptionStart = finite(row.vehicle_consumption_start_kwh);
const consumptionEnd = finite(row.vehicle_consumption_end_kwh);
const speedCount = Number(row.speed_count ?? 0);
return {
id: Number(row.id),
deviceId: String(row.device_id),
deviceName: String(row.device_name),
startedAt,
endedAt,
active: endedAt === null,
durationSeconds: Math.max(0, ((endedAt ?? Date.now()) - startedAt) / 1_000),
movingSeconds: Number(row.moving_seconds ?? 0),
distanceKm: Number(row.distance_km ?? 0),
pointCount: Number(row.point_count ?? 0),
averageSpeedKph: speedCount > 0 ? Number(row.speed_sum) / speedCount : null,
peakSpeedKph: finite(row.peak_speed_kph),
vehicleBatteryStartPercent: vehicleStart,
vehicleBatteryEndPercent: vehicleEnd,
batteryConsumedPercent: vehicleStart !== null && vehicleEnd !== null ? Math.max(0, vehicleStart - vehicleEnd) : null,
energyConsumedKwh: consumptionStart !== null && consumptionEnd !== null
? Math.max(0, consumptionEnd - consumptionStart)
: null,
phoneBatteryStartPercent: finite(row.phone_battery_start),
phoneBatteryEndPercent: finite(row.phone_battery_end),
start: { latitude: Number(row.start_latitude), longitude: Number(row.start_longitude) },
end: { latitude: Number(row.end_latitude), longitude: Number(row.end_longitude) }
};
}
}
+12
View File
@@ -357,6 +357,7 @@ describe("authentication boundary", () => {
expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401); expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401); expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/trips" })).statusCode).toBe(401);
const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } }); const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } });
expect(bluetooth.statusCode).toBe(200); expect(bluetooth.statusCode).toBe(200);
expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) }); expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) });
@@ -407,6 +408,17 @@ describe("authentication boundary", () => {
shortcuts: [] shortcuts: []
}); });
const trips = await app.inject({ method: "GET", url: "/api/trips", headers: { cookie } });
expect(trips.statusCode).toBe(200);
const trip = trips.json<{ trips: Array<{ id: number; peakSpeedKph: number; pointCount: number }> }>().trips[0];
expect(trip).toMatchObject({ peakSpeedKph: 42.5, pointCount: 1 });
const tripDetail = await app.inject({ method: "GET", url: `/api/trips/${trip!.id}`, headers: { cookie } });
expect(tripDetail.statusCode).toBe(200);
expect(tripDetail.json()).toMatchObject({
points: [expect.objectContaining({ latitude: 55.6761, longitude: 12.5683 })],
pins: expect.arrayContaining([expect.objectContaining({ type: "peak-speed", speedKph: 42.5 })])
});
const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } }); const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } });
expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] }); expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] });
expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204); expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204);
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest";
import { openDatabase, type CompanionDatabase } from "../src/database.js";
import { TripStore } from "../src/trips.js";
let database: CompanionDatabase | null = null;
function setup() {
database = openDatabase(":memory:");
database.prepare(
`INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at)
VALUES ('phone', 'Test phone', 'token', '2026-08-01T10:00:00.000Z', '2026-08-01T10:00:00.000Z')`
).run();
return new TripStore(database);
}
function location(capturedAt: string, longitude: number, speedKph: number) {
return {
deviceId: "phone",
capturedAt,
latitude: 55.6761,
longitude,
accuracyMeters: 4,
speedKph,
phoneBatteryPercent: 80
};
}
afterEach(() => {
database?.close();
database = null;
});
describe("trip recording", () => {
it("starts on movement, records a route, and ends after stationary timeout", () => {
const trips = setup();
expect(trips.record(location("2026-08-01T10:00:00.000Z", 12.5683, 0))).toEqual({ tripId: null, active: false });
const started = trips.record(location("2026-08-01T10:00:10.000Z", 12.5684, 20));
expect(started.active).toBe(true);
trips.record(location("2026-08-01T10:01:10.000Z", 12.5784, 45));
trips.record(location("2026-08-01T10:04:11.000Z", 12.5784, 0));
const list = trips.list().trips;
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ active: false, pointCount: 2, peakSpeedKph: 45 });
expect(list[0]!.distanceKm).toBeGreaterThan(0.5);
const detail = trips.detail(started.tripId!);
expect(detail?.points).toHaveLength(2);
expect(detail?.pins).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "start" }),
expect.objectContaining({ type: "peak-speed", speedKph: 45 }),
expect.objectContaining({ type: "end" })
]));
});
it("uses nearby vehicle telemetry for speed and battery statistics", () => {
const trips = setup();
const first = Date.parse("2026-08-01T12:00:00.000Z");
database!.prepare(
`INSERT INTO car_telemetry_samples
(recorded_at, speed_kph, battery_percent, total_consumption_kwh)
VALUES (?, 60, 78, 12.4), (?, 40, 74, 14.1)`
).run(first, first + 60_000);
const started = trips.record(location("2026-08-01T12:00:00.000Z", 12.5683, 0));
trips.record(location("2026-08-01T12:01:00.000Z", 12.5783, 0));
const detail = trips.detail(started.tripId!);
expect(detail).toMatchObject({
peakSpeedKph: 60,
vehicleBatteryStartPercent: 78,
vehicleBatteryEndPercent: 74,
batteryConsumedPercent: 4,
energyConsumedKwh: expect.closeTo(1.7, 5)
});
});
it("does not create trips from inaccurate or stationary locations", () => {
const trips = setup();
trips.record({ ...location("2026-08-01T13:00:00.000Z", 12.5, 80), accuracyMeters: 250 });
trips.record(location("2026-08-01T13:01:00.000Z", 12.5, 2));
expect(trips.list().trips).toEqual([]);
});
});
+2 -1
View File
@@ -7,6 +7,7 @@ Wants=pi-car-companion.service
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/libexec/pi-car-companion-bluetooth ExecStart=/usr/local/libexec/pi-car-companion-bluetooth
ExecStartPost=/usr/bin/timeout 5 /usr/bin/script -qec '/usr/bin/btmgmt add-adv -c -u 6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001 1' /dev/null
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
NoNewPrivileges=true NoNewPrivileges=true
@@ -18,7 +19,7 @@ ProtectKernelModules=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_BLUETOOTH
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+2
View File
@@ -14,10 +14,12 @@
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"leaflet": "^1.9.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
+13 -6
View File
@@ -13,6 +13,7 @@ import {
GitBranch, GitBranch,
DownloadSimple, DownloadSimple,
Power, Power,
Path,
SignOut, SignOut,
TerminalWindow, TerminalWindow,
Trash, Trash,
@@ -46,10 +47,15 @@ const ShellTerminal = lazy(async () => {
return { default: module.ShellTerminal }; return { default: module.ShellTerminal };
}); });
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal"; const TripsPage = lazy(async () => {
type DashboardPage = "home" | "car" | "adb" | "network" | "settings"; const module = await import("./TripsPage");
return { default: module.TripsPage };
});
const dashboardPages = new Set<DashboardPage>(["home", "car", "adb", "network", "settings"]); type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
type DashboardPage = "home" | "car" | "trips" | "adb" | "network" | "settings";
const dashboardPages = new Set<DashboardPage>(["home", "car", "trips", "adb", "network", "settings"]);
function pageFromLocation(): DashboardPage { function pageFromLocation(): DashboardPage {
const page = window.location.hash.replace(/^#\/?/, ""); const page = window.location.hash.replace(/^#\/?/, "");
@@ -233,6 +239,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<nav aria-label="Primary navigation"> <nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => navigate("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button> <button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => navigate("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => navigate("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button> <button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => navigate("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
<button className={`nav-item ${page === "trips" ? "active" : ""}`} onClick={() => navigate("trips")}><Path size={24} weight={page === "trips" ? "fill" : "regular"} /><span>Trips</span></button>
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => navigate("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button> <button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => navigate("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => navigate("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button> <button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => navigate("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => navigate("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button> <button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => navigate("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
@@ -242,8 +249,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<main className="dashboard"> <main className="dashboard">
<header className="dashboard-header"> <header className="dashboard-header">
<div> <div>
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p> <p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "trips" ? "Drive history" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1> <h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "trips" ? "Trips" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<div className={`connection-state ${connection}`}> <div className={`connection-state ${connection}`}>
@@ -258,7 +265,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <> {page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>} {error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />} {!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />} </> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "trips" ? <Suspense fallback={<div className="trips-skeleton"><div /><div /><div /></div>}><TripsPage /></Suspense> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main> </main>
</div> </div>
); );
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import {
ArrowClockwise,
BatteryMedium,
Clock,
FlagCheckered,
MapPin,
NavigationArrow,
Path,
Speedometer
} from "@phosphor-icons/react";
import { getTrip, getTrips, type TripDetail, type TripSummary } from "./api";
function formatDistance(value: number): string {
return value < 1 ? `${Math.round(value * 1_000)} m` : `${value.toFixed(1)} km`;
}
function formatDuration(seconds: number): string {
const minutes = Math.max(0, Math.round(seconds / 60));
const hours = Math.floor(minutes / 60);
return hours ? `${hours}h ${minutes % 60}m` : `${minutes} min`;
}
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(timestamp);
}
function value(value_: number | null, suffix: string, digits = 0): string {
return value_ === null ? "Unavailable" : `${value_.toFixed(digits)}${suffix}`;
}
export function TripsPage() {
const [trips, setTrips] = useState<TripSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<TripDetail | null>(null);
const [error, setError] = useState("");
const [detailError, setDetailError] = useState("");
const [refreshing, setRefreshing] = useState(false);
async function refresh() {
setRefreshing(true);
try {
const nextTrips = await getTrips();
setTrips(nextTrips);
setError("");
if (selectedId !== null && !nextTrips.some((trip) => trip.id === selectedId)) {
setSelectedId(null);
setDetail(null);
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Trips could not be loaded");
} finally {
setRefreshing(false);
}
}
useEffect(() => { void refresh(); }, []);
useEffect(() => {
if (selectedId === null) return;
setDetail(null);
setDetailError("");
void getTrip(selectedId)
.then(setDetail)
.catch((caught) => setDetailError(caught instanceof Error ? caught.message : "Trip details could not be loaded"));
}, [selectedId]);
return (
<section className="trips-page">
<div className="trips-toolbar">
<div>
<strong>{trips?.length ?? 0} recorded trips</strong>
<span>Trips start above 5 km/h and close after three stationary minutes.</span>
</div>
<button className="secondary-button trips-refresh" onClick={() => void refresh()} disabled={refreshing}>
<ArrowClockwise size={21} className={refreshing ? "spin" : ""} /> Refresh
</button>
</div>
{error && <div className="status-error" role="alert">{error}<button onClick={() => void refresh()}>Try again</button></div>}
{trips === null && !error ? <TripsSkeleton /> : trips?.length === 0 ? <EmptyTrips /> : (
<div className="trips-workspace">
<div className="trip-list" aria-label="Recorded trips">
{trips?.map((trip) => (
<button
key={trip.id}
className={`trip-row ${selectedId === trip.id ? "selected" : ""}`}
onClick={() => setSelectedId(trip.id)}
aria-pressed={selectedId === trip.id}
>
<span className="trip-row-route"><NavigationArrow size={20} weight="fill" /></span>
<span className="trip-row-main">
<strong>{formatDate(trip.startedAt)}</strong>
<small>{trip.active ? "Recording now" : `${formatDuration(trip.durationSeconds)} · ${trip.deviceName}`}</small>
</span>
<span className="trip-row-distance">{formatDistance(trip.distanceKm)}</span>
<span className="trip-row-speed">{value(trip.peakSpeedKph, " km/h")}</span>
</button>
))}
</div>
<div className="trip-detail">
{detailError ? <div className="trip-detail-state" role="alert"><MapPin size={34} />{detailError}</div>
: selectedId === null ? <div className="trip-detail-state"><Path size={42} /><strong>Select a trip</strong><span>Open a drive to inspect its route, statistics, and notable points.</span></div>
: detail === null ? <TripDetailSkeleton /> : <TripReport trip={detail} />}
</div>
</div>
)}
</section>
);
}
function TripReport({ trip }: { trip: TripDetail }) {
return (
<article className="trip-report">
<div className="trip-report-heading">
<div>
<span>{trip.active ? "Live trip" : "Trip report"}</span>
<h2>{formatDate(trip.startedAt)}</h2>
<p>{formatDuration(trip.durationSeconds)} recorded by {trip.deviceName}</p>
</div>
{trip.active && <span className="trip-live"><i /> Recording</span>}
</div>
<TripMap trip={trip} />
<div className="trip-stat-grid">
<TripStat icon={<Path size={22} />} label="Distance" value={formatDistance(trip.distanceKm)} />
<TripStat icon={<Speedometer size={22} />} label="Peak speed" value={value(trip.peakSpeedKph, " km/h")} />
<TripStat icon={<NavigationArrow size={22} />} label="Average speed" value={value(trip.averageSpeedKph, " km/h")} />
<TripStat icon={<Clock size={22} />} label="Duration" value={formatDuration(trip.durationSeconds)} />
<TripStat icon={<BatteryMedium size={22} />} label="Vehicle battery used" value={value(trip.batteryConsumedPercent, "%", 1)} />
<TripStat icon={<BatteryMedium size={22} />} label="Energy used" value={value(trip.energyConsumedKwh, " kWh", 2)} />
</div>
<div className="trip-report-footer">
<span><MapPin size={18} /> {trip.pointCount.toLocaleString()} GPS samples</span>
<span><FlagCheckered size={18} /> {trip.endedAt ? `Ended ${formatDate(trip.endedAt)}` : "Trip in progress"}</span>
</div>
</article>
);
}
function TripStat({ icon, label, value: displayValue }: { icon: React.ReactNode; label: string; value: string }) {
return <div className="trip-stat"><span>{icon}{label}</span><strong>{displayValue}</strong></div>;
}
function TripMap({ trip }: { trip: TripDetail }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!host.current || trip.points.length === 0) return;
const map = L.map(host.current, { zoomControl: true, attributionControl: true });
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const coordinates = trip.points.map((point) => L.latLng(point.latitude, point.longitude));
L.polyline(coordinates, { color: "#0b1005", weight: 9, opacity: 0.72, lineCap: "round" }).addTo(map);
L.polyline(coordinates, { color: "#b9e769", weight: 5, opacity: 0.95, lineCap: "round" }).addTo(map);
const bounds = L.latLngBounds(coordinates);
if (bounds.isValid() && coordinates.length > 1) map.fitBounds(bounds, { padding: [34, 34], maxZoom: 16 });
else map.setView(coordinates[0]!, 16);
for (const pin of trip.pins) {
const peak = pin.type === "peak-speed";
const marker = L.circleMarker([pin.latitude, pin.longitude], {
radius: peak ? 9 : 7,
color: peak ? "#10130f" : "#f0f2eb",
weight: 3,
fillColor: peak ? "#b9e769" : pin.type === "start" ? "#f0f2eb" : "#8f9a88",
fillOpacity: 1
}).addTo(map);
marker.bindTooltip(peak ? `Top speed · ${value(pin.speedKph ?? null, " km/h")}` : pin.type === "start" ? "Trip start" : "Trip end");
}
return () => { map.remove(); };
}, [trip]);
if (trip.points.length === 0) return <div className="trip-map-empty"><MapPin size={34} />No route points were retained for this trip.</div>;
return <div className="trip-map" ref={host} aria-label="Map showing the trip route" />;
}
function EmptyTrips() {
return <div className="trips-empty"><Path size={46} /><h2>No trips recorded yet</h2><p>Pair the Android companion and leave its foreground service running. Your first drive will appear here automatically.</p></div>;
}
function TripsSkeleton() {
return <div className="trips-skeleton" aria-label="Loading trips"><div /><div /><div /></div>;
}
function TripDetailSkeleton() {
return <div className="trip-detail-skeleton" aria-label="Loading trip report"><div /><div /><div /></div>;
}
+55
View File
@@ -130,6 +130,50 @@ export type BluetoothStatus = {
message: string; message: string;
}; };
export type TripSummary = {
id: number;
deviceId: string;
deviceName: string;
startedAt: number;
endedAt: number | null;
active: boolean;
durationSeconds: number;
movingSeconds: number;
distanceKm: number;
pointCount: number;
averageSpeedKph: number | null;
peakSpeedKph: number | null;
vehicleBatteryStartPercent: number | null;
vehicleBatteryEndPercent: number | null;
batteryConsumedPercent: number | null;
energyConsumedKwh: number | null;
phoneBatteryStartPercent: number | null;
phoneBatteryEndPercent: number | null;
start: { latitude: number; longitude: number };
end: { latitude: number; longitude: number };
};
export type TripPoint = {
capturedAt: number;
latitude: number;
longitude: number;
accuracyMeters: number;
altitudeMeters: number | null;
speedKph: number | null;
bearingDegrees: number | null;
vehicleBatteryPercent: number | null;
};
export type TripPin = {
type: "start" | "end" | "peak-speed";
latitude: number;
longitude: number;
capturedAt: number | null;
speedKph?: number | null;
};
export type TripDetail = TripSummary & { points: TripPoint[]; pins: TripPin[] };
export type CarTelemetry = { export type CarTelemetry = {
available: true; available: true;
collectedAt: string; collectedAt: string;
@@ -274,6 +318,17 @@ export async function getBluetoothStatus(): Promise<BluetoothStatus> {
return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" })); return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" }));
} }
export async function getTrips(): Promise<TripSummary[]> {
const response = await parseResponse<{ trips: TripSummary[] }>(
await fetch("/api/trips", { credentials: "same-origin" })
);
return response.trips;
}
export async function getTrip(id: number): Promise<TripDetail> {
return parseResponse<TripDetail>(await fetch(`/api/trips/${id}`, { credentials: "same-origin" }));
}
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> { export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, { await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
method: "DELETE", method: "DELETE",
+70
View File
@@ -408,6 +408,63 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.clear-statistics-options .danger-button small { color: #e7b8b1; } .clear-statistics-options .danger-button small { color: #e7b8b1; }
.status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; } .status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; }
.trips-page { min-width: 0; display: grid; gap: 18px; }
.trips-toolbar { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px 14px 22px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trips-toolbar > div { min-width: 0; display: grid; gap: 5px; }
.trips-toolbar strong { font-size: 0.95rem; }
.trips-toolbar span { color: var(--muted); font-size: 0.76rem; line-height: 1.4; }
.trips-refresh { min-height: 46px; padding: 0 17px; }
.trips-workspace { min-width: 0; display: grid; grid-template-columns: minmax(300px, 0.72fr) minmax(510px, 1.28fr); gap: 18px; align-items: start; }
.trip-list { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trip-row { width: 100%; min-width: 0; min-height: 84px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 5px 13px; padding: 13px 16px; color: var(--text); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; transition: background 140ms ease; }
.trip-row:last-child { border-bottom: 0; }
.trip-row:hover { background: var(--surface-raised); }
.trip-row.selected { background: rgba(185, 231, 105, 0.09); box-shadow: inset 3px 0 var(--accent); }
.trip-row-route { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 9px; }
.trip-row-main { min-width: 0; display: grid; gap: 5px; }
.trip-row-main strong, .trip-row-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trip-row-main strong { font-size: 0.84rem; }
.trip-row-main small { color: var(--muted); font-size: 0.7rem; }
.trip-row-distance { justify-self: end; font: 750 0.88rem/1 ui-monospace, "Cascadia Code", monospace; }
.trip-row-speed { grid-column: 3; justify-self: end; color: var(--muted); font: 0.67rem/1 ui-monospace, "Cascadia Code", monospace; }
.trip-detail { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trip-detail-state { min-height: 540px; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 32px; color: var(--muted); text-align: center; }
.trip-detail-state svg { color: var(--accent); }
.trip-detail-state strong { color: var(--text); font-size: 1.15rem; }
.trip-detail-state span { max-width: 370px; font-size: 0.8rem; line-height: 1.5; }
.trip-report-heading { min-height: 106px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 23px; }
.trip-report-heading > div > span { color: var(--accent); font: 700 0.67rem/1.2 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.1em; text-transform: uppercase; }
.trip-report-heading h2 { margin: 7px 0 5px; font-size: 1.3rem; }
.trip-report-heading p { margin: 0; color: var(--muted); font-size: 0.76rem; }
.trip-live { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 0.75rem; font-weight: 750; }
.trip-live i { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.1); }
.trip-map { position: relative; z-index: 0; width: 100%; height: clamp(340px, 46dvh, 500px); background: #222820; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.trip-map .leaflet-tile-pane { filter: saturate(0.45) brightness(0.72) contrast(1.08); }
.trip-map .leaflet-control-zoom a { color: var(--text); background: #171b16; border-color: var(--line); }
.trip-map .leaflet-control-zoom a:hover { background: #242a22; }
.trip-map .leaflet-control-attribution { color: #cbd3c5; background: rgba(16, 19, 15, 0.86); }
.trip-map .leaflet-control-attribution a { color: var(--accent); }
.trip-map .leaflet-tooltip { color: var(--text); background: #171b16; border: 1px solid #465043; box-shadow: none; font-weight: 700; }
.trip-map .leaflet-tooltip::before { border-top-color: #465043; }
.trip-map-empty { min-height: 340px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.trip-map-empty svg { color: var(--accent); }
.trip-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--line); }
.trip-stat { min-width: 0; min-height: 106px; display: grid; align-content: center; gap: 12px; padding: 17px 19px; background: var(--surface); }
.trip-stat span { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.72rem; }
.trip-stat svg { flex: none; color: var(--accent); }
.trip-stat strong { overflow: hidden; font: 750 1.16rem/1 ui-monospace, "Cascadia Code", monospace; text-overflow: ellipsis; white-space: nowrap; }
.trip-report-footer { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 20px; color: var(--muted); font-size: 0.7rem; }
.trip-report-footer span { display: flex; align-items: center; gap: 7px; }
.trip-report-footer svg { color: var(--accent); }
.trips-empty { min-height: 460px; display: grid; place-content: center; justify-items: center; padding: 36px; color: var(--muted); text-align: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trips-empty svg { margin-bottom: 19px; color: var(--accent); }
.trips-empty h2 { margin: 0 0 10px; color: var(--text); }
.trips-empty p { max-width: 500px; margin: 0; font-size: 0.84rem; line-height: 1.55; }
.trips-skeleton { min-height: 420px; display: grid; grid-template-columns: 0.72fr 1.28fr; gap: 18px; }
.trips-skeleton div, .trip-detail-skeleton div { border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 30%, var(--surface-raised) 50%, var(--surface) 70%); background-size: 220% 100%; animation: shimmer 1.4s ease infinite; }
.trips-skeleton div:first-child { grid-row: 1 / 3; }
.trip-detail-skeleton { min-height: 540px; display: grid; grid-template-rows: 90px 1fr 120px; gap: 1px; padding: 18px; }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.adb-command-grid { grid-template-columns: 1fr; } .adb-command-grid { grid-template-columns: 1fr; }
.drive-stage { grid-template-columns: 1fr 1.25fr; } .drive-stage { grid-template-columns: 1fr 1.25fr; }
@@ -417,6 +474,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.telemetry-source { grid-column: 1 / -1; } .telemetry-source { grid-column: 1 / -1; }
.record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-grid { grid-template-columns: 1fr; } .chart-grid { grid-template-columns: 1fr; }
.trips-workspace { grid-template-columns: minmax(280px, 0.7fr) minmax(440px, 1.3fr); }
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -446,4 +505,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); } .telemetry-storage { grid-template-columns: auto minmax(0, 1fr); }
.telemetry-storage-summary { grid-column: 1 / -1; text-align: left; } .telemetry-storage-summary { grid-column: 1 / -1; text-align: left; }
.telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; } .telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; }
.trips-toolbar { align-items: flex-start; }
.trips-toolbar span { display: none; }
.trips-refresh { width: 46px; padding: 0; font-size: 0; }
.trips-workspace { grid-template-columns: 1fr; }
.trip-detail { min-height: 420px; }
.trip-detail-state { min-height: 420px; }
.trip-map { height: 52dvh; min-height: 330px; }
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trip-report-footer { align-items: flex-start; flex-direction: column; }
.trips-skeleton { grid-template-columns: 1fr; }
.trips-skeleton div:first-child { grid-row: auto; min-height: 260px; }
} }