diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 94938c7c3f4..cdf512f53d4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -208,6 +208,7 @@ dependencies {
implementation("com.github.AppDevNext:ChangeLog:3.4")
implementation("androidx.car.app:app:1.3.0-rc01")
+ "fullImplementation"("androidx.car.app:app-projected:1.2.0")
}
// Disable to fix memory leak and be compatible with the configuration cache.
diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index 13f1ed0fd6d..ed1f0bbd44f 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9399b9f0f16..5712d857fcc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
-
+
diff --git a/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt b/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt
new file mode 100644
index 00000000000..6ee6ec0125f
--- /dev/null
+++ b/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt
@@ -0,0 +1,296 @@
+package io.homeassistant.companion.android.sensors
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.car.app.hardware.common.CarValue
+import androidx.car.app.hardware.info.EnergyLevel
+import androidx.car.app.hardware.info.EvStatus
+import androidx.car.app.hardware.info.Mileage
+import androidx.car.app.hardware.info.Model
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.DefaultLifecycleObserver
+import io.homeassistant.companion.android.BuildConfig
+import io.homeassistant.companion.android.common.R
+import io.homeassistant.companion.android.common.sensors.SensorManager
+import io.homeassistant.companion.android.vehicle.HaCarAppService
+
+class CarSensorManager :
+ SensorManager,
+ DefaultLifecycleObserver {
+
+ companion object {
+ internal const val TAG = "CarSM"
+
+ private val fuelLevel = SensorManager.BasicSensor(
+ "car_fuel",
+ "sensor",
+ R.string.basic_sensor_name_car_fuel,
+ R.string.sensor_description_car_fuel,
+ "mdi:barrel",
+ unitOfMeasurement = "%",
+ stateClass = SensorManager.STATE_CLASS_MEASUREMENT,
+ deviceClass = "battery"
+ )
+ private val batteryLevel = SensorManager.BasicSensor(
+ "car_battery",
+ "sensor",
+ R.string.basic_sensor_name_car_battery,
+ R.string.sensor_description_car_battery,
+ "mdi:car-battery",
+ unitOfMeasurement = "%",
+ stateClass = SensorManager.STATE_CLASS_MEASUREMENT,
+ deviceClass = "battery",
+ entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC
+ )
+ private val carName = SensorManager.BasicSensor(
+ "car_name",
+ "sensor",
+ R.string.basic_sensor_name_car_name,
+ R.string.sensor_description_car_name,
+ "mdi:car-info"
+ )
+ private val carStatus = SensorManager.BasicSensor(
+ "car_charging_status",
+ "sensor",
+ R.string.basic_sensor_name_car_charging_status,
+ R.string.sensor_description_car_charging_status,
+ "mdi:ev-station",
+ deviceClass = "plug"
+ )
+ private val odometerValue = SensorManager.BasicSensor(
+ "car_odometer",
+ "sensor",
+ R.string.basic_sensor_name_car_odometer,
+ R.string.sensor_description_car_odometer,
+ "mdi:map-marker-distance",
+ unitOfMeasurement = "m",
+ stateClass = SensorManager.STATE_CLASS_TOTAL_INCREASING,
+ deviceClass = "distance"
+ )
+
+ private val sensorsList = listOf(
+ batteryLevel,
+ carName,
+ carStatus,
+ fuelLevel,
+ odometerValue
+ )
+
+ private enum class Listener {
+ ENERGY, MODEL, MILEAGE, STATUS,
+ }
+
+ private val listenerSensors = mapOf(
+ Listener.ENERGY to listOf(batteryLevel, fuelLevel),
+ Listener.MODEL to listOf(carName),
+ Listener.STATUS to listOf(carStatus),
+ Listener.MILEAGE to listOf(odometerValue)
+ )
+ private val listenerLastRegistered = mutableMapOf(
+ Listener.ENERGY to -1L,
+ Listener.MODEL to -1L,
+ Listener.STATUS to -1L,
+ Listener.MILEAGE to -1L
+ )
+ }
+
+ override val name: Int
+ get() = R.string.sensor_name_car
+
+ override suspend fun getAvailableSensors(context: Context): List {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ sensorsList
+ } else {
+ emptyList()
+ }
+ }
+
+ override fun hasSensor(context: Context): Boolean {
+ // TODO: show sensors for automotive (except odometer) once
+ // we can ask for special automotive permissions in requiredPermissions
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ !context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) &&
+ BuildConfig.FLAVOR == "full"
+ }
+
+ override fun requiredPermissions(sensorId: String): Array {
+ return when {
+ (sensorId == fuelLevel.id || sensorId == batteryLevel.id) -> {
+ arrayOf("com.google.android.gms.permission.CAR_FUEL")
+ }
+ sensorId == odometerValue.id -> {
+ arrayOf("com.google.android.gms.permission.CAR_MILEAGE")
+ }
+ else -> emptyArray()
+ }
+ }
+
+ private lateinit var context: Context
+
+ private fun allDisabled(): Boolean = sensorsList.none { isEnabled(context, it) }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun connected(): Boolean = HaCarAppService.carInfo != null
+
+ override fun requestSensorUpdate(context: Context) {
+ this.context = context.applicationContext
+ if (allDisabled()) {
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (connected()) {
+ updateCarInfo()
+ } else {
+ sensorsList.forEach {
+ if (isEnabled(context, it)) {
+ onSensorUpdated(
+ context,
+ it,
+ context.getString(R.string.car_data_unavailable),
+ it.statelessIcon,
+ mapOf()
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @androidx.annotation.OptIn(androidx.car.app.annotations.ExperimentalCarApi::class)
+ private fun setListener(l: Listener, enable: Boolean) {
+ if (enable) {
+ Log.d(TAG, "registering CarInfo $l listener")
+ } else {
+ Log.d(TAG, "unregistering CarInfo $l listener")
+ }
+
+ val car = HaCarAppService.carInfo ?: return
+ when (l) {
+ Listener.ENERGY -> {
+ if (enable) {
+ car.addEnergyLevelListener(ContextCompat.getMainExecutor(context), ::onEnergyAvailable)
+ } else {
+ car.removeEnergyLevelListener(::onEnergyAvailable)
+ }
+ }
+ Listener.MILEAGE -> {
+ if (enable) {
+ car.addMileageListener(ContextCompat.getMainExecutor(context), ::onMileageAvailable)
+ } else {
+ car.removeMileageListener(::onMileageAvailable)
+ }
+ }
+ Listener.MODEL -> {
+ if (enable) {
+ car.fetchModel(ContextCompat.getMainExecutor(context), ::onModelAvailable)
+ }
+ }
+ Listener.STATUS -> {
+ if (enable) {
+ car.addEvStatusListener(ContextCompat.getMainExecutor(context), ::onStatusAvailable)
+ } else {
+ car.removeEvStatusListener(::onStatusAvailable)
+ }
+ }
+ }
+
+ if (enable) {
+ listenerLastRegistered[l] = System.currentTimeMillis()
+ } else {
+ listenerLastRegistered[l] = -1L
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun updateCarInfo() {
+ for (l in listenerSensors) {
+ if (l.value.any { isEnabled(context, it) }) {
+ if (listenerLastRegistered[l.key] != -1L && listenerLastRegistered[l.key]!! + SensorManager.SENSOR_LISTENER_TIMEOUT < System.currentTimeMillis()) {
+ Log.d(TAG, "Re-registering CarInfo ${l.key} listener as it appears to be stuck")
+ setListener(l.key, false)
+ }
+
+ if (listenerLastRegistered[l.key] == -1L) {
+ setListener(l.key, true)
+ }
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun onEnergyAvailable(data: EnergyLevel) {
+ if (data.fuelPercent.status == CarValue.STATUS_SUCCESS && isEnabled(context, fuelLevel)) {
+ onSensorUpdated(
+ context,
+ fuelLevel,
+ data.fuelPercent.value!!,
+ fuelLevel.statelessIcon,
+ mapOf()
+ )
+ }
+ if (data.batteryPercent.status == CarValue.STATUS_SUCCESS && isEnabled(context, batteryLevel)) {
+ onSensorUpdated(
+ context,
+ batteryLevel,
+ data.batteryPercent.value!!,
+ batteryLevel.statelessIcon,
+ mapOf()
+ )
+ }
+ setListener(Listener.ENERGY, false)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun onModelAvailable(data: Model) {
+ if (data.name.status == CarValue.STATUS_SUCCESS && isEnabled(context, carName)) {
+ onSensorUpdated(
+ context,
+ carName,
+ data.name.value!!,
+ carName.statelessIcon,
+ mapOf(
+ "car_manufacturer" to data.manufacturer.value,
+ "car_manufactured_year" to data.year.value
+ )
+ )
+ }
+ setListener(Listener.MODEL, false)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @androidx.annotation.OptIn(androidx.car.app.annotations.ExperimentalCarApi::class)
+ fun onStatusAvailable(data: EvStatus) {
+ if (data.evChargePortConnected.status == CarValue.STATUS_SUCCESS && isEnabled(context, carStatus)) {
+ onSensorUpdated(
+ context,
+ carStatus,
+ data.evChargePortConnected.value == true,
+ carStatus.statelessIcon,
+ mapOf(
+ "car_charge_port_open" to (data.evChargePortOpen.value == true)
+ )
+ )
+ }
+ setListener(Listener.STATUS, false)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @androidx.annotation.OptIn(androidx.car.app.annotations.ExperimentalCarApi::class)
+ fun onMileageAvailable(data: Mileage) {
+ if (data.odometerMeters.status == CarValue.STATUS_SUCCESS && isEnabled(context, odometerValue)) {
+ onSensorUpdated(
+ context,
+ odometerValue,
+ data.odometerMeters.value!!,
+ odometerValue.statelessIcon,
+ mapOf()
+ )
+ }
+ setListener(Listener.MILEAGE, false)
+ }
+}
diff --git a/app/src/main/java/io/homeassistant/companion/android/sensors/SensorReceiver.kt b/app/src/main/java/io/homeassistant/companion/android/sensors/SensorReceiver.kt
index 417dd41e304..95357fdaa8c 100644
--- a/app/src/main/java/io/homeassistant/companion/android/sensors/SensorReceiver.kt
+++ b/app/src/main/java/io/homeassistant/companion/android/sensors/SensorReceiver.kt
@@ -58,6 +58,7 @@ class SensorReceiver : SensorReceiverBase() {
AudioSensorManager(),
BatterySensorManager(),
BluetoothSensorManager(),
+ CarSensorManager(),
DisplaySensorManager(),
DNDSensorManager(),
DynamicColorSensorManager(),
diff --git a/app/src/main/java/io/homeassistant/companion/android/vehicle/HaCarAppService.kt b/app/src/main/java/io/homeassistant/companion/android/vehicle/HaCarAppService.kt
index 614f8086088..1423af83d3e 100644
--- a/app/src/main/java/io/homeassistant/companion/android/vehicle/HaCarAppService.kt
+++ b/app/src/main/java/io/homeassistant/companion/android/vehicle/HaCarAppService.kt
@@ -10,6 +10,8 @@ import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.SessionInfo
+import androidx.car.app.hardware.CarHardwareManager
+import androidx.car.app.hardware.info.CarInfo
import androidx.car.app.validation.HostValidator
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
@@ -32,6 +34,8 @@ class HaCarAppService : CarAppService() {
companion object {
private const val TAG = "HaCarAppService"
+ var carInfo: CarInfo? = null
+ private set
}
@Inject
@@ -67,6 +71,8 @@ class HaCarAppService : CarAppService() {
)
override fun onCreateScreen(intent: Intent): Screen {
+ carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo
+
if (intent.getBooleanExtra("TRANSITION_LAUNCH", false)) {
carContext
.getCarService(ScreenManager::class.java).run {
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
index b50a9622b44..f8880dc46be 100644
--- a/common/src/main/res/values/strings.xml
+++ b/common/src/main/res/values/strings.xml
@@ -59,6 +59,11 @@
Beacon Monitor
Bluetooth State
Bluetooth Connection
+ Car Battery
+ Car Name
+ Charging Status
+ Fuel
+ Odometer
Charger Type
Is Charging
Current Time Zone
@@ -521,6 +526,11 @@
Scans for iBeacons and shows the IDs of nearby beacons and their distance in meters. A notification will be shown on the device when scanning is actively running.\n\nWarning: this can affect battery life, especially with a short \"Scan Interval\".\n\nSettings allow for specifying:\n- \"Filter Iterations\" (should be 1 - 100, default: 10), higher values will result in more stable measurements but also less responsiveness.\n- \"Filter RSSI Multiplier\" (should be 1.0 - 2.0, default: 1.05), can be used to archive more stable measurements when beacons are farther away. This will also affect responsiveness.\n- \"Scan Interval\" (default: 500) milliseconds between scans. Shorter intervals will drain the battery more quickly.\n- \"Scan Period\" (default: 1100) milliseconds to scan for beacons. Most beacons will send a signal every second so this value should be at least 1100ms.\n- \"UUID Filter\" allows to restrict the reported beacons by including (or excluding) those with the selected UUIDs.\n- \"Exclude selected UUIDs\", if false (default) only the beacons with the selected UUIDs are reported. If true all beacons except the selected ones are reported. Not available when \"UUID Filter\" is empty.\n\nNote:\nAdditionally a separate setting exists (\"Enable Beacon Monitor\") to stop or start scanning.
Information about currently connected Bluetooth devices
Whether Bluetooth is enabled on the device
+ Remaining car battery percentage
+ Car name, manufacturer and year
+ Charging status (only for EVs)
+ Remaining car fuel percentage
+ Odometer value
The type of charger plugged into the device currently
Whether the device is actively charging
The current time zone the device is in
@@ -592,6 +602,7 @@
Audio Sensors
Battery Sensors
Bluetooth Sensors
+ Car Sensors
Device Policy
Do Not Disturb Sensor
Geolocation Sensors
@@ -1098,4 +1109,5 @@
HA: Assist
Only Show Favorites
Beacon Monitor Scanning
+ Open Home Assistant app to activate the sensor