Compare commits
7 Commits
7bbf7aa4cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 972deb93df | |||
| 518ee028a3 | |||
| 15d13cc52f | |||
| d2934690a5 | |||
| e501895e56 | |||
| cb0ac06091 | |||
| 13c9f5d983 |
@@ -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.
@@ -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.
|
||||||
Generated
+25
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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'"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: '© <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>;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user