mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[bluelink] Initial contribution (#19823)
* Hyundai/Genesis Bluelink binding Signed-off-by: Marcus Better <marcus@better.se>
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
/bundles/org.openhab.binding.bambulab/ @magx2
|
||||
/bundles/org.openhab.binding.benqprojector/ @mlobstein
|
||||
/bundles/org.openhab.binding.bigassfan/ @mhilbush
|
||||
/bundles/org.openhab.binding.bluelink/ @marcusb
|
||||
/bundles/org.openhab.binding.bluetooth/ @cdjackson @cpmeister
|
||||
/bundles/org.openhab.binding.bluetooth.airthings/ @paulianttila
|
||||
/bundles/org.openhab.binding.bluetooth.am43/ @cpmeister
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
<artifactId>org.openhab.binding.bigassfan</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.bluelink</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.bluetooth</artifactId>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
@@ -0,0 +1,229 @@
|
||||
# Bluelink Binding (Hyundai/Genesis)
|
||||
|
||||
This binding integrates Hyundai vehicles equipped with Bluelink connected car services.
|
||||
It allows you to monitor your vehicle's status and control various features remotely.
|
||||
|
||||
Genesis vehicles may also work, though this has not been tested.
|
||||
|
||||
**NOTE: The binding only support the US region at the moment, because the Bluelink API
|
||||
differs by region.**
|
||||
|
||||
## Supported Things
|
||||
|
||||
| Thing Type | Description |
|
||||
|------------|----------------------------------------------|
|
||||
| `account` | Bridge representing your Bluelink account |
|
||||
| `vehicle` | A vehicle connected to your Bluelink account |
|
||||
|
||||
## Discovery
|
||||
|
||||
Once an account bridge is configured and online, the binding will automatically discover vehicles
|
||||
registered to your account.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
### `account` Bridge
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------------|----------|----------------------------------------------------------|
|
||||
| `username` | Yes | Bluelink account email |
|
||||
| `password` | Yes | Bluelink account password |
|
||||
| `pin` | No | Bluelink service PIN (required for lock/unlock commands) |
|
||||
| `region` | No | Country code (`US`), autodetected if absent |
|
||||
|
||||
### `vehicle` Thing
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|------------------------|----------|---------|---------------------------------------------------|
|
||||
| `vin` | Yes | - | Vehicle Identification Number (VIN) |
|
||||
| `refreshInterval` | No | 30 | Status refresh interval in minutes (cached) |
|
||||
| `forceRefreshInterval` | No | 240 | Status refresh interval in minutes (from vehicle) |
|
||||
|
||||
The `refreshInterval` parameter controls how often cached data is fetched from Kia/Hyundai servers.
|
||||
In contrast, `forceRefreshInterval` controls fetching of data from the vehicle.
|
||||
If this is too aggressive, you will quickly run into the daily rate limit.
|
||||
|
||||
Since we cannot fetch up-to-date data very often, the binding will often reflect stale data.
|
||||
The Force Refresh action can be used to refresh on demand.
|
||||
|
||||
## Actions
|
||||
|
||||
Vehicle things support the following actions:
|
||||
|
||||
| Action | Parameters | Description |
|
||||
|-----------------|------------------------------------------------------------|------------------------------------------------------------|
|
||||
| forceRefresh() | - | Fetch up-to-date data from the vehicle and update channels |
|
||||
| climateStart() | temperature, heated features, defrost, engine run duration | Start climate control |
|
||||
| climateStop() | - | Stop climate control |
|
||||
| lock() | - | Lock the vehicle |
|
||||
| unlock() | - | Unlock the vehicle |
|
||||
| startCharging() | - | Start charging the vehicle |
|
||||
| stopCharging() | - | Stop charging the verhicle |
|
||||
|
||||
## Channels
|
||||
|
||||
### Status Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|------------------|----------------------|------------|--------------------------------------------|
|
||||
| `locked` | Switch | R | Door lock status (ON=locked, OFF=unlocked) |
|
||||
| `engine-running` | Switch | R | Engine running status |
|
||||
| `odometer` | Number:Length | R | Odometer reading |
|
||||
| `battery-level` | Number:Dimensionless | R | 12V battery state of charge (%) |
|
||||
| `location` | Location | R | GPS coordinates |
|
||||
| `last-update` | DateTime | R | Last vehicle data update time |
|
||||
|
||||
### Doors Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|---------------|---------|------------|------------------------|
|
||||
| `front-left` | Contact | R | Front left door state |
|
||||
| `front-right` | Contact | R | Front right door state |
|
||||
| `rear-left` | Contact | R | Rear left door state |
|
||||
| `rear-right` | Contact | R | Rear right door state |
|
||||
| `trunk` | Contact | R | Trunk state |
|
||||
| `hood` | Contact | R | Hood state |
|
||||
|
||||
### Windows Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|---------------|---------|------------|--------------------------|
|
||||
| `front-left` | Contact | R | Front left window state |
|
||||
| `front-right` | Contact | R | Front right window state |
|
||||
| `rear-left` | Contact | R | Rear left window state |
|
||||
| `rear-right` | Contact | R | Rear right window state |
|
||||
|
||||
### Climate Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|------------------------|--------------------|------------|------------------------------|
|
||||
| `hvac-on` | Switch | R | Climate control status |
|
||||
| `temperature-setpoint` | Number:Temperature | R | Cabin temperature setting |
|
||||
| `defrost` | Switch | R | Defrost status |
|
||||
| `seat-front-left` | Number | R | Driver seat heater (0-3) |
|
||||
| `seat-front-right` | Number | R | Passenger seat heater (0-3) |
|
||||
| `seat-rear-left` | Number | R | Rear left seat heater (0-3) |
|
||||
| `seat-rear-right` | Number | R | Rear right seat heater (0-3) |
|
||||
| `steering-heater` | Switch | R | Steering wheel heater |
|
||||
| `rear-window-heater` | Switch | R | Rear window heater |
|
||||
| `side-mirror-heater` | Switch | R | Side mirror heater |
|
||||
|
||||
### Range Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|---------------|---------------|------------|-------------------------|
|
||||
| `total-range` | Number:Length | R | Total driving range |
|
||||
| `ev-range` | Number:Length | R | EV-only driving range |
|
||||
| `fuel-range` | Number:Length | R | Fuel-only driving range |
|
||||
|
||||
### Fuel Group (ICE vehicles only)
|
||||
|
||||
This channel group is only available for internal combustion engine (ICE) vehicles.
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|--------------------|----------------------|------------|---------------------|
|
||||
| `level` | Number:Dimensionless | R | Fuel tank level (%) |
|
||||
| `low-fuel-warning` | Switch | R | Low fuel warning |
|
||||
|
||||
### Charging Group (Electric/Hybrid vehicles only)
|
||||
|
||||
This channel group is only available for electric and hybrid vehicles.
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|-------------------------|----------------------|------------|---------------------------------|
|
||||
| `soc` | Number:Dimensionless | R | Battery state of charge (%) |
|
||||
| `charging` | Switch | R | Charging status (ON to start) |
|
||||
| `plugged-in` | Switch | R | Charge cable plugged in |
|
||||
| `charge-limit-dc` | Number:Dimensionless | R | DC charge limit (%) |
|
||||
| `charge-limit-ac` | Number:Dimensionless | R | AC charge limit (%) |
|
||||
| `time-to-full-current` | Number:Time | R | Time to full (current charger) |
|
||||
| `time-to-full-fast` | Number:Time | R | Time to full (fast charger) |
|
||||
| `time-to-full-portable` | Number:Time | R | Time to full (portable charger) |
|
||||
| `time-to-full-station` | Number:Time | R | Time to full (station charger) |
|
||||
|
||||
### Warnings Group
|
||||
|
||||
| Channel | Type | Read/Write | Description |
|
||||
|---------------------|--------|------------|---------------------------|
|
||||
| `tire-pressure` | Switch | R | Tire pressure warning |
|
||||
| `washer-fluid` | Switch | R | Washer fluid low warning |
|
||||
| `brake-fluid` | Switch | R | Brake fluid warning |
|
||||
| `smart-key-battery` | Switch | R | Smart key battery warning |
|
||||
|
||||
## Full Example
|
||||
|
||||
### `bluelink.things` Example
|
||||
|
||||
```java
|
||||
Bridge bluelink:account:myaccount "Bluelink Account" [ username="your@email.com", password="yourpassword", pin="1234" ] {
|
||||
Thing vehicle ioniq5 "Ioniq 5" [ vin="KMXXXXXXXXXXXXXXX", refreshInterval=10 ]
|
||||
}
|
||||
```
|
||||
|
||||
### `bluelink.items` Example
|
||||
|
||||
```java
|
||||
// Status
|
||||
Switch Car_Locked "Locked [%s]" <lock> { channel="bluelink:vehicle:myaccount:ioniq5:status#locked" }
|
||||
Switch Car_Engine "Engine [%s]" <engine> { channel="bluelink:vehicle:myaccount:ioniq5:status#engine-running" }
|
||||
Number:Length Car_Odometer "Odometer [%.0f %unit%]" <car> { channel="bluelink:vehicle:myaccount:ioniq5:status#odometer", unit="mi" }
|
||||
Location Car_Location "Location [%2$s°N %3$s°E %1$sm]" <location> { channel="bluelink:vehicle:myaccount:ioniq5:status#location" }
|
||||
DateTime Car_Last_Update "Last Update [%1$ta %1$tR]" { channel="bluelink:vehicle:myaccount:ioniq5:status#last-update" }
|
||||
Number Car_12V_Battery "12V Battery [%.0f %%]" <battery> { channel="bluelink:vehicle:myaccount:ioniq5:status#battery-level" }
|
||||
|
||||
// Doors
|
||||
Contact Car_Door_FL "Front Left Door [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#front-left" }
|
||||
Contact Car_Door_FR "Front Right Door [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#front-right" }
|
||||
Contact Car_Door_RL "Rear Left Door [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#rear-left" }
|
||||
Contact Car_Door_RR "Rear Right Door [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#rear-right" }
|
||||
Contact Car_Trunk "Trunk [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#trunk" }
|
||||
Contact Car_Hood "Hood [%s]" <door> { channel="bluelink:vehicle:myaccount:ioniq5:doors#hood" }
|
||||
|
||||
// Windows
|
||||
Contact Car_Window_FL "Front Left Window [%s]" <window> { channel="bluelink:vehicle:myaccount:ioniq5:windows#front-left" }
|
||||
Contact Car_Window_FR "Front Right Window [%s]" <window> { channel="bluelink:vehicle:myaccount:ioniq5:windows#front-right" }
|
||||
Contact Car_Window_RL "Rear Left Window [%s]" <window> { channel="bluelink:vehicle:myaccount:ioniq5:windows#rear-left" }
|
||||
Contact Car_Window_RR "Rear Right Window [%s]" <window> { channel="bluelink:vehicle:myaccount:ioniq5:windows#rear-right" }
|
||||
|
||||
// Climate
|
||||
Number:Temperature Car_Temp "Temperature setpoint [%.0f %unit%]" <temperature> { channel="bluelink:vehicle:myaccount:ioniq5:climate#temperature-setpoint" }
|
||||
Switch Car_Climate "Climate [%s]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#hvac-on" }
|
||||
Switch Car_Defrost "Defrost [%s]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#defrost" }
|
||||
Number Car_Heat_Seat_FL "Front Left Seat Heater [%d]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#seat-front-left" }
|
||||
Number Car_Heat_Seat_FR "Front Right Seat Heater [%d]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#seat-front-right" }
|
||||
Number Car_Heat_Seat_RL "Rear Left Seat Heater [%d]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#seat-rear-left" }
|
||||
Number Car_Heat_Seat_RR "Rear Right Seat Heater [%d]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#seat-rear-right" }
|
||||
Switch Car_Heat_Steering "Steering Wheel Heater [%s]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#steering-heater" }
|
||||
Switch Car_Heat_Rear_Window "Rear Window Heater [%s]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#rear-window-heater" }
|
||||
Switch Car_Heat_Side_Mirror "Side Mirror Heater [%s]" <climate> { channel="bluelink:vehicle:myaccount:ioniq5:climate#side-mirror-heater" }
|
||||
|
||||
// Range
|
||||
Number:Length Car_Range "Range [%.0f %unit%]" <car> { channel="bluelink:vehicle:myaccount:ioniq5:range#total-range", unit="mi" }
|
||||
Number:Length Car_EV_Range "EV Range [%.0f %unit%]" <car> { channel="bluelink:vehicle:myaccount:ioniq5:range#ev-range", unit="mi" }
|
||||
Number:Length Car_Fuel_Range "Fuel Range [%.0f %unit%]" <car> { channel="bluelink:vehicle:myaccount:ioniq5:range#fuel-range", unit="mi" }
|
||||
|
||||
// Fuel (ICE vehicles only)
|
||||
Number Car_Fuel_Level "Fuel Level [%.0f %%]" { channel="bluelink:vehicle:myaccount:ioniq5:fuel#level" }
|
||||
Switch Car_Fuel_Low "Fuel Low [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:fuel#low-fuel-warning" }
|
||||
|
||||
// Charging (EV/Hybrid vehicles only)
|
||||
Number Car_EV_SOC "EV Battery [%.0f %%]" <battery> { channel="bluelink:vehicle:myaccount:ioniq5:charging#soc" }
|
||||
Switch Car_Charging "Charging [%s]" <battery> { channel="bluelink:vehicle:myaccount:ioniq5:charging#charging" }
|
||||
Switch Car_PluggedIn "Plugged In [%s]" <poweroutlet> { channel="bluelink:vehicle:myaccount:ioniq5:charging#plugged-in" }
|
||||
Number Car_Charge_Limit_DC "DC Charge Limit [%.0f %%]" <battery> { channel="bluelink:vehicle:myaccount:ioniq5:charging#charge-limit-dc" }
|
||||
Number Car_Charge_Limit_AC "AC Charge Limit [%.0f %%]" <battery> { channel="bluelink:vehicle:myaccount:ioniq5:charging#charge-limit-ac" }
|
||||
Number:Time Car_Time_To_Full_Current "Time To Full (current charger) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:charging#time-to-full-current" }
|
||||
Number:Time Car_Time_To_Full_Fast "Time To Full (fast charger) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:charging#time-to-full-fast" }
|
||||
Number:Time Car_Time_To_Full_Portable "Time To Full (portable charger) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:charging#time-to-full-portable" }
|
||||
Number:Time Car_Time_To_Full_Station "Time To Full (station charger) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:charging#time-to-full-station" }
|
||||
|
||||
// Warnings
|
||||
Switch Car_Washer_Fluid_Warning "Washer Fluid Warning [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#washer-fluid" }
|
||||
Switch Car_Brake_Fluid_Warning "Brake Fluid Warning [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#brake-fluid" }
|
||||
Switch Car_Key_Battery_Warning "Smart Key Battery Warning [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#smart-key-battery" }
|
||||
Switch Car_Tire_Pressure_Warning "Tire Pressure Warning [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#tire-pressure" }
|
||||
Switch Car_Tire_Pressure_Warning_FR "Tire Pressure Warning (Front Right) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#tire-pressure-front-right" }
|
||||
Switch Car_Tire_Pressure_Warning_FL "Tire Pressure Warning (Front Left) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#tire-pressure-front-left" }
|
||||
Switch Car_Tire_Pressure_Warning_RR "Tire Pressure Warning (Rear Right) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#tire-pressure-rear-right" }
|
||||
Switch Car_Tire_Pressure_Warning_RL "Tire Pressure Warning (Rear Left) [%s]" { channel="bluelink:vehicle:myaccount:ioniq5:warnings#tire-pressure-rear-left" }
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.bluelink</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: Bluelink Binding</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>2.27.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.bluelink-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-bluelink" description="Bluelink Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluelink/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.library.CoreItemFactory;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "bluelink";
|
||||
|
||||
// Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
|
||||
public static final ThingTypeUID THING_TYPE_VEHICLE = new ThingTypeUID(BINDING_ID, "vehicle");
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_VEHICLE);
|
||||
|
||||
// API Configuration
|
||||
public static final String API_ENDPOINT = "https://api.telematics.hyundaiusa.com";
|
||||
public static final String CLIENT_ID = "m66129Bb-em93-SPAHYN-bZ91-am4540zp19920";
|
||||
public static final String CLIENT_SECRET = "v558o935-6nne-423i-baa8";
|
||||
|
||||
// Channel Groups
|
||||
public static final String GROUP_STATUS = "status";
|
||||
public static final String GROUP_CHARGING = "charging";
|
||||
public static final String GROUP_FUEL = "fuel";
|
||||
public static final String GROUP_DOORS = "doors";
|
||||
public static final String GROUP_WINDOWS = "windows";
|
||||
public static final String GROUP_CLIMATE = "climate";
|
||||
public static final String GROUP_RANGE = "range";
|
||||
public static final String GROUP_WARNINGS = "warnings";
|
||||
|
||||
// Channel Types
|
||||
public static final String CHANNEL_TYPE_EV_SOC = "ev-soc";
|
||||
public static final String CHANNEL_TYPE_EV_CHARGING = "ev-charging";
|
||||
public static final String CHANNEL_TYPE_EV_PLUGGED_IN = "ev-plugged-in";
|
||||
public static final String CHANNEL_TYPE_CHARGE_LIMIT_DC = "charge-limit-dc";
|
||||
public static final String CHANNEL_TYPE_CHARGE_LIMIT_AC = "charge-limit-ac";
|
||||
public static final String CHANNEL_TYPE_CHARGE_TIME_CURRENT = "charge-time-current";
|
||||
public static final String CHANNEL_TYPE_CHARGE_TIME_FAST = "charge-time-fast";
|
||||
public static final String CHANNEL_TYPE_CHARGE_TIME_PORTABLE = "charge-time-portable";
|
||||
public static final String CHANNEL_TYPE_CHARGE_TIME_STATION = "charge-time-station";
|
||||
public static final String CHANNEL_TYPE_FUEL_LEVEL = "fuel-level";
|
||||
public static final String CHANNEL_TYPE_FUEL_WARNING = "fuel-warning";
|
||||
|
||||
// Item Types
|
||||
public static final String NUMBER_DIMENSIONLESS = CoreItemFactory.NUMBER + ":Dimensionless";
|
||||
public static final String NUMBER_TIME = CoreItemFactory.NUMBER + ":Time";
|
||||
|
||||
// Status Channels
|
||||
public static final String CHANNEL_LOCKED = "locked";
|
||||
public static final String CHANNEL_ENGINE_RUNNING = "engine-running";
|
||||
public static final String CHANNEL_ODOMETER = "odometer";
|
||||
public static final String CHANNEL_BATTERY_LEVEL = "battery-level";
|
||||
public static final String CHANNEL_LOCATION = "location";
|
||||
public static final String CHANNEL_LAST_UPDATE = "last-update";
|
||||
|
||||
// Door Channels
|
||||
public static final String CHANNEL_DOOR_FRONT_LEFT = "front-left";
|
||||
public static final String CHANNEL_DOOR_FRONT_RIGHT = "front-right";
|
||||
public static final String CHANNEL_DOOR_REAR_LEFT = "rear-left";
|
||||
public static final String CHANNEL_DOOR_REAR_RIGHT = "rear-right";
|
||||
public static final String CHANNEL_TRUNK = "trunk";
|
||||
public static final String CHANNEL_HOOD = "hood";
|
||||
|
||||
// Window Channels
|
||||
public static final String CHANNEL_WINDOW_FRONT_LEFT = "front-left";
|
||||
public static final String CHANNEL_WINDOW_FRONT_RIGHT = "front-right";
|
||||
public static final String CHANNEL_WINDOW_REAR_LEFT = "rear-left";
|
||||
public static final String CHANNEL_WINDOW_REAR_RIGHT = "rear-right";
|
||||
|
||||
// Climate Channels
|
||||
public static final String CHANNEL_TEMPERATURE = "temperature-setpoint";
|
||||
public static final String CHANNEL_HVAC_ON = "hvac-on";
|
||||
public static final String CHANNEL_DEFROST = "defrost";
|
||||
public static final String CHANNEL_SEAT_FRONT_LEFT = "seat-front-left";
|
||||
public static final String CHANNEL_SEAT_FRONT_RIGHT = "seat-front-right";
|
||||
public static final String CHANNEL_SEAT_REAR_LEFT = "seat-rear-left";
|
||||
public static final String CHANNEL_SEAT_REAR_RIGHT = "seat-rear-right";
|
||||
public static final String CHANNEL_STEERING_HEATER = "steering-heater";
|
||||
public static final String CHANNEL_REAR_WINDOW_HEATER = "rear-window-heater";
|
||||
public static final String CHANNEL_SIDE_MIRROR_HEATER = "side-mirror-heater";
|
||||
|
||||
// Range Channels
|
||||
public static final String CHANNEL_TOTAL_RANGE = "total-range";
|
||||
public static final String CHANNEL_EV_RANGE = "ev-range";
|
||||
public static final String CHANNEL_FUEL_RANGE = "fuel-range";
|
||||
|
||||
// Fuel Channels
|
||||
public static final String CHANNEL_FUEL_LEVEL = "level";
|
||||
public static final String CHANNEL_FUEL_LOW_WARNING = "low-fuel-warning";
|
||||
|
||||
// Battery and Charging Channels
|
||||
public static final String CHANNEL_EV_BATTERY_SOC = "soc";
|
||||
public static final String CHANNEL_EV_CHARGING = "charging";
|
||||
public static final String CHANNEL_EV_PLUGGED_IN = "plugged-in";
|
||||
public static final String CHANNEL_CHARGE_LIMIT_DC = "charge-limit-dc";
|
||||
public static final String CHANNEL_CHARGE_LIMIT_AC = "charge-limit-ac";
|
||||
public static final String CHANNEL_TIME_TO_FULL_CURRENT = "time-to-full-current";
|
||||
public static final String CHANNEL_TIME_TO_FULL_FAST = "time-to-full-fast";
|
||||
public static final String CHANNEL_TIME_TO_FULL_PORTABLE = "time-to-full-portable";
|
||||
public static final String CHANNEL_TIME_TO_FULL_STATION = "time-to-full-station";
|
||||
|
||||
// Warning Channels
|
||||
public static final String CHANNEL_TIRE_PRESSURE_WARNING = "tire-pressure";
|
||||
public static final String CHANNEL_TIRE_PRESSURE_WARNING_FL = "tire-pressure-front-left";
|
||||
public static final String CHANNEL_TIRE_PRESSURE_WARNING_FR = "tire-pressure-front-right";
|
||||
public static final String CHANNEL_TIRE_PRESSURE_WARNING_RR = "tire-pressure-rear-left";
|
||||
public static final String CHANNEL_TIRE_PRESSURE_WARNING_RL = "tire-pressure-rear-right";
|
||||
public static final String CHANNEL_WASHER_FLUID_WARNING = "washer-fluid";
|
||||
public static final String CHANNEL_BRAKE_FLUID_WARNING = "brake-fluid";
|
||||
public static final String CHANNEL_SMART_KEY_BATTERY_WARNING = "smart-key-battery";
|
||||
|
||||
// Vehicle Properties
|
||||
public static final String PROPERTY_VIN = "vin";
|
||||
public static final String PROPERTY_MODEL = "model";
|
||||
public static final String PROPERTY_ENGINE_TYPE = "engineType";
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal;
|
||||
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.bluelink.internal.handler.BluelinkAccountHandler;
|
||||
import org.openhab.binding.bluelink.internal.handler.BluelinkVehicleHandler;
|
||||
import org.openhab.core.i18n.LocaleProvider;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link BluelinkHandlerFactory} creates handlers for Bluelink things.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.bluelink", service = ThingHandlerFactory.class)
|
||||
public class BluelinkHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
private final LocaleProvider localeProvider;
|
||||
|
||||
@Activate
|
||||
public BluelinkHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
|
||||
final @Reference TimeZoneProvider timeZoneProvider, final @Reference LocaleProvider localeProvider) {
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
this.timeZoneProvider = timeZoneProvider;
|
||||
this.localeProvider = localeProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(final ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(final Thing thing) {
|
||||
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
|
||||
final var httpClient = httpClientFactory.getCommonHttpClient();
|
||||
return new BluelinkAccountHandler((Bridge) thing, httpClient, timeZoneProvider, localeProvider);
|
||||
}
|
||||
if (THING_TYPE_VEHICLE.equals(thingTypeUID)) {
|
||||
return new BluelinkVehicleHandler(thing);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.api;
|
||||
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.CLIENT_ID;
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.CLIENT_SECRET;
|
||||
import static org.openhab.binding.bluelink.internal.dto.ChargeLimitsRequest.PLUG_TYPE_AC;
|
||||
import static org.openhab.binding.bluelink.internal.dto.ChargeLimitsRequest.PLUG_TYPE_DC;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.bluelink.internal.dto.ChargeLimitsRequest;
|
||||
import org.openhab.binding.bluelink.internal.dto.ClimateRequestEv;
|
||||
import org.openhab.binding.bluelink.internal.dto.ClimateRequestIce;
|
||||
import org.openhab.binding.bluelink.internal.dto.DoorCommandRequest;
|
||||
import org.openhab.binding.bluelink.internal.dto.EnrollmentResponse;
|
||||
import org.openhab.binding.bluelink.internal.dto.EnrollmentResponse.EnrolledVehicle;
|
||||
import org.openhab.binding.bluelink.internal.dto.LoginRequest;
|
||||
import org.openhab.binding.bluelink.internal.dto.TokenResponse;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleStatus;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
/**
|
||||
* HTTP client for the Bluelink API.
|
||||
* <p>
|
||||
* Implementation based on [hyundai_kia_connect_api](https://github.com/Hyundai-Kia-Connect/hyundai_kia_connect_api)
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkApi {
|
||||
|
||||
private static final String APPLICATION_JSON = "application/json;charset=UTF-8";
|
||||
private static final int HTTP_TIMEOUT_SECONDS = 30;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BluelinkApi.class);
|
||||
private final Gson gson = new GsonBuilder().create();
|
||||
private final HttpClient httpClient;
|
||||
private final String baseUrl;
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final @Nullable String pin;
|
||||
|
||||
private @Nullable TokenResponse token;
|
||||
private @Nullable Instant tokenExpiry;
|
||||
|
||||
public BluelinkApi(final HttpClient httpClient, final String baseUrl, final TimeZoneProvider timeZoneProvider,
|
||||
final String username, final String password, final @Nullable String pin) {
|
||||
this.httpClient = httpClient;
|
||||
this.baseUrl = baseUrl;
|
||||
this.timeZoneProvider = timeZoneProvider;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.pin = pin;
|
||||
}
|
||||
|
||||
public boolean login() throws BluelinkApiException {
|
||||
final LoginRequest loginRequest = new LoginRequest(username, password);
|
||||
|
||||
try {
|
||||
final String loginUrl = baseUrl + "/v2/ac/oauth/token";
|
||||
final Request request = httpClient.newRequest(loginUrl).method(HttpMethod.POST)
|
||||
.timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.content(new StringContentProvider(gson.toJson(loginRequest)), APPLICATION_JSON);
|
||||
addStandardHeaders(request);
|
||||
|
||||
final ContentResponse response = request.send();
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("Login failed with status {}: {}", response.getStatus(), response.getContentAsString());
|
||||
final String msg = "Login failed: " + response.getStatus();
|
||||
if (isRetryableStatus(response.getStatus())) {
|
||||
throw new RetryableRequestException(msg);
|
||||
} else {
|
||||
throw new BluelinkApiException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
final TokenResponse tokenResponse = gson.fromJson(response.getContentAsString(), TokenResponse.class);
|
||||
if (tokenResponse == null || tokenResponse.accessToken() == null || tokenResponse.expiresIn() == null) {
|
||||
throw new BluelinkApiException("Invalid token response");
|
||||
}
|
||||
token = tokenResponse;
|
||||
tokenExpiry = Instant.now().plusSeconds(Integer.parseInt(tokenResponse.expiresIn()) - 60);
|
||||
logger.debug("Login successful, token valid until {}", tokenExpiry);
|
||||
return true;
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Login interrupted", e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Login failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAuthenticated() {
|
||||
final TokenResponse t = token;
|
||||
final Instant expiry = tokenExpiry;
|
||||
return t != null && expiry != null && Instant.now().isBefore(expiry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid token, refreshing if necessary.
|
||||
*/
|
||||
private void ensureAuthenticated() throws BluelinkApiException {
|
||||
if (!isAuthenticated()) {
|
||||
login();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of enrolled vehicles.
|
||||
*/
|
||||
public List<VehicleInfo> getVehicles() throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
// One would expect the username (email address) to be URL-encoded, but
|
||||
// the API does not accept encoding the @ character so we omit encoding.
|
||||
final String url = baseUrl + "/ac/v2/enrollment/details/" + username;
|
||||
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
final ContentResponse response = checkStatus(request.send(), "get vehicles");
|
||||
final EnrollmentResponse enrollment = gson.fromJson(response.getContentAsString(),
|
||||
EnrollmentResponse.class);
|
||||
if (enrollment == null) {
|
||||
return List.of();
|
||||
}
|
||||
final List<EnrolledVehicle> vehicles = enrollment.enrolledVehicleDetails();
|
||||
if (vehicles == null) {
|
||||
return List.of();
|
||||
}
|
||||
return vehicles.stream().map(EnrolledVehicle::vehicleDetails).filter(Objects::nonNull).toList();
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Get vehicles interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Failed to get vehicles", e);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable VehicleStatus getVehicleStatus(final VehicleInfo vehicle, final boolean forceRefresh)
|
||||
throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
|
||||
final String url = baseUrl + "/ac/v2/rcs/rvs/vehicleStatus";
|
||||
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.header("refresh", String.valueOf(forceRefresh));
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
addVehicleHeaders(request, vehicle);
|
||||
final ContentResponse response = checkStatus(request.send(), "get vehicle status");
|
||||
return gson.fromJson(response.getContentAsString(), VehicleStatus.class);
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Get vehicle status interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Failed to get vehicle status", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean lockVehicle(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
return sendDoorCommand(vehicle, "/ac/v2/rcs/rdo/off");
|
||||
}
|
||||
|
||||
public boolean unlockVehicle(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
return sendDoorCommand(vehicle, "/ac/v2/rcs/rdo/on");
|
||||
}
|
||||
|
||||
private boolean sendDoorCommand(final VehicleInfo vehicle, final String endpoint) throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
|
||||
final String vin = vehicle.vin();
|
||||
if (vin == null) {
|
||||
throw new BluelinkApiException("VIN not available");
|
||||
}
|
||||
final String url = baseUrl + endpoint;
|
||||
final DoorCommandRequest doorRequest = new DoorCommandRequest(username, vin);
|
||||
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).method(HttpMethod.POST).timeout(HTTP_TIMEOUT_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
addVehicleHeaders(request, vehicle);
|
||||
request.header("APPCLOUD-VIN", vin);
|
||||
request.content(new StringContentProvider(gson.toJson(doorRequest)), APPLICATION_JSON);
|
||||
final ContentResponse response = request.send();
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("Door command failed: {} - {}", response.getStatus(), response.getContentAsString());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Door command interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Door command failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean climateStart(final VehicleInfo vehicle, final QuantityType<Temperature> temperature,
|
||||
final boolean heat, final boolean defrost) throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
final String url;
|
||||
final Object request;
|
||||
if (vehicle.isElectric()) {
|
||||
url = baseUrl + "/ac/v2/evc/fatc/start";
|
||||
request = ClimateRequestEv.create(temperature, heat, defrost);
|
||||
} else {
|
||||
if (vehicle.vin() == null) {
|
||||
throw new BluelinkApiException("VIN not available");
|
||||
}
|
||||
url = baseUrl + "/ac/v2/rcs/rsc/start";
|
||||
request = ClimateRequestIce.create(temperature, heat, defrost, username, vehicle.vin());
|
||||
}
|
||||
return sendClimateCommand(vehicle, url, request);
|
||||
}
|
||||
|
||||
public boolean climateStop(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
return sendSimplePostCommand(vehicle, vehicle.isElectric() ? "/ac/v2/evc/fatc/stop" : "/ac/v2/rcs/rsc/stop");
|
||||
}
|
||||
|
||||
private boolean sendClimateCommand(final VehicleInfo vehicle, final String url, final Object climateRequest)
|
||||
throws BluelinkApiException {
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).method(HttpMethod.POST).timeout(HTTP_TIMEOUT_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
addVehicleHeaders(request, vehicle);
|
||||
request.content(new StringContentProvider(gson.toJson(climateRequest)), APPLICATION_JSON);
|
||||
|
||||
final ContentResponse response = request.send();
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("Climate command failed: {} - {}", response.getStatus(), response.getContentAsString());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Climate command interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Climate command failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean startCharging(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
return sendSimplePostCommand(vehicle, "/ac/v2/evc/charge/start");
|
||||
}
|
||||
|
||||
public boolean stopCharging(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
return sendSimplePostCommand(vehicle, "/ac/v2/evc/charge/stop");
|
||||
}
|
||||
|
||||
public boolean setChargeLimitDC(final VehicleInfo vehicle, final int limit) throws BluelinkApiException {
|
||||
return setChargeLimit(vehicle, PLUG_TYPE_DC, limit);
|
||||
}
|
||||
|
||||
public boolean setChargeLimitAC(final VehicleInfo vehicle, final int limit) throws BluelinkApiException {
|
||||
return setChargeLimit(vehicle, PLUG_TYPE_AC, limit);
|
||||
}
|
||||
|
||||
private boolean setChargeLimit(final VehicleInfo vehicle, final int plugType, final int limit)
|
||||
throws BluelinkApiException {
|
||||
ensureAuthenticated();
|
||||
|
||||
final String url = baseUrl + "/ac/v2/evc/charge/targetsoc/set";
|
||||
final ChargeLimitsRequest chargeLimitsRequest = new ChargeLimitsRequest(
|
||||
List.of(new ChargeLimitsRequest.TargetSOC(plugType, limit)));
|
||||
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).method(HttpMethod.POST)
|
||||
.timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.content(new StringContentProvider(gson.toJson(chargeLimitsRequest)), APPLICATION_JSON);
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
addVehicleHeaders(request, vehicle);
|
||||
|
||||
final ContentResponse response = request.send();
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("Set charge limits failed: {} - {}", response.getStatus(), response.getContentAsString());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Set charge limits interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Set charge limits failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendSimplePostCommand(final VehicleInfo vehicle, final String endpoint)
|
||||
throws BluelinkApiException {
|
||||
final String url = baseUrl + endpoint;
|
||||
|
||||
try {
|
||||
final Request request = httpClient.newRequest(url).method(HttpMethod.POST)
|
||||
.timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.content(new StringContentProvider("{}"), APPLICATION_JSON);
|
||||
addStandardHeaders(request);
|
||||
addAuthHeaders(request);
|
||||
addVehicleHeaders(request, vehicle);
|
||||
|
||||
final ContentResponse response = request.send();
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("Command {} failed: {} - {}", endpoint, response.getStatus(),
|
||||
response.getContentAsString());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (final InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("Command interrupted", e);
|
||||
} catch (final TimeoutException | ExecutionException e) {
|
||||
throw new BluelinkApiException("Command failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void addStandardHeaders(final Request request) {
|
||||
request.header(HttpHeader.CONTENT_TYPE, APPLICATION_JSON)
|
||||
.header(HttpHeader.ACCEPT, "application/json, text/plain, */*")
|
||||
.header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate, br")
|
||||
.header(HttpHeader.USER_AGENT,
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.header("from", "SPA").header("to", "ISS").header("language", "0").header("client_id", CLIENT_ID)
|
||||
.header("clientSecret", CLIENT_SECRET).header("brandIndicator", "H")
|
||||
.header("offset", String.valueOf(getTimeZoneOffset()));
|
||||
}
|
||||
|
||||
private void addAuthHeaders(final Request request) {
|
||||
final TokenResponse t = token;
|
||||
if (t != null && t.accessToken() != null) {
|
||||
request.header("accessToken", t.accessToken());
|
||||
}
|
||||
request.header("username", username).header("blueLinkServicePin", pin);
|
||||
}
|
||||
|
||||
private void addVehicleHeaders(final Request request, final VehicleInfo vehicle) {
|
||||
final String regId = vehicle.registrationId();
|
||||
final String gen = vehicle.vehicleGeneration();
|
||||
final String vin = vehicle.vin();
|
||||
|
||||
if (regId != null) {
|
||||
request.header("registrationId", regId);
|
||||
}
|
||||
if (gen != null) {
|
||||
request.header("gen", gen);
|
||||
}
|
||||
if (vin != null) {
|
||||
request.header("vin", vin);
|
||||
}
|
||||
}
|
||||
|
||||
private long getTimeZoneOffset() {
|
||||
return Duration.ofSeconds(timeZoneProvider.getTimeZone().getRules().getOffset(Instant.now()).getTotalSeconds())
|
||||
.toHours();
|
||||
}
|
||||
|
||||
private ContentResponse checkStatus(final ContentResponse response, final String op) throws BluelinkApiException {
|
||||
if (response.getStatus() != HttpStatus.OK_200) {
|
||||
logger.debug("operation failed ({}): {} - {}", op, response.getStatus(), response.getContentAsString());
|
||||
final String msg = "operation failed: %s - %d".formatted(op, response.getStatus());
|
||||
if (isRetryableStatus(response.getStatus())) {
|
||||
throw new RetryableRequestException(msg);
|
||||
} else {
|
||||
throw new BluelinkApiException(msg);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private static boolean isRetryableStatus(final int status) {
|
||||
return status >= 500 && status < 600;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Exception for Bluelink API errors.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkApiException extends Exception {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public BluelinkApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public BluelinkApiException(String message, @Nullable Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Supported API regions.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public enum Region {
|
||||
US("United States");
|
||||
|
||||
private final String label;
|
||||
|
||||
Region(final String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Exception indicating a retryable request failure, such as a 5xx HTTP status.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RetryableRequestException extends BluelinkApiException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public RetryableRequestException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public RetryableRequestException(final String message, final @Nullable Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Configuration for the Bluelink account bridge.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkAccountConfiguration {
|
||||
|
||||
/**
|
||||
* Bluelink account username (email).
|
||||
*/
|
||||
public @Nullable String username;
|
||||
|
||||
/**
|
||||
* Bluelink account password.
|
||||
*/
|
||||
public @Nullable String password;
|
||||
|
||||
/**
|
||||
* Bluelink service PIN (required for lock/unlock commands).
|
||||
*/
|
||||
public @Nullable String pin;
|
||||
|
||||
/**
|
||||
* API region. Auto-detected from system locale if not set.
|
||||
*/
|
||||
public @Nullable String region;
|
||||
|
||||
/**
|
||||
* API base URL (for unit test use).
|
||||
*/
|
||||
public @Nullable String apiBaseUrl;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Configuration for a Bluelink vehicle thing.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkVehicleConfiguration {
|
||||
|
||||
/**
|
||||
* Vehicle Identification Number (VIN).
|
||||
*/
|
||||
public @Nullable String vin;
|
||||
|
||||
/**
|
||||
* Refresh interval in minutes.
|
||||
*/
|
||||
public int refreshInterval = 30;
|
||||
|
||||
/**
|
||||
* Forced refresh interval in minutes.
|
||||
*/
|
||||
public int forceRefreshInterval = 240;
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApiException;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.binding.bluelink.internal.handler.BluelinkAccountHandler;
|
||||
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Discovery service for Bluelink vehicles.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = BluelinkVehicleDiscoveryService.class)
|
||||
@NonNullByDefault
|
||||
public class BluelinkVehicleDiscoveryService extends AbstractThingHandlerDiscoveryService<BluelinkAccountHandler> {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BluelinkVehicleDiscoveryService.class);
|
||||
|
||||
private static final int DISCOVERY_TIMEOUT_SECONDS = 10;
|
||||
|
||||
public BluelinkVehicleDiscoveryService() {
|
||||
super(BluelinkAccountHandler.class, Set.of(THING_TYPE_VEHICLE), DISCOVERY_TIMEOUT_SECONDS, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
logger.debug("Starting Bluelink vehicle discovery");
|
||||
final BluelinkAccountHandler handler = thingHandler;
|
||||
final List<VehicleInfo> vehicles;
|
||||
try {
|
||||
vehicles = handler.getVehicles();
|
||||
logger.debug("Found {} vehicles", vehicles.size());
|
||||
vehicles.forEach(v -> discoverVehicle(v, handler));
|
||||
} catch (final BluelinkApiException e) {
|
||||
logger.debug("discovery failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void discoverVehicle(final VehicleInfo vehicle, final BluelinkAccountHandler handler) {
|
||||
if (vehicle.vin() == null || vehicle.vin().isBlank()) {
|
||||
logger.debug("Skipping vehicle with no VIN");
|
||||
return;
|
||||
}
|
||||
|
||||
final ThingUID bridgeUID = handler.getThing().getUID();
|
||||
final ThingUID thingUID = new ThingUID(THING_TYPE_VEHICLE, bridgeUID, vehicle.vin());
|
||||
|
||||
String label = vehicle.getDisplayName();
|
||||
if (label == null) {
|
||||
label = "Bluelink Vehicle " + vehicle.vin();
|
||||
}
|
||||
|
||||
final String modelCode = vehicle.modelCode();
|
||||
final String evStatus = vehicle.evStatus();
|
||||
final Map<String, Object> properties = Map.of(PROPERTY_VIN, vehicle.vin(), PROPERTY_MODEL,
|
||||
modelCode != null ? modelCode : "", PROPERTY_ENGINE_TYPE, evStatus != null ? evStatus : "");
|
||||
|
||||
final DiscoveryResult result = DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID).withLabel(label)
|
||||
.withProperties(properties).withRepresentationProperty(PROPERTY_VIN).build();
|
||||
|
||||
thingDiscovered(result);
|
||||
logger.debug("Discovered vehicle: {} ({})", label, vehicle.vin());
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
|
||||
import static org.openhab.core.library.unit.SIUnits.CELSIUS;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
|
||||
/**
|
||||
* Air temperature value for the Bluelink API.
|
||||
* Unit values: 0 = Celsius, 1 = Fahrenheit.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record AirTemperature(String value, int unit) {
|
||||
|
||||
private static final int UNIT_CELSIUS = 0;
|
||||
private static final int UNIT_FAHRENHEIT = 1;
|
||||
|
||||
private static final QuantityType<@NonNull Temperature> LO_TEMP = new QuantityType<>(62, FAHRENHEIT);
|
||||
private static final QuantityType<@NonNull Temperature> HI_TEMP = new QuantityType<>(82, FAHRENHEIT);
|
||||
|
||||
private static AirTemperature ofCelsius(final int value) {
|
||||
return new AirTemperature(String.valueOf(value), UNIT_CELSIUS);
|
||||
}
|
||||
|
||||
private static AirTemperature ofFahrenheit(final int value) {
|
||||
return new AirTemperature(String.valueOf(value), UNIT_FAHRENHEIT);
|
||||
}
|
||||
|
||||
public static AirTemperature of(final @NonNull QuantityType<@NonNull Temperature> temp) {
|
||||
if (CELSIUS.equals(temp.getUnit())) {
|
||||
return ofCelsius(temp.intValue());
|
||||
} else {
|
||||
final var tempF = temp.toUnit(FAHRENHEIT);
|
||||
if (tempF != null) {
|
||||
return ofFahrenheit(tempF.intValue());
|
||||
} else {
|
||||
throw new IllegalArgumentException("cannot convert temperature");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public State getTemperature() {
|
||||
final String value = this.value;
|
||||
try {
|
||||
return switch (value) {
|
||||
case "LO" -> LO_TEMP;
|
||||
case "HI" -> HI_TEMP;
|
||||
case null -> UnDefType.NULL;
|
||||
default -> switch (unit) {
|
||||
case UNIT_CELSIUS -> new QuantityType<>(Double.parseDouble(value), CELSIUS);
|
||||
case UNIT_FAHRENHEIT -> new QuantityType<>(Double.parseDouble(value), FAHRENHEIT);
|
||||
default -> UnDefType.UNDEF;
|
||||
};
|
||||
};
|
||||
} catch (final NumberFormatException e) {
|
||||
return UnDefType.UNDEF;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Charge limits request for the Bluelink API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record ChargeLimitsRequest(@SerializedName("targetSOClist") List<TargetSOC> targetSOCList) {
|
||||
|
||||
public static final int PLUG_TYPE_DC = 0;
|
||||
public static final int PLUG_TYPE_AC = 1;
|
||||
|
||||
public record TargetSOC(int plugType, @SerializedName("targetSOClevel") int targetSOCLevel) {
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
|
||||
/**
|
||||
* Climate control request for electric vehicles.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record ClimateRequestEv(int airCtrl, AirTemperature airTemp, boolean defrost, int heating1) {
|
||||
|
||||
public static ClimateRequestEv create(final @NonNull QuantityType<@NonNull Temperature> temperature,
|
||||
final boolean heat, final boolean defrost) {
|
||||
return new ClimateRequestEv(1, AirTemperature.of(temperature), defrost, heat ? 1 : 0);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Climate control request for ICE (internal combustion engine) vehicles.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record ClimateRequestIce(int airCtrl, AirTemperature airTemp, boolean defrost, int heating1, int igniOnDuration,
|
||||
SeatHeaterVentInfo seatHeaterVentInfo, @SerializedName("Ims") int ims, String username, String vin) {
|
||||
|
||||
public record SeatHeaterVentInfo(@SerializedName("drvSeatHeatState") int driverSeat,
|
||||
@SerializedName("astSeatHeatState") int passengerSeat, @SerializedName("rlSeatHeatState") int rearLeftSeat,
|
||||
@SerializedName("rrSeatHeatState") int rearRightSeat) {
|
||||
|
||||
public static final SeatHeaterVentInfo OFF = new SeatHeaterVentInfo(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
public static ClimateRequestIce create(final @NonNull QuantityType<@NonNull Temperature> temperature,
|
||||
final boolean heat, final boolean defrost, final String username, final String vin) {
|
||||
return new ClimateRequestIce(1, AirTemperature.of(temperature), defrost, heat ? 1 : 0, 10,
|
||||
SeatHeaterVentInfo.OFF, 0, username, vin);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
/**
|
||||
* Door lock/unlock command request for the Bluelink API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record DoorCommandRequest(String userName, String vin) {
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Response from the enrollment details API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record EnrollmentResponse(List<EnrolledVehicle> enrolledVehicleDetails) {
|
||||
|
||||
public record EnrolledVehicle(VehicleInfo vehicleDetails) {
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
/**
|
||||
* Login request for the Bluelink API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record LoginRequest(String username, String password) {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Token response from the Bluelink authentication API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record TokenResponse(@SerializedName("access_token") String accessToken,
|
||||
@SerializedName("refresh_token") String refreshToken, @SerializedName("expires_in") String expiresIn) {
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Vehicle information from the enrollment details API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record VehicleInfo(@SerializedName("regid") String registrationId, String nickName, String vin, String evStatus,
|
||||
String modelCode, String vehicleGeneration, double odometer) {
|
||||
|
||||
public boolean isElectric() {
|
||||
return "E".equals(evStatus);
|
||||
}
|
||||
|
||||
public int getGeneration() {
|
||||
final String gen = vehicleGeneration;
|
||||
if (gen == null) {
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(gen);
|
||||
} catch (final NumberFormatException e) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
final String nick = nickName;
|
||||
if (nick != null && !nick.isBlank()) {
|
||||
return nick;
|
||||
}
|
||||
return modelCode;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Vehicle location data from the Bluelink API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record VehicleLocation(Coordinates coord) {
|
||||
|
||||
public record Coordinates(@SerializedName("lat") double latitude, @SerializedName("lon") double longitude,
|
||||
@SerializedName("alt") double altitude) {
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.ImperialUnits;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Vehicle status response from the Bluelink API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
public record VehicleStatus(VehicleStatusData vehicleStatus) {
|
||||
|
||||
public record VehicleStatusData(String dateTime, VehicleLocation vehicleLocation, boolean engine, boolean doorLock,
|
||||
DoorStatus doorOpen, WindowStatus windowOpen, boolean trunkOpen, boolean hoodOpen, boolean airCtrlOn,
|
||||
AirTemperature airTemp, boolean defrost, int steerWheelHeat, int sideBackWindowHeat, int sideMirrorHeat,
|
||||
SeatHeaterState seatHeaterVentState, BatteryStatus battery, EvStatus evStatus, DrivingRange dte,
|
||||
int fuelLevel, boolean lowFuelLight, boolean washerFluidStatus,
|
||||
@SerializedName("breakOilStatus") boolean brakeOilStatus, boolean smartKeyBatteryWarning,
|
||||
@SerializedName("tirePressureLamp") TirePressureWarning tirePressureWarning) {
|
||||
}
|
||||
|
||||
public record DoorStatus(int frontLeft, int frontRight, int backLeft, int backRight) {
|
||||
}
|
||||
|
||||
public record WindowStatus(int frontLeft, int frontRight, int backLeft, int backRight) {
|
||||
}
|
||||
|
||||
public record SeatHeaterState(@SerializedName("flSeatHeatState") int frontLeft,
|
||||
@SerializedName("frSeatHeatState") int frontRight, @SerializedName("rlSeatHeatState") int rearLeft,
|
||||
@SerializedName("rrSeatHeatState") int rearRight) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 12V battery status.
|
||||
*/
|
||||
public record BatteryStatus(@SerializedName("batSoc") int stateOfCharge) {
|
||||
}
|
||||
|
||||
/**
|
||||
* EV-specific status.
|
||||
*/
|
||||
public record EvStatus(boolean batteryCharge, int batteryStatus, int batteryPlugin,
|
||||
ReserveChargeInfo reservChargeInfos, List<DrivingDistance> drvDistance, ChargeRemainingTime remainTime2) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge limit and scheduling info.
|
||||
*/
|
||||
public record ReserveChargeInfo(@SerializedName("targetSOCList") List<TargetSOC> targetSocList) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Target state of charge setting.
|
||||
*/
|
||||
public record TargetSOC(int plugType, // 0 = DC, 1 = AC
|
||||
@SerializedName("targetSOCLevel") int targetSocLevel) {
|
||||
}
|
||||
|
||||
public record DrivingDistance(RangeByFuel rangeByFuel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Range by fuel type.
|
||||
*/
|
||||
public record RangeByFuel(RangeValue totalAvailableRange, RangeValue evModeRange, RangeValue gasModeRange) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Range value with unit.
|
||||
*/
|
||||
public record RangeValue(double value, int unit) {
|
||||
|
||||
public State getRange() {
|
||||
return switch (unit) {
|
||||
case 1 -> new QuantityType<>(value * 1000, SIUnits.METRE);
|
||||
case 2, 3 -> new QuantityType<>(value, ImperialUnits.MILE);
|
||||
default -> UnDefType.UNDEF;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record ChargeRemainingTime(
|
||||
// Current
|
||||
TimeValue atc,
|
||||
// Fast
|
||||
TimeValue etc1,
|
||||
// Portable
|
||||
TimeValue etc2,
|
||||
// Station
|
||||
TimeValue etc3) {
|
||||
}
|
||||
|
||||
public record TimeValue(int value, int unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DTE (Distance to Empty) for non-EV.
|
||||
*/
|
||||
public record DrivingRange(double value, int unit) {
|
||||
}
|
||||
|
||||
public record TirePressureWarning(@SerializedName("tirePressureWarningLampAll") int all,
|
||||
@SerializedName("tirePressureWarningLampFrontLeft") int frontLeft,
|
||||
@SerializedName("tirePressureWarningLampFrontRight") int frontRight,
|
||||
@SerializedName("tirePressureWarningLampRearLeft") int rearLeft,
|
||||
@SerializedName("tirePressureWarningLampRearRight") int rearRight) {
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.handler;
|
||||
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.API_ENDPOINT;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApi;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApiException;
|
||||
import org.openhab.binding.bluelink.internal.api.Region;
|
||||
import org.openhab.binding.bluelink.internal.api.RetryableRequestException;
|
||||
import org.openhab.binding.bluelink.internal.config.BluelinkAccountConfiguration;
|
||||
import org.openhab.binding.bluelink.internal.discovery.BluelinkVehicleDiscoveryService;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleStatus;
|
||||
import org.openhab.binding.bluelink.internal.util.Backoff;
|
||||
import org.openhab.core.i18n.LocaleProvider;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link BluelinkAccountHandler} is responsible for handling the Bluelink account bridge.
|
||||
* It manages authentication and provides API access to vehicle handlers.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkAccountHandler extends BaseBridgeHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BluelinkAccountHandler.class);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
private final LocaleProvider localeProvider;
|
||||
|
||||
private volatile @Nullable BluelinkApi api;
|
||||
private volatile @Nullable ScheduledFuture<?> loginTask;
|
||||
|
||||
public BluelinkAccountHandler(final Bridge bridge, final HttpClient httpClient,
|
||||
final TimeZoneProvider timeZoneProvider, final LocaleProvider localeProvider) {
|
||||
super(bridge);
|
||||
this.httpClient = httpClient;
|
||||
this.timeZoneProvider = timeZoneProvider;
|
||||
this.localeProvider = localeProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
logger.debug("Initializing Bluelink account handler");
|
||||
|
||||
final BluelinkAccountConfiguration config = getConfigAs(BluelinkAccountConfiguration.class);
|
||||
final String username = config.username;
|
||||
final String password = config.password;
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/account-handler.initialize.missing-credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure a region is configured. The value is not used but is here to ensure backwards compatibility
|
||||
// when support for other regions is added.
|
||||
final String regionStr;
|
||||
final String configuredRegion = config.region;
|
||||
if (configuredRegion != null && !configuredRegion.isBlank()) {
|
||||
regionStr = configuredRegion.toUpperCase(Locale.ROOT);
|
||||
} else {
|
||||
regionStr = localeProvider.getLocale().getCountry();
|
||||
}
|
||||
try {
|
||||
Region.valueOf(regionStr);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/account-handler.initialize.unsupported-region");
|
||||
return;
|
||||
}
|
||||
|
||||
final String configuredBaseUrl = config.apiBaseUrl;
|
||||
final String baseUrl = configuredBaseUrl != null ? configuredBaseUrl : API_ENDPOINT;
|
||||
this.api = new BluelinkApi(httpClient, baseUrl, timeZoneProvider, username, password, config.pin);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
loginTask = scheduler.schedule(this::login, 0, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void login() {
|
||||
final BluelinkApi bluelinkApi = api;
|
||||
if (bluelinkApi == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bluelinkApi.login()) {
|
||||
logger.debug("Bluelink login successful");
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/account-handler.login.login-failed");
|
||||
}
|
||||
} catch (final BluelinkApiException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
logger.debug("Disposing Bluelink account handler");
|
||||
final ScheduledFuture<?> task = loginTask;
|
||||
if (task != null) {
|
||||
task.cancel(true);
|
||||
loginTask = null;
|
||||
}
|
||||
api = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(final ChannelUID channelUID, final Command command) {
|
||||
// Bridge has no channels to handle
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(BluelinkVehicleDiscoveryService.class);
|
||||
}
|
||||
|
||||
public List<VehicleInfo> getVehicles() throws BluelinkApiException {
|
||||
final Backoff backoff = new Backoff(Duration.ofSeconds(1), Duration.ofMillis(300), 3);
|
||||
final BluelinkApi bluelinkApi = api;
|
||||
if (bluelinkApi == null) {
|
||||
return List.of();
|
||||
}
|
||||
while (true) {
|
||||
try {
|
||||
return bluelinkApi.getVehicles();
|
||||
} catch (final RetryableRequestException e) {
|
||||
if (backoff.hasMoreAttempts()) {
|
||||
try {
|
||||
Thread.sleep(backoff.nextDelay());
|
||||
} catch (final InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BluelinkApiException("interrupted");
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean lockVehicle(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
final BluelinkApi api = this.api;
|
||||
return api != null && api.lockVehicle(vehicle);
|
||||
}
|
||||
|
||||
public boolean unlockVehicle(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
final BluelinkApi api = this.api;
|
||||
return api != null && api.unlockVehicle(vehicle);
|
||||
}
|
||||
|
||||
public boolean startCharging(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
final BluelinkApi api = this.api;
|
||||
return api != null && api.startCharging(vehicle);
|
||||
}
|
||||
|
||||
public boolean stopCharging(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
final BluelinkApi api = this.api;
|
||||
return api != null && api.stopCharging(vehicle);
|
||||
}
|
||||
|
||||
public boolean climateStart(final VehicleInfo vehicle, final QuantityType<Temperature> temperature,
|
||||
final boolean heat, final boolean defrost) throws BluelinkApiException {
|
||||
final BluelinkApi bluelinkApi = api;
|
||||
if (bluelinkApi == null) {
|
||||
return false;
|
||||
}
|
||||
return bluelinkApi.climateStart(vehicle, temperature, heat, defrost);
|
||||
}
|
||||
|
||||
public boolean climateStop(final VehicleInfo vehicle) throws BluelinkApiException {
|
||||
final BluelinkApi bluelinkApi = api;
|
||||
if (bluelinkApi == null) {
|
||||
return false;
|
||||
}
|
||||
return bluelinkApi.climateStop(vehicle);
|
||||
}
|
||||
|
||||
public @Nullable VehicleStatus getVehicleStatus(final VehicleInfo vehicle, final boolean forceRefresh)
|
||||
throws BluelinkApiException {
|
||||
final BluelinkApi bluelinkApi = api;
|
||||
if (bluelinkApi == null) {
|
||||
return null;
|
||||
}
|
||||
return bluelinkApi.getVehicleStatus(vehicle, forceRefresh);
|
||||
}
|
||||
}
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.handler;
|
||||
|
||||
import static java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME;
|
||||
import static java.util.stream.Collectors.toUnmodifiableSet;
|
||||
import static org.openhab.binding.bluelink.internal.BluelinkBindingConstants.*;
|
||||
import static org.openhab.core.library.CoreItemFactory.SWITCH;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApiException;
|
||||
import org.openhab.binding.bluelink.internal.config.BluelinkVehicleConfiguration;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleStatus;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleStatus.VehicleStatusData;
|
||||
import org.openhab.core.library.types.DateTimeType;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.OpenClosedType;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.ImperialUnits;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.Channel;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.thing.binding.builder.ChannelBuilder;
|
||||
import org.openhab.core.thing.binding.builder.ThingBuilder;
|
||||
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link BluelinkVehicleHandler} handles a single Bluelink vehicle.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkVehicleHandler extends BaseThingHandler {
|
||||
|
||||
private static final Duration DEFAULT_REFRESH_INTERVAL = Duration.ofMinutes(30);
|
||||
private static final Duration DEFAULT_FORCE_REFRESH_INTERVAL = Duration.ofMinutes(240);
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BluelinkVehicleHandler.class);
|
||||
|
||||
private volatile @Nullable ScheduledFuture<?> refreshJob;
|
||||
private volatile @Nullable ScheduledFuture<?> forceRefreshJob;
|
||||
private volatile @Nullable ScheduledFuture<?> initTask;
|
||||
private volatile @Nullable VehicleInfo vehicle;
|
||||
private @Nullable Duration forceRefreshInterval;
|
||||
|
||||
public BluelinkVehicleHandler(final Thing thing) {
|
||||
super(thing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(VehicleActions.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
logger.debug("Initializing Bluelink vehicle handler");
|
||||
|
||||
final BluelinkVehicleConfiguration config = getConfigAs(BluelinkVehicleConfiguration.class);
|
||||
final String vin = config.vin;
|
||||
if (vin == null || vin.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/vehicle-handler.initialize.vin-required");
|
||||
return;
|
||||
}
|
||||
|
||||
final Duration refreshInterval = config.refreshInterval >= 1 ? Duration.ofMinutes(config.refreshInterval)
|
||||
: DEFAULT_REFRESH_INTERVAL;
|
||||
final Duration forceRefreshInterval = config.forceRefreshInterval >= 1
|
||||
? Duration.ofMinutes(config.forceRefreshInterval)
|
||||
: DEFAULT_FORCE_REFRESH_INTERVAL;
|
||||
this.forceRefreshInterval = forceRefreshInterval;
|
||||
refreshJob = scheduler.scheduleWithFixedDelay(() -> refreshVehicleStatus(false), 5, refreshInterval.toSeconds(),
|
||||
TimeUnit.SECONDS);
|
||||
forceRefreshJob = scheduler.scheduleWithFixedDelay(() -> refreshVehicleStatus(true), 30,
|
||||
forceRefreshInterval.toSeconds(), TimeUnit.SECONDS);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
initTask = scheduler.schedule(() -> loadVehicle(vin), 0, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void loadVehicle(final String vin) {
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
if (bridgeHnd == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
bridgeHnd.getVehicles().stream().filter(v -> vin.equals(v.vin())).findFirst().ifPresentOrElse(v -> {
|
||||
this.vehicle = v;
|
||||
updateProperty(PROPERTY_MODEL, v.modelCode());
|
||||
updateProperty(PROPERTY_ENGINE_TYPE, v.evStatus());
|
||||
createDynamicChannels();
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}, () -> updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/vehicle-handler.initialize.vehicle-not-found")
|
||||
|
||||
);
|
||||
} catch (final BluelinkApiException e) {
|
||||
logger.debug("error loading vehicle: {}", e.getMessage());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/vehicle-handler.initialize.vehicle-data-error");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
logger.debug("Disposing Bluelink vehicle handler");
|
||||
|
||||
ScheduledFuture<?> job = refreshJob;
|
||||
if (job != null) {
|
||||
job.cancel(true);
|
||||
refreshJob = null;
|
||||
}
|
||||
job = forceRefreshJob;
|
||||
if (job != null) {
|
||||
job.cancel(true);
|
||||
forceRefreshJob = null;
|
||||
}
|
||||
job = initTask;
|
||||
if (job != null) {
|
||||
job.cancel(true);
|
||||
initTask = null;
|
||||
}
|
||||
|
||||
vehicle = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(final ChannelUID channelUID, final Command command) {
|
||||
if (command instanceof RefreshType) {
|
||||
// we do not force a refresh from the vehicle because of the low rate limit
|
||||
refreshVehicleStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean lock() throws BluelinkApiException {
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
final var vehicle = this.vehicle;
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean res = bridgeHnd.lockVehicle(vehicle);
|
||||
if (res) {
|
||||
scheduleForceRefresh();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public boolean unlock() throws BluelinkApiException {
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
final var vehicle = this.vehicle;
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean res = bridgeHnd.unlockVehicle(vehicle);
|
||||
if (res) {
|
||||
scheduleForceRefresh();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public boolean startCharging() throws BluelinkApiException {
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
final var vehicle = this.vehicle;
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean res = bridgeHnd.startCharging(vehicle);
|
||||
if (res) {
|
||||
scheduleForceRefresh();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public boolean stopCharging() throws BluelinkApiException {
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
final var vehicle = this.vehicle;
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean res = bridgeHnd.stopCharging(vehicle);
|
||||
if (res) {
|
||||
scheduleForceRefresh();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public boolean climateStart(final QuantityType<Temperature> temperature, final boolean heat, final boolean defrost)
|
||||
throws BluelinkApiException {
|
||||
final VehicleInfo vehicle = this.vehicle;
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bridgeHnd.climateStart(vehicle, temperature, heat, defrost);
|
||||
}
|
||||
|
||||
public boolean climateStop() throws BluelinkApiException {
|
||||
final VehicleInfo vehicle = this.vehicle;
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bridgeHnd.climateStop(vehicle);
|
||||
}
|
||||
|
||||
public void refreshVehicleStatus(boolean forceRefresh) {
|
||||
final VehicleInfo vehicle = this.vehicle;
|
||||
final var bridgeHnd = getBridgeHandler();
|
||||
if (vehicle == null || bridgeHnd == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("refreshing vehicle status");
|
||||
final VehicleStatus status = bridgeHnd.getVehicleStatus(vehicle, forceRefresh);
|
||||
if (status != null) {
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
updateChannels(status);
|
||||
}
|
||||
} catch (final BluelinkApiException e) {
|
||||
logger.debug("Failed to refresh vehicle status: {}", e.getMessage());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChannels(final VehicleStatus status) {
|
||||
final VehicleStatusData data = status.vehicleStatus();
|
||||
final VehicleInfo info = vehicle;
|
||||
if (data == null || info == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Status group
|
||||
updateState(GROUP_STATUS, CHANNEL_LOCKED, OnOffType.from(data.doorLock()));
|
||||
updateState(GROUP_STATUS, CHANNEL_ENGINE_RUNNING, OnOffType.from(data.engine()));
|
||||
updateState(GROUP_STATUS, CHANNEL_ODOMETER, new QuantityType<>(info.odometer(), ImperialUnits.MILE));
|
||||
if (data.dateTime() != null) {
|
||||
try {
|
||||
final Instant lastUpdate = ISO_ZONED_DATE_TIME.parse(data.dateTime(), Instant::from);
|
||||
updateState(GROUP_STATUS, CHANNEL_LAST_UPDATE, new DateTimeType(lastUpdate));
|
||||
} catch (final DateTimeParseException e) {
|
||||
logger.warn("unexpected dateTime format: {}", data.dateTime());
|
||||
}
|
||||
}
|
||||
|
||||
// 12V Battery
|
||||
if (data.battery() != null) {
|
||||
updateState(GROUP_STATUS, CHANNEL_BATTERY_LEVEL,
|
||||
new QuantityType<>(data.battery().stateOfCharge(), Units.PERCENT));
|
||||
}
|
||||
|
||||
// Doors
|
||||
final var doorOpen = data.doorOpen();
|
||||
if (doorOpen != null) {
|
||||
updateState(GROUP_DOORS, CHANNEL_DOOR_FRONT_LEFT, toOpenClosed(doorOpen.frontLeft()));
|
||||
updateState(GROUP_DOORS, CHANNEL_DOOR_FRONT_RIGHT, toOpenClosed(doorOpen.frontRight()));
|
||||
updateState(GROUP_DOORS, CHANNEL_DOOR_REAR_LEFT, toOpenClosed(doorOpen.backLeft()));
|
||||
updateState(GROUP_DOORS, CHANNEL_DOOR_REAR_RIGHT, toOpenClosed(doorOpen.backRight()));
|
||||
}
|
||||
updateState(GROUP_DOORS, CHANNEL_TRUNK, toOpenClosed(data.trunkOpen()));
|
||||
updateState(GROUP_DOORS, CHANNEL_HOOD, toOpenClosed(data.hoodOpen()));
|
||||
|
||||
// Windows
|
||||
final var windowOpen = data.windowOpen();
|
||||
if (windowOpen != null) {
|
||||
updateState(GROUP_WINDOWS, CHANNEL_WINDOW_FRONT_LEFT, toOpenClosed(windowOpen.frontLeft()));
|
||||
updateState(GROUP_WINDOWS, CHANNEL_WINDOW_FRONT_RIGHT, toOpenClosed(windowOpen.frontRight()));
|
||||
updateState(GROUP_WINDOWS, CHANNEL_WINDOW_REAR_LEFT, toOpenClosed(windowOpen.backLeft()));
|
||||
updateState(GROUP_WINDOWS, CHANNEL_WINDOW_REAR_RIGHT, toOpenClosed(windowOpen.backRight()));
|
||||
}
|
||||
|
||||
// Climate
|
||||
updateState(GROUP_CLIMATE, CHANNEL_HVAC_ON, OnOffType.from(data.airCtrlOn()));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_DEFROST, OnOffType.from(data.defrost()));
|
||||
final var airTemp = data.airTemp();
|
||||
if (airTemp != null) {
|
||||
updateState(GROUP_CLIMATE, CHANNEL_TEMPERATURE, airTemp.getTemperature());
|
||||
}
|
||||
updateState(GROUP_CLIMATE, CHANNEL_STEERING_HEATER, OnOffType.from(data.steerWheelHeat() > 0));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_REAR_WINDOW_HEATER, OnOffType.from(data.sideBackWindowHeat() > 0));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_SIDE_MIRROR_HEATER, OnOffType.from(data.sideMirrorHeat() > 0));
|
||||
|
||||
final var heater = data.seatHeaterVentState();
|
||||
if (heater != null) {
|
||||
updateState(GROUP_CLIMATE, CHANNEL_SEAT_FRONT_LEFT, new DecimalType(heater.frontLeft()));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_SEAT_FRONT_RIGHT, new DecimalType(heater.frontRight()));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_SEAT_REAR_LEFT, new DecimalType(heater.rearLeft()));
|
||||
updateState(GROUP_CLIMATE, CHANNEL_SEAT_REAR_RIGHT, new DecimalType(heater.rearRight()));
|
||||
}
|
||||
|
||||
// EV-specific
|
||||
final var evStatus = data.evStatus();
|
||||
if (info.isElectric() && evStatus != null) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_EV_BATTERY_SOC,
|
||||
new QuantityType<>(evStatus.batteryStatus(), Units.PERCENT));
|
||||
updateState(GROUP_CHARGING, CHANNEL_EV_CHARGING, OnOffType.from(evStatus.batteryCharge()));
|
||||
updateState(GROUP_CHARGING, CHANNEL_EV_PLUGGED_IN, OnOffType.from(evStatus.batteryPlugin() > 0));
|
||||
|
||||
// Driving ranges
|
||||
if (evStatus.drvDistance() != null && !evStatus.drvDistance().isEmpty()) {
|
||||
final var rangeByFuel = evStatus.drvDistance().getFirst().rangeByFuel();
|
||||
if (rangeByFuel != null) {
|
||||
if (rangeByFuel.totalAvailableRange() != null) {
|
||||
updateState(GROUP_RANGE, CHANNEL_TOTAL_RANGE, rangeByFuel.totalAvailableRange().getRange());
|
||||
}
|
||||
if (rangeByFuel.evModeRange() != null) {
|
||||
updateState(GROUP_RANGE, CHANNEL_EV_RANGE, rangeByFuel.evModeRange().getRange());
|
||||
}
|
||||
if (rangeByFuel.gasModeRange() != null) {
|
||||
updateState(GROUP_RANGE, CHANNEL_FUEL_RANGE, rangeByFuel.gasModeRange().getRange());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charge limits
|
||||
final var reservChargeInfos = evStatus.reservChargeInfos();
|
||||
if (reservChargeInfos != null && reservChargeInfos.targetSocList() != null) {
|
||||
for (final VehicleStatus.TargetSOC target : reservChargeInfos.targetSocList()) {
|
||||
if (target.plugType() == 0) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_CHARGE_LIMIT_DC,
|
||||
new QuantityType<>(target.targetSocLevel(), Units.PERCENT));
|
||||
} else if (target.plugType() == 1) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_CHARGE_LIMIT_AC,
|
||||
new QuantityType<>(target.targetSocLevel(), Units.PERCENT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charge times
|
||||
final var remainTime2 = evStatus.remainTime2();
|
||||
if (remainTime2 != null) {
|
||||
if (remainTime2.atc() != null) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_CURRENT,
|
||||
new QuantityType<>(remainTime2.atc().value(), Units.MINUTE));
|
||||
}
|
||||
if (remainTime2.etc1() != null) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_FAST,
|
||||
new QuantityType<>(remainTime2.etc1().value(), Units.MINUTE));
|
||||
}
|
||||
if (remainTime2.etc2() != null) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_PORTABLE,
|
||||
new QuantityType<>(remainTime2.etc2().value(), Units.MINUTE));
|
||||
}
|
||||
if (remainTime2.etc3() != null) {
|
||||
updateState(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_STATION,
|
||||
new QuantityType<>(remainTime2.etc3().value(), Units.MINUTE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuel (ICE only)
|
||||
if (!info.isElectric()) {
|
||||
updateState(GROUP_FUEL, CHANNEL_FUEL_LEVEL, new QuantityType<>(data.fuelLevel(), Units.PERCENT));
|
||||
updateState(GROUP_FUEL, CHANNEL_FUEL_LOW_WARNING, OnOffType.from(data.lowFuelLight()));
|
||||
if (data.dte() != null) {
|
||||
updateState(GROUP_RANGE, CHANNEL_FUEL_RANGE,
|
||||
new QuantityType<>(data.dte().value(), ImperialUnits.MILE));
|
||||
}
|
||||
}
|
||||
|
||||
// Warnings
|
||||
updateState(GROUP_WARNINGS, CHANNEL_WASHER_FLUID_WARNING, OnOffType.from(data.washerFluidStatus()));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_BRAKE_FLUID_WARNING, OnOffType.from(data.brakeOilStatus()));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_SMART_KEY_BATTERY_WARNING, OnOffType.from(data.smartKeyBatteryWarning()));
|
||||
final var tirePressureWarning = data.tirePressureWarning();
|
||||
if (tirePressureWarning != null) {
|
||||
updateState(GROUP_WARNINGS, CHANNEL_TIRE_PRESSURE_WARNING, OnOffType.from(tirePressureWarning.all() > 0));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_TIRE_PRESSURE_WARNING_FR,
|
||||
OnOffType.from(tirePressureWarning.frontRight() > 0));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_TIRE_PRESSURE_WARNING_FL,
|
||||
OnOffType.from(tirePressureWarning.frontLeft() > 0));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_TIRE_PRESSURE_WARNING_RR,
|
||||
OnOffType.from(tirePressureWarning.rearRight() > 0));
|
||||
updateState(GROUP_WARNINGS, CHANNEL_TIRE_PRESSURE_WARNING_RL,
|
||||
OnOffType.from(tirePressureWarning.rearLeft() > 0));
|
||||
}
|
||||
|
||||
// Location
|
||||
final var location = data.vehicleLocation();
|
||||
if (location != null && location.coord() != null) {
|
||||
updateState(GROUP_STATUS, CHANNEL_LOCATION, new PointType(new DecimalType(location.coord().latitude()),
|
||||
new DecimalType(location.coord().longitude()), new DecimalType(location.coord().altitude())));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateState(final String group, final String channel, final State state) {
|
||||
updateState(new ChannelUID(getThing().getUID(), group, channel), state);
|
||||
}
|
||||
|
||||
private OpenClosedType toOpenClosed(final int value) {
|
||||
return value > 0 ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
|
||||
}
|
||||
|
||||
private OpenClosedType toOpenClosed(final boolean value) {
|
||||
return value ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
|
||||
}
|
||||
|
||||
private void createDynamicChannels() {
|
||||
final List<ChannelBuilder> newChannels;
|
||||
final String group;
|
||||
final var vehicle = this.vehicle;
|
||||
if (vehicle == null) {
|
||||
return;
|
||||
}
|
||||
if (vehicle.isElectric()) {
|
||||
group = GROUP_CHARGING;
|
||||
newChannels = List.of(
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_EV_BATTERY_SOC, NUMBER_DIMENSIONLESS, CHANNEL_TYPE_EV_SOC),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_EV_CHARGING, SWITCH, CHANNEL_TYPE_EV_CHARGING),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_EV_PLUGGED_IN, SWITCH, CHANNEL_TYPE_EV_PLUGGED_IN),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_CHARGE_LIMIT_DC, NUMBER_DIMENSIONLESS,
|
||||
CHANNEL_TYPE_CHARGE_LIMIT_DC),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_CHARGE_LIMIT_AC, NUMBER_DIMENSIONLESS,
|
||||
CHANNEL_TYPE_CHARGE_LIMIT_AC),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_CURRENT, NUMBER_TIME,
|
||||
CHANNEL_TYPE_CHARGE_TIME_CURRENT),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_FAST, NUMBER_TIME, CHANNEL_TYPE_CHARGE_TIME_FAST),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_PORTABLE, NUMBER_TIME,
|
||||
CHANNEL_TYPE_CHARGE_TIME_PORTABLE),
|
||||
buildChannel(GROUP_CHARGING, CHANNEL_TIME_TO_FULL_STATION, NUMBER_TIME,
|
||||
CHANNEL_TYPE_CHARGE_TIME_STATION));
|
||||
} else {
|
||||
group = GROUP_FUEL;
|
||||
newChannels = List.of(
|
||||
buildChannel(GROUP_FUEL, CHANNEL_FUEL_LEVEL, NUMBER_DIMENSIONLESS, CHANNEL_TYPE_FUEL_LEVEL),
|
||||
buildChannel(GROUP_FUEL, CHANNEL_FUEL_LOW_WARNING, SWITCH, CHANNEL_TYPE_FUEL_WARNING));
|
||||
}
|
||||
final Set<ChannelUID> currentChannels = getThing().getChannelsOfGroup(group).stream().map(Channel::getUID)
|
||||
.collect(toUnmodifiableSet());
|
||||
final ThingBuilder thingBuilder = editThing();
|
||||
newChannels.stream().map(ChannelBuilder::build).filter(c -> !currentChannels.contains(c.getUID()))
|
||||
.forEach(thingBuilder::withChannel);
|
||||
updateThing(thingBuilder.build());
|
||||
}
|
||||
|
||||
private ChannelBuilder buildChannel(final String group, final String channelId, final String itemType,
|
||||
final String channelTypeId) {
|
||||
final ChannelUID channelUID = new ChannelUID(getThing().getUID(), group, channelId);
|
||||
final ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, channelTypeId);
|
||||
return ChannelBuilder.create(channelUID, itemType).withType(channelTypeUID);
|
||||
}
|
||||
|
||||
private @Nullable BluelinkAccountHandler getBridgeHandler() {
|
||||
final var bridge = getBridge();
|
||||
if (bridge == null) {
|
||||
return null;
|
||||
}
|
||||
return (BluelinkAccountHandler) bridge.getHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule the forced refresh so the first execution starts in 10 seconds.
|
||||
*/
|
||||
private void scheduleForceRefresh() {
|
||||
final ScheduledFuture<?> job = forceRefreshJob;
|
||||
final Duration forceRefreshInterval = this.forceRefreshInterval;
|
||||
if (job == null || forceRefreshInterval == null) {
|
||||
return;
|
||||
}
|
||||
job.cancel(false);
|
||||
scheduler.scheduleWithFixedDelay(() -> refreshVehicleStatus(true), 10, forceRefreshInterval.toSeconds(),
|
||||
TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.handler;
|
||||
|
||||
import javax.measure.quantity.Temperature;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApiException;
|
||||
import org.openhab.core.automation.annotation.ActionInput;
|
||||
import org.openhab.core.automation.annotation.ActionOutput;
|
||||
import org.openhab.core.automation.annotation.RuleAction;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.thing.binding.ThingActions;
|
||||
import org.openhab.core.thing.binding.ThingActionsScope;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.ServiceScope;
|
||||
|
||||
/**
|
||||
* Actions for the Bluelink binding.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@Component(scope = ServiceScope.PROTOTYPE, service = VehicleActions.class)
|
||||
@ThingActionsScope(name = "bluelink")
|
||||
@NonNullByDefault
|
||||
public class VehicleActions implements ThingActions {
|
||||
private @Nullable BluelinkVehicleHandler handler;
|
||||
|
||||
@Override
|
||||
public void setThingHandler(@Nullable ThingHandler handler) {
|
||||
this.handler = (BluelinkVehicleHandler) handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return handler;
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.force-refresh.label", description = "@text/action.force-refresh.desc")
|
||||
public void forceRefresh() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
if (hnd != null) {
|
||||
hnd.refreshVehicleStatus(true);
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.lock.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean lock() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.lock();
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.unlock.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean unlock() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.unlock();
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.start-charging.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean startCharging() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.startCharging();
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.stop-charging.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean stopCharging() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.stopCharging();
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.climate-start.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean climateStart(
|
||||
final @ActionInput(name = "temperature", type = "QuantityType<Temperature>", label = "@text/action.climate-start.input.temperature.label") QuantityType<Temperature> temperature,
|
||||
final @ActionInput(name = "heating", label = "@text/action.climate-start.input.heating.label", description = "@text/action.climate-start.input.heating.desc") boolean heating,
|
||||
final @ActionInput(name = "defrost", label = "@text/action.climate-start.input.defrost.label") boolean defrost) {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.climateStart(temperature, heating, defrost);
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/action.climate-stop.label")
|
||||
@ActionOutput(type = "boolean")
|
||||
public boolean climateStop() {
|
||||
final BluelinkVehicleHandler hnd = handler;
|
||||
try {
|
||||
return hnd != null && hnd.climateStop();
|
||||
} catch (final BluelinkApiException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void forceRefresh(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.forceRefresh();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void climateStart(final @Nullable ThingActions actions, final QuantityType<Temperature> temperature,
|
||||
final boolean heating, final boolean defrost) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.climateStart(temperature, heating, defrost);
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void climateStop(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.climateStop();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void lock(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.lock();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void unlock(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.unlock();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void startCharging(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.startCharging();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
|
||||
public static void stopCharging(final @Nullable ThingActions actions) {
|
||||
if (actions instanceof VehicleActions va) {
|
||||
va.stopCharging();
|
||||
} else {
|
||||
throw new IllegalArgumentException("expected VehicleActions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.util;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Exponential backoff helper.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Backoff {
|
||||
private final long jitterMs;
|
||||
private final int maxAttempts;
|
||||
|
||||
private Duration nextDelay;
|
||||
private int attempts;
|
||||
|
||||
public Backoff(final Duration initialDelay, final Duration jitter, final int maxAttempts) {
|
||||
this.nextDelay = initialDelay;
|
||||
this.jitterMs = jitter.toMillis();
|
||||
this.maxAttempts = maxAttempts;
|
||||
}
|
||||
|
||||
public boolean hasMoreAttempts() {
|
||||
return attempts < maxAttempts;
|
||||
}
|
||||
|
||||
public Duration nextDelay() {
|
||||
attempts += 1;
|
||||
final long randomJitter = ThreadLocalRandom.current().nextLong(jitterMs);
|
||||
nextDelay = nextDelay.multipliedBy(2).plusMillis(randomJitter);
|
||||
return nextDelay;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="bluelink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>binding</type>
|
||||
<name>Bluelink Binding</name>
|
||||
<description>This binding enables access to Bluelink connected vehicle services.</description>
|
||||
<connection>cloud</connection>
|
||||
|
||||
</addon:addon>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# add-on
|
||||
|
||||
addon.bluelink.name = Bluelink Binding
|
||||
addon.bluelink.description = This binding enables access to Bluelink connected vehicle services.
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.bluelink.account.label = Bluelink Account
|
||||
thing-type.bluelink.account.description = Bluelink account for accessing connected vehicle services
|
||||
thing-type.bluelink.vehicle.label = Bluelink Vehicle
|
||||
thing-type.bluelink.vehicle.description = A Hyundai vehicle connected via Bluelink.
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.bluelink.account.password.label = Password
|
||||
thing-type.config.bluelink.account.password.description = Your Bluelink account password
|
||||
thing-type.config.bluelink.account.pin.label = PIN
|
||||
thing-type.config.bluelink.account.pin.description = Your Bluelink service PIN (required for lock/unlock commands)
|
||||
thing-type.config.bluelink.account.region.label = Region
|
||||
thing-type.config.bluelink.account.region.description = API region. If not set, auto-detected from system locale.
|
||||
thing-type.config.bluelink.account.region.option.US = United States
|
||||
thing-type.config.bluelink.account.username.label = Username
|
||||
thing-type.config.bluelink.account.username.description = Your Bluelink account email address
|
||||
thing-type.config.bluelink.vehicle.forceRefreshInterval.label = Refresh Interval
|
||||
thing-type.config.bluelink.vehicle.forceRefreshInterval.description = Interval in minutes to refresh data from the vehicle
|
||||
thing-type.config.bluelink.vehicle.refreshInterval.label = Refresh Interval
|
||||
thing-type.config.bluelink.vehicle.refreshInterval.description = Interval in minutes to fetch cached data from the Hyundai/Kia servers
|
||||
thing-type.config.bluelink.vehicle.vin.label = VIN
|
||||
thing-type.config.bluelink.vehicle.vin.description = Vehicle Identification Number (VIN)
|
||||
|
||||
# channel group types
|
||||
|
||||
channel-group-type.bluelink.charging.label = Charging
|
||||
channel-group-type.bluelink.climate.label = Climate Control
|
||||
channel-group-type.bluelink.climate.channel.rear-window-heater.label = Rear Window Heater
|
||||
channel-group-type.bluelink.climate.channel.seat-front-left.label = Front Left Seat Heater
|
||||
channel-group-type.bluelink.climate.channel.seat-front-right.label = Front Right Seat Heater
|
||||
channel-group-type.bluelink.climate.channel.seat-rear-left.label = Rear Left Seat Heater
|
||||
channel-group-type.bluelink.climate.channel.seat-rear-right.label = Rear Right Seat Heater
|
||||
channel-group-type.bluelink.climate.channel.side-mirror-heater.label = Side Mirror Heater
|
||||
channel-group-type.bluelink.climate.channel.steering-heater.label = Steering Wheel Heater
|
||||
channel-group-type.bluelink.doors.label = Doors
|
||||
channel-group-type.bluelink.doors.channel.front-left.label = Front Left Door
|
||||
channel-group-type.bluelink.doors.channel.front-right.label = Front Right Door
|
||||
channel-group-type.bluelink.doors.channel.rear-left.label = Rear Left Door
|
||||
channel-group-type.bluelink.doors.channel.rear-right.label = Rear Right Door
|
||||
channel-group-type.bluelink.fuel.label = Fuel
|
||||
channel-group-type.bluelink.range.label = Driving Range
|
||||
channel-group-type.bluelink.range.channel.ev-range.label = Electric Driving Range
|
||||
channel-group-type.bluelink.range.channel.fuel-range.label = Fuel Driving Range
|
||||
channel-group-type.bluelink.range.channel.total-range.label = Total Driving Range
|
||||
channel-group-type.bluelink.status.label = Vehicle Status
|
||||
channel-group-type.bluelink.warnings.label = Warnings
|
||||
channel-group-type.bluelink.warnings.channel.brake-fluid.label = Brake Fluid Level Warning
|
||||
channel-group-type.bluelink.warnings.channel.smart-key-battery.label = Smart Key Battery Warning
|
||||
channel-group-type.bluelink.warnings.channel.tire-pressure.label = Tire Pressure Warning
|
||||
channel-group-type.bluelink.warnings.channel.tire-pressure-front-left.label = Tire Pressure Warning (Front Left)
|
||||
channel-group-type.bluelink.warnings.channel.tire-pressure-front-right.label = Tire Pressure Warning (Front Right)
|
||||
channel-group-type.bluelink.warnings.channel.tire-pressure-rear-left.label = Tire Pressure Warning (Rear Left)
|
||||
channel-group-type.bluelink.warnings.channel.tire-pressure-rear-right.label = Tire Pressure Warning (Rear Right)
|
||||
channel-group-type.bluelink.warnings.channel.washer-fluid.label = Washer Fluid Level Warning
|
||||
channel-group-type.bluelink.windows.label = Windows
|
||||
channel-group-type.bluelink.windows.channel.front-left.label = Front Left Window
|
||||
channel-group-type.bluelink.windows.channel.front-right.label = Front Right Window
|
||||
channel-group-type.bluelink.windows.channel.rear-left.label = Rear Left Window
|
||||
channel-group-type.bluelink.windows.channel.rear-right.label = Rear Right Window
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.bluelink.battery-level.label = 12V Battery Level
|
||||
channel-type.bluelink.battery-level.description = 12V battery state of charge
|
||||
channel-type.bluelink.charge-limit-ac.label = AC Charge Limit
|
||||
channel-type.bluelink.charge-limit-ac.description = Target charge limit on AC
|
||||
channel-type.bluelink.charge-limit-dc.label = DC Charge Limit
|
||||
channel-type.bluelink.charge-limit-dc.description = Target charge limit on DC
|
||||
channel-type.bluelink.charge-time-current.label = Time to Full (Current)
|
||||
channel-type.bluelink.charge-time-current.description = Estimated time to fully charged with current charger
|
||||
channel-type.bluelink.charge-time-fast.label = Time to Full (Fast)
|
||||
channel-type.bluelink.charge-time-fast.description = Estimated time to fully charged with Level 3 charger
|
||||
channel-type.bluelink.charge-time-portable.label = Time to Full (Portable)
|
||||
channel-type.bluelink.charge-time-portable.description = Estimated time to fully charged with Level 1 charger
|
||||
channel-type.bluelink.charge-time-station.label = Time to Full (Station)
|
||||
channel-type.bluelink.charge-time-station.description = Estimated time to fully charged with Level 2 charger
|
||||
channel-type.bluelink.defrost.label = Defrost
|
||||
channel-type.bluelink.defrost.description = Defrost on/off status
|
||||
channel-type.bluelink.door-state.label = Door
|
||||
channel-type.bluelink.door-state.description = Door open/closed state
|
||||
channel-type.bluelink.engine-running.label = Engine Running
|
||||
channel-type.bluelink.engine-running.description = Engine running status
|
||||
channel-type.bluelink.ev-charging.label = Charging State
|
||||
channel-type.bluelink.ev-charging.description = EV charging status
|
||||
channel-type.bluelink.ev-plugged-in.label = Plugged In
|
||||
channel-type.bluelink.ev-plugged-in.description = Whether the charge cable is plugged
|
||||
channel-type.bluelink.ev-soc.label = EV Battery
|
||||
channel-type.bluelink.ev-soc.description = EV battery state of charge
|
||||
channel-type.bluelink.fuel-level.label = Fuel Level
|
||||
channel-type.bluelink.fuel-level.description = Fuel tank level
|
||||
channel-type.bluelink.fuel-warning.label = Fuel Level Warning
|
||||
channel-type.bluelink.fuel-warning.description = Low fuel level indicator
|
||||
channel-type.bluelink.heater-state.label = Heater
|
||||
channel-type.bluelink.heater-state.description = Heater on/off status
|
||||
channel-type.bluelink.hood-state.label = Hood
|
||||
channel-type.bluelink.hood-state.description = Hood open/closed state
|
||||
channel-type.bluelink.hvac-on.label = Climate Control
|
||||
channel-type.bluelink.hvac-on.description = Climate control active state
|
||||
channel-type.bluelink.last-update.label = Last Update
|
||||
channel-type.bluelink.last-update.description = Time of last vehicle data update
|
||||
channel-type.bluelink.locked.label = Locked
|
||||
channel-type.bluelink.locked.description = Vehicle door lock status
|
||||
channel-type.bluelink.odometer.label = Odometer
|
||||
channel-type.bluelink.odometer.description = Vehicle odometer reading
|
||||
channel-type.bluelink.range.label = Range
|
||||
channel-type.bluelink.range.description = Estimated driving range
|
||||
channel-type.bluelink.seat-heater.label = Seat Heater
|
||||
channel-type.bluelink.seat-heater.description = Seat heater level (0-3)
|
||||
channel-type.bluelink.temperature-setpoint.label = Temperature Setpoint
|
||||
channel-type.bluelink.temperature-setpoint.description = Air temperature setpoint
|
||||
channel-type.bluelink.trunk-state.label = Trunk
|
||||
channel-type.bluelink.trunk-state.description = Trunk open/closed state
|
||||
channel-type.bluelink.warning.label = Warning
|
||||
channel-type.bluelink.warning.description = Warning indicator
|
||||
channel-type.bluelink.window-state.label = Window
|
||||
channel-type.bluelink.window-state.description = Window open/closed state
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.bluelink.charge-time.label = Time to Full
|
||||
channel-type.bluelink.charge-time.description = Estimated time to fully charged.
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.bluelink.charge-limit.label = Charge Limit
|
||||
channel-type.bluelink.charge-limit.description = Target state of charge limit.
|
||||
|
||||
# binding
|
||||
|
||||
binding.bluelink.name = Bluelink Binding
|
||||
binding.bluelink.description = Binding for Bluelink connected vehicle services.
|
||||
|
||||
# channel groups
|
||||
|
||||
channel-group-type.bluelink.fuel.channel.low-fuel-warning.label = Fuel Level Warning
|
||||
|
||||
# account handler
|
||||
|
||||
account-handler.initialize.missing-credentials = Username and password required
|
||||
account-handler.login.login-failed = Login failed
|
||||
account-handler.initialize.unsupported-region = Unsupported region
|
||||
|
||||
# vehicle handler
|
||||
|
||||
vehicle-handler.initialize.vehicle-not-found = Vehicle not found
|
||||
vehicle-handler.initialize.vin-required = VIN is required
|
||||
vehicle-handler.initialize.vehicle-data-error = Failed to load vehicle data
|
||||
|
||||
# actions
|
||||
|
||||
action.force-refresh.label = Force Refresh
|
||||
action.force-refresh.desc = Force update vehicle status from the vehicle, bypassing cache.
|
||||
action.lock.label = Lock Vehicle
|
||||
action.unlock.label = Unlock Vehicle
|
||||
action.start-charging.label = Start Charging
|
||||
action.stop-charging.label = Stop Charging
|
||||
action.climate-start.label = Climate Start
|
||||
action.climate-start.input.temperature.label = Cabin Temperature
|
||||
action.climate-start.input.heating.label = Heated Features
|
||||
action.climate-start.input.heating.desc = Rear defroster, side mirrors and steering wheel heating (if equipped).
|
||||
action.climate-start.input.defrost.label = Front Defroster
|
||||
action.climate-stop.label = Climate Stop
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="bluelink"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<bridge-type id="account">
|
||||
<label>Bluelink Account</label>
|
||||
<description>Bluelink account for accessing connected vehicle services</description>
|
||||
<semantic-equipment-tag>WebService</semantic-equipment-tag>
|
||||
|
||||
<config-description>
|
||||
<parameter name="username" type="text" required="true">
|
||||
<label>Username</label>
|
||||
<description>Your Bluelink account email address</description>
|
||||
</parameter>
|
||||
<parameter name="password" type="text" required="true">
|
||||
<label>Password</label>
|
||||
<description>Your Bluelink account password</description>
|
||||
<context>password</context>
|
||||
</parameter>
|
||||
<parameter name="pin" type="text" required="false">
|
||||
<label>PIN</label>
|
||||
<description>Your Bluelink service PIN (required for lock/unlock commands)</description>
|
||||
<context>password</context>
|
||||
</parameter>
|
||||
<parameter name="region" type="text" required="false">
|
||||
<advanced>true</advanced>
|
||||
<label>Region</label>
|
||||
<description>API region. If not set, auto-detected from system locale.</description>
|
||||
<options>
|
||||
<option value="US">United States</option>
|
||||
</options>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</bridge-type>
|
||||
|
||||
</thing:thing-descriptions>
|
||||
@@ -0,0 +1,495 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="bluelink"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="vehicle">
|
||||
<supported-bridge-type-refs>
|
||||
<bridge-type-ref id="account"/>
|
||||
</supported-bridge-type-refs>
|
||||
|
||||
<label>Bluelink Vehicle</label>
|
||||
<description>A Hyundai vehicle connected via Bluelink.</description>
|
||||
<semantic-equipment-tag>Vehicle</semantic-equipment-tag>
|
||||
|
||||
<channel-groups>
|
||||
<channel-group id="status" typeId="status"/>
|
||||
<channel-group id="charging" typeId="charging"/>
|
||||
<channel-group id="fuel" typeId="fuel"/>
|
||||
<channel-group id="doors" typeId="doors"/>
|
||||
<channel-group id="windows" typeId="windows"/>
|
||||
<channel-group id="climate" typeId="climate"/>
|
||||
<channel-group id="range" typeId="range"/>
|
||||
<channel-group id="warnings" typeId="warnings"/>
|
||||
</channel-groups>
|
||||
|
||||
<representation-property>vin</representation-property>
|
||||
|
||||
<config-description>
|
||||
<parameter name="vin" type="text" required="true">
|
||||
<label>VIN</label>
|
||||
<description>Vehicle Identification Number (VIN)</description>
|
||||
</parameter>
|
||||
<parameter name="refreshInterval" type="integer" min="0" unit="min" required="false">
|
||||
<label>Refresh Interval</label>
|
||||
<description>Interval in minutes to fetch cached data from the Hyundai/Kia servers</description>
|
||||
<default>30</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
<parameter name="forceRefreshInterval" type="integer" min="0" unit="min" required="false">
|
||||
<label>Refresh Interval</label>
|
||||
<description>Interval in minutes to refresh data from the vehicle</description>
|
||||
<default>240</default>
|
||||
<advanced>true</advanced>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<!-- Channel Group Types -->
|
||||
|
||||
<channel-group-type id="status">
|
||||
<label>Vehicle Status</label>
|
||||
<channels>
|
||||
<channel id="locked" typeId="locked"/>
|
||||
<channel id="engine-running" typeId="engine-running"/>
|
||||
<channel id="odometer" typeId="odometer"/>
|
||||
<channel id="battery-level" typeId="battery-level"/>
|
||||
<channel id="location" typeId="system.location"/>
|
||||
<channel id="last-update" typeId="last-update"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="charging">
|
||||
<label>Charging</label>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="fuel">
|
||||
<label>Fuel</label>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="doors">
|
||||
<label>Doors</label>
|
||||
<channels>
|
||||
<channel id="front-left" typeId="door-state">
|
||||
<label>Front Left Door</label>
|
||||
</channel>
|
||||
<channel id="front-right" typeId="door-state">
|
||||
<label>Front Right Door</label>
|
||||
</channel>
|
||||
<channel id="rear-left" typeId="door-state">
|
||||
<label>Rear Left Door</label>
|
||||
</channel>
|
||||
<channel id="rear-right" typeId="door-state">
|
||||
<label>Rear Right Door</label>
|
||||
</channel>
|
||||
<channel id="trunk" typeId="trunk-state"/>
|
||||
<channel id="hood" typeId="hood-state"/>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="windows">
|
||||
<label>Windows</label>
|
||||
<channels>
|
||||
<channel id="front-left" typeId="window-state">
|
||||
<label>Front Left Window</label>
|
||||
</channel>
|
||||
<channel id="front-right" typeId="window-state">
|
||||
<label>Front Right Window</label>
|
||||
</channel>
|
||||
<channel id="rear-left" typeId="window-state">
|
||||
<label>Rear Left Window</label>
|
||||
</channel>
|
||||
<channel id="rear-right" typeId="window-state">
|
||||
<label>Rear Right Window</label>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="climate">
|
||||
<label>Climate Control</label>
|
||||
<channels>
|
||||
<channel id="hvac-on" typeId="hvac-on"/>
|
||||
<channel id="temperature-setpoint" typeId="temperature-setpoint"/>
|
||||
<channel id="defrost" typeId="defrost"/>
|
||||
<channel id="seat-front-left" typeId="seat-heater">
|
||||
<label>Front Left Seat Heater</label>
|
||||
</channel>
|
||||
<channel id="seat-front-right" typeId="seat-heater">
|
||||
<label>Front Right Seat Heater</label>
|
||||
</channel>
|
||||
<channel id="seat-rear-left" typeId="seat-heater">
|
||||
<label>Rear Left Seat Heater</label>
|
||||
</channel>
|
||||
<channel id="seat-rear-right" typeId="seat-heater">
|
||||
<label>Rear Right Seat Heater</label>
|
||||
</channel>
|
||||
<channel id="steering-heater" typeId="heater-state">
|
||||
<label>Steering Wheel Heater</label>
|
||||
</channel>
|
||||
<channel id="rear-window-heater" typeId="heater-state">
|
||||
<label>Rear Window Heater</label>
|
||||
</channel>
|
||||
<channel id="side-mirror-heater" typeId="heater-state">
|
||||
<label>Side Mirror Heater</label>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="range">
|
||||
<label>Driving Range</label>
|
||||
<channels>
|
||||
<channel id="total-range" typeId="range">
|
||||
<label>Total Driving Range</label>
|
||||
</channel>
|
||||
<channel id="ev-range" typeId="range">
|
||||
<label>Electric Driving Range</label>
|
||||
</channel>
|
||||
<channel id="fuel-range" typeId="range">
|
||||
<label>Fuel Driving Range</label>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="warnings">
|
||||
<label>Warnings</label>
|
||||
<channels>
|
||||
<channel id="tire-pressure" typeId="warning">
|
||||
<label>Tire Pressure Warning</label>
|
||||
</channel>
|
||||
<channel id="tire-pressure-front-left" typeId="warning">
|
||||
<label>Tire Pressure Warning (Front Left)</label>
|
||||
</channel>
|
||||
<channel id="tire-pressure-front-right" typeId="warning">
|
||||
<label>Tire Pressure Warning (Front Right)</label>
|
||||
</channel>
|
||||
<channel id="tire-pressure-rear-left" typeId="warning">
|
||||
<label>Tire Pressure Warning (Rear Left)</label>
|
||||
</channel>
|
||||
<channel id="tire-pressure-rear-right" typeId="warning">
|
||||
<label>Tire Pressure Warning (Rear Right)</label>
|
||||
</channel>
|
||||
<channel id="washer-fluid" typeId="warning">
|
||||
<label>Washer Fluid Level Warning</label>
|
||||
</channel>
|
||||
<channel id="brake-fluid" typeId="warning">
|
||||
<label>Brake Fluid Level Warning</label>
|
||||
</channel>
|
||||
<channel id="smart-key-battery" typeId="warning">
|
||||
<label>Smart Key Battery Warning</label>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<!-- Channel Types -->
|
||||
|
||||
<channel-type id="locked">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Locked</label>
|
||||
<description>Vehicle door lock status</description>
|
||||
<category>Lock</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>LockState</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="engine-running">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Engine Running</label>
|
||||
<description>Engine running status</description>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="battery-level">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>12V Battery Level</label>
|
||||
<description>12V battery state of charge</description>
|
||||
<category>BatteryLevel</category>
|
||||
<tags>
|
||||
<tag>Measurement</tag>
|
||||
<tag>Energy</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="door-state">
|
||||
<item-type>Contact</item-type>
|
||||
<label>Door</label>
|
||||
<description>Door open/closed state</description>
|
||||
<category>Door</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>OpenState</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="trunk-state">
|
||||
<item-type>Contact</item-type>
|
||||
<label>Trunk</label>
|
||||
<description>Trunk open/closed state</description>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>OpenState</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="hood-state">
|
||||
<item-type>Contact</item-type>
|
||||
<label>Hood</label>
|
||||
<description>Hood open/closed state</description>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>OpenState</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="window-state">
|
||||
<item-type>Contact</item-type>
|
||||
<label>Window</label>
|
||||
<description>Window open/closed state</description>
|
||||
<category>Window</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>OpenState</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="hvac-on">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Climate Control</label>
|
||||
<description>Climate control active state</description>
|
||||
<category>Climate</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Heating</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="temperature-setpoint">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Temperature Setpoint</label>
|
||||
<description>Air temperature setpoint</description>
|
||||
<category>Temperature</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Temperature</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="defrost">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Defrost</label>
|
||||
<description>Defrost on/off status</description>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Enabled</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="seat-heater">
|
||||
<item-type>Number</item-type>
|
||||
<label>Seat Heater</label>
|
||||
<description>Seat heater level (0-3)</description>
|
||||
<category>Heating</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Heating</tag>
|
||||
</tags>
|
||||
<state min="0" max="3" step="1" pattern="%.0f" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="heater-state">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Heater</label>
|
||||
<description>Heater on/off status</description>
|
||||
<category>Heating</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Heating</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="last-update">
|
||||
<item-type>DateTime</item-type>
|
||||
<label>Last Update</label>
|
||||
<description>Time of last vehicle data update</description>
|
||||
<category>Time</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Timestamp</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="range">
|
||||
<item-type>Number:Length</item-type>
|
||||
<label>Range</label>
|
||||
<description>Estimated driving range</description>
|
||||
<category>Car</category>
|
||||
<tags>
|
||||
<tag>Forecast</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="fuel-level">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>Fuel Level</label>
|
||||
<description>Fuel tank level</description>
|
||||
<category>Oil</category>
|
||||
<tags>
|
||||
<tag>Measurement</tag>
|
||||
<tag>Level</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ev-soc">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>EV Battery</label>
|
||||
<description>EV battery state of charge</description>
|
||||
<category>Battery</category>
|
||||
<tags>
|
||||
<tag>Measurement</tag>
|
||||
<tag>Level</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ev-charging">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Charging State</label>
|
||||
<description>EV charging status</description>
|
||||
<category>Battery</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Info</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ev-plugged-in">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Plugged In</label>
|
||||
<description>Whether the charge cable is plugged</description>
|
||||
<category>Battery</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="charge-limit-dc">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>DC Charge Limit</label>
|
||||
<description>Target charge limit on DC</description>
|
||||
<category>Battery</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Energy</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %%" readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="charge-limit-ac">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>AC Charge Limit</label>
|
||||
<description>Target charge limit on AC</description>
|
||||
<category>Battery</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Energy</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="charge-time-current">
|
||||
<item-type>Number:Time</item-type>
|
||||
<label>Time to Full (Current)</label>
|
||||
<description>Estimated time to fully charged with current charger</description>
|
||||
<category>Time</category>
|
||||
<tags>
|
||||
<tag>Forecast</tag>
|
||||
<tag>Duration</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="charge-time-fast">
|
||||
<item-type>Number:Time</item-type>
|
||||
<label>Time to Full (Fast)</label>
|
||||
<description>Estimated time to fully charged with Level 3 charger</description>
|
||||
<category>Time</category>
|
||||
<tags>
|
||||
<tag>Forecast</tag>
|
||||
<tag>Duration</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="charge-time-portable">
|
||||
<item-type>Number:Time</item-type>
|
||||
<label>Time to Full (Portable)</label>
|
||||
<description>Estimated time to fully charged with Level 1 charger</description>
|
||||
<category>Time</category>
|
||||
<tags>
|
||||
<tag>Forecast</tag>
|
||||
<tag>Duration</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="charge-time-station">
|
||||
<item-type>Number:Time</item-type>
|
||||
<label>Time to Full (Station)</label>
|
||||
<description>Estimated time to fully charged with Level 2 charger</description>
|
||||
<category>Time</category>
|
||||
<tags>
|
||||
<tag>Forecast</tag>
|
||||
<tag>Duration</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="odometer">
|
||||
<item-type>Number:Length</item-type>
|
||||
<label>Odometer</label>
|
||||
<description>Vehicle odometer reading</description>
|
||||
<category>Car</category>
|
||||
<tags>
|
||||
<tag>Measurement</tag>
|
||||
</tags>
|
||||
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="warning">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Warning</label>
|
||||
<description>Warning indicator</description>
|
||||
<category>Alarm</category>
|
||||
<tags>
|
||||
<tag>Switch</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="fuel-warning">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Fuel Level Warning</label>
|
||||
<description>Low fuel level indicator</description>
|
||||
<category>Alarm</category>
|
||||
<tags>
|
||||
<tag>Switch</tag>
|
||||
</tags>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class MockApiData {
|
||||
public static final String TOKEN_RESPONSE = """
|
||||
{
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": "3600",
|
||||
"username": "test@example.com"
|
||||
}
|
||||
""";
|
||||
|
||||
public static final String ENROLLMENT_RESPONSE;
|
||||
public static final String VEHICLE_STATUS_RESPONSE;
|
||||
public static final String TEST_USERNAME = "test@example.com";
|
||||
public static final String TEST_PASSWORD = "testpassword";
|
||||
|
||||
static {
|
||||
try {
|
||||
ENROLLMENT_RESPONSE = loadResource("/enrollment-details.json");
|
||||
VEHICLE_STATUS_RESPONSE = loadResource("/vehicle-status.json");
|
||||
} catch (final IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String loadResource(final String path) throws IOException {
|
||||
try (final var in = MockApiData.class.getResourceAsStream(path)) {
|
||||
if (in != null) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} else {
|
||||
throw new IOException("cannot load resource: %s".formatted(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.api;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.openhab.binding.bluelink.internal.MockApiData.*;
|
||||
|
||||
import java.time.ZoneId;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleStatus;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
||||
|
||||
/**
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BluelinkApiTest {
|
||||
|
||||
private static final WireMockServer WIREMOCK_SERVER = new WireMockServer(
|
||||
WireMockConfiguration.options().dynamicPort());
|
||||
private static final HttpClient HTTP_CLIENT = new HttpClient();
|
||||
|
||||
private final TimeZoneProvider timeZoneProvider = () -> ZoneId.of("America/New_York");
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() throws Exception {
|
||||
WIREMOCK_SERVER.start();
|
||||
WireMock.configureFor("localhost", WIREMOCK_SERVER.port());
|
||||
stubFor(post(urlEqualTo("/v2/ac/oauth/token")).willReturn(
|
||||
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(TOKEN_RESPONSE)));
|
||||
stubFor(get(urlEqualTo("/ac/v2/rcs/rvs/vehicleStatus")).willReturn(aResponse().withStatus(200)
|
||||
.withHeader("Content-Type", "application/json").withBody(VEHICLE_STATUS_RESPONSE)));
|
||||
|
||||
HTTP_CLIENT.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() throws Exception {
|
||||
WIREMOCK_SERVER.stop();
|
||||
HTTP_CLIENT.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetVehicleStatus() throws Exception {
|
||||
final String baseUrl = "http://localhost:" + WIREMOCK_SERVER.port();
|
||||
final BluelinkApi api = new BluelinkApi(HTTP_CLIENT, baseUrl, timeZoneProvider, TEST_USERNAME, TEST_PASSWORD,
|
||||
null);
|
||||
assertTrue(api.login());
|
||||
|
||||
final VehicleInfo vehicle = new VehicleInfo("123", "", "VIN1234", "", "", "2", 0);
|
||||
final VehicleStatus status = api.getVehicleStatus(vehicle, false);
|
||||
|
||||
assertNotNull(status);
|
||||
assertNotNull(status.vehicleStatus());
|
||||
assertTrue(status.vehicleStatus().doorLock());
|
||||
assertFalse(status.vehicleStatus().engine());
|
||||
assertEquals(75, status.vehicleStatus().battery().stateOfCharge());
|
||||
|
||||
final var evStatus = status.vehicleStatus().evStatus();
|
||||
assertNotNull(evStatus);
|
||||
assertEquals(42, evStatus.batteryStatus());
|
||||
assertTrue(evStatus.batteryCharge());
|
||||
assertEquals(2, evStatus.batteryPlugin());
|
||||
|
||||
final var drvDistance = evStatus.drvDistance();
|
||||
assertNotNull(drvDistance);
|
||||
assertFalse(drvDistance.isEmpty());
|
||||
final var rangeByFuel = drvDistance.getFirst().rangeByFuel();
|
||||
assertNotNull(rangeByFuel);
|
||||
assertEquals(184.0, rangeByFuel.evModeRange().value());
|
||||
assertEquals(184.0, rangeByFuel.totalAvailableRange().value());
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.bluelink.internal.handler;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.openhab.binding.bluelink.internal.MockApiData.ENROLLMENT_RESPONSE;
|
||||
import static org.openhab.binding.bluelink.internal.MockApiData.TOKEN_RESPONSE;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.openhab.binding.bluelink.internal.MockApiData;
|
||||
import org.openhab.binding.bluelink.internal.api.BluelinkApiException;
|
||||
import org.openhab.binding.bluelink.internal.dto.VehicleInfo;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.i18n.LocaleProvider;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.test.java.JavaTest;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
||||
|
||||
/**
|
||||
* Integration tests for the Bluelink binding using WireMock to mock the API.
|
||||
*
|
||||
* @author Marcus Better - Initial contribution
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@NonNullByDefault
|
||||
class BluelinkAccountHandlerTest extends JavaTest {
|
||||
|
||||
private static final WireMockServer WIREMOCK_SERVER = new WireMockServer(
|
||||
WireMockConfiguration.options().dynamicPort());
|
||||
private static final HttpClient HTTP_CLIENT = new HttpClient();
|
||||
|
||||
private final TimeZoneProvider timeZoneProvider = () -> ZoneId.of("America/New_York");
|
||||
private final LocaleProvider localeProvider = () -> Locale.US;
|
||||
|
||||
private @Mock @NonNullByDefault({}) Bridge bridge;
|
||||
private @Mock @NonNullByDefault({}) ThingHandlerCallback callback;
|
||||
|
||||
private @NonNullByDefault({}) BluelinkAccountHandler handler;
|
||||
|
||||
@BeforeAll
|
||||
static void setUpHttpServer() throws Exception {
|
||||
WIREMOCK_SERVER.start();
|
||||
WireMock.configureFor("localhost", WIREMOCK_SERVER.port());
|
||||
stubFor(post(urlEqualTo("/v2/ac/oauth/token")).willReturn(
|
||||
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(TOKEN_RESPONSE)));
|
||||
stubFor(get(urlPathMatching("/ac/v2/enrollment/details/.*")).willReturn(aResponse().withStatus(200)
|
||||
.withHeader("Content-Type", "application/json").withBody(ENROLLMENT_RESPONSE)));
|
||||
|
||||
HTTP_CLIENT.start();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
final Configuration config = new Configuration(Map.of("username", MockApiData.TEST_USERNAME, "password",
|
||||
MockApiData.TEST_PASSWORD, "apiBaseUrl", "http://localhost:" + WIREMOCK_SERVER.port()));
|
||||
when(bridge.getConfiguration()).thenReturn(config);
|
||||
when(bridge.getUID()).thenReturn(new ThingUID("bluelink:account:testaccount"));
|
||||
|
||||
handler = new BluelinkAccountHandler(bridge, HTTP_CLIENT, timeZoneProvider, localeProvider);
|
||||
handler.setCallback(callback);
|
||||
handler.initialize();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
handler.dispose();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownHttpServer() throws Exception {
|
||||
WIREMOCK_SERVER.stop();
|
||||
HTTP_CLIENT.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoginAndGetVehicles() throws BluelinkApiException {
|
||||
waitForAssert(() -> {
|
||||
try {
|
||||
assertThat(handler.getVehicles(), is(not(empty())));
|
||||
} catch (final BluelinkApiException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
});
|
||||
final VehicleInfo vehicle = handler.getVehicles().getFirst();
|
||||
assertNotNull(vehicle);
|
||||
assertEquals("IONIQ 6", vehicle.modelCode());
|
||||
assertEquals("E", vehicle.evStatus());
|
||||
assertEquals("2", vehicle.vehicleGeneration());
|
||||
assertTrue(vehicle.isElectric());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"addressDetails": [
|
||||
{
|
||||
"city": "Boston",
|
||||
"postalCode": "02180",
|
||||
"region": "MA",
|
||||
"type": "PRIMARY"
|
||||
}
|
||||
],
|
||||
"enrolledVehicleDetails": [
|
||||
{
|
||||
"packageDetails": [
|
||||
{
|
||||
"assetNumber": "A-S12345678",
|
||||
"displayCategory": "Basic||YES",
|
||||
"packageId": "238546309a3d04a201511ccf2d2872dd",
|
||||
"packageType": "Basic",
|
||||
"renewalDate": "20450128000000",
|
||||
"startDate": "20230128000000",
|
||||
"subscriptionType": "Included",
|
||||
"term": "240"
|
||||
},
|
||||
{
|
||||
"assetNumber": "A-S03802321",
|
||||
"displayCategory": "Advanced||YES",
|
||||
"packageId": "ea086fe1dd38b11eee529d74b24f726d",
|
||||
"packageType": "Advanced",
|
||||
"renewalDate": "20450128000000",
|
||||
"startDate": "20230128000000",
|
||||
"subscriptionType": "Included",
|
||||
"term": "240"
|
||||
},
|
||||
{
|
||||
"assetNumber": "A-S03891823",
|
||||
"displayCategory": "Map & Multimedia Over-the-Air Updates||NO",
|
||||
"packageId": "f8dfdf5ab27eb8c9afce4dbdb1c9f060",
|
||||
"packageType": "Map & Multimedia Over-the-Air Updates",
|
||||
"renewalDate": "20280128000000",
|
||||
"startDate": "20230128000000",
|
||||
"subscriptionType": "Trial",
|
||||
"term": "36"
|
||||
}
|
||||
],
|
||||
"responseHeaderMap": {},
|
||||
"roleDetails": [
|
||||
{
|
||||
"roleCode": "SD",
|
||||
"roleName": "Secondary Driver"
|
||||
}
|
||||
],
|
||||
"vehicleDetails": {
|
||||
"accessoryCode": "WAVN 5.0",
|
||||
"additionalVehicleDetails": {
|
||||
"alexaIntegrationCapable": "N",
|
||||
"battDrainFunctOnOff": "N",
|
||||
"batteryPreconditioningOpton": "1",
|
||||
"calendarVehicleSyncEnable": "No",
|
||||
"chargePortDoorOption": "N",
|
||||
"combinedHeatSettingsEnable": "Y",
|
||||
"cpoVehicle": "No",
|
||||
"dkCapable": "Y",
|
||||
"dkEnrolled": "Y",
|
||||
"dkType": "2.0-Standard",
|
||||
"dynamicSOCText": "Use the slider above to set a charge limit. Charging will stop when this battery level is reached. The limit cannot be set lower than 50% This setting will override all other charge settings if set.",
|
||||
"enableEVTrip": "Y",
|
||||
"enableHCAModule": "Y",
|
||||
"enableNTCModule": "Y",
|
||||
"enableRoadSideAssitanceAAAModule": "Y",
|
||||
"enableValetActivate": "Y",
|
||||
"energyConsoleCapable": "No",
|
||||
"evAlarmOptionInfo": "Yes",
|
||||
"fodEligibilityInd": 0,
|
||||
"fodProductId": "",
|
||||
"frunkOption": "N",
|
||||
"guidancePackage": "N",
|
||||
"hyundaiHome": "N",
|
||||
"icpAACapable": "Y",
|
||||
"icpAvntCapable": "Y",
|
||||
"icpCPCapable": "Y",
|
||||
"icpChargingStation": 1,
|
||||
"icpDriveThru": 0,
|
||||
"icpFuelStation": 0,
|
||||
"icpParking": 1,
|
||||
"idleSpeedinValetAlert": "Y",
|
||||
"mapOtaAccepted": "N",
|
||||
"mapOtaPackage": "Y",
|
||||
"maxTemp": 82,
|
||||
"minTemp": 62,
|
||||
"mqttCapable": "N",
|
||||
"msCapableOption": "N",
|
||||
"myqGarageDoorServiceCapable": 0,
|
||||
"nacsEligibleInd": "No",
|
||||
"newActivationSupport": "N",
|
||||
"productName": "Bluelink+",
|
||||
"remoteLockConsentForRemoteStart": "Yes",
|
||||
"remoteLockConsentForRemoteStartCapable": "Yes",
|
||||
"targetSOCLevelMax": 100,
|
||||
"temperatureRange": "true",
|
||||
"tmuSleepInMin": 240.41666666666666,
|
||||
"tmuSleepMode": "No",
|
||||
"userProfileAvailable": "N",
|
||||
"v2GOption": "N",
|
||||
"v2LOption": "N",
|
||||
"v2LRemainTimeOption": "Y",
|
||||
"vehicleModemType": "4G (CEEV.USA.S5W_M.V013.010.241127)",
|
||||
"vssxSupport": "0",
|
||||
"wifiHotspotCapable": "Y",
|
||||
"windowSafetyOption2": "3"
|
||||
},
|
||||
"billingInd": "MONTHLY",
|
||||
"blueLink": "Y",
|
||||
"bluelinkEnabled": true,
|
||||
"bluelinkEnrolled": true,
|
||||
"brandIndicator": "H",
|
||||
"color": "BLUE",
|
||||
"defaultBurgerMenu": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/sel/exterior/base/default/Dashboard-01.png/jcr:content/renditions/cq5dam.thumbnail.105.68.png",
|
||||
"defaultDashboard": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/sel/exterior/base/default/Dashboard-01.png",
|
||||
"deviceStatus": "ENROLLED",
|
||||
"dynamicBurgerMenu": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/sel/exterior/base/transmission-blue/Dashboard-01.png/jcr:content/renditions/cq5dam.thumbnail.105.68.png",
|
||||
"dynamicDashboard": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/sel/exterior/base/transmission-blue/Dashboard-01.png",
|
||||
"enrollmentDate": "20230128",
|
||||
"enrollmentId": "9812375",
|
||||
"enrollmentStatus": "ACTIVE",
|
||||
"enrollmentType": "INDIVIDUAL",
|
||||
"evStatus": "E",
|
||||
"fatcAvailable": "Y",
|
||||
"generalBurgerMenu": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/general/exterior/base/default/Dashboard-01.png/jcr:content/renditions/cq5dam.thumbnail.105.68.png",
|
||||
"generalDashboard": "https://owners.hyundaiusa.com/content/dam/hyundai/us/myhyundai/image/2025/ioniq-6/general/exterior/base/default/Dashboard-01.png",
|
||||
"hmaModel": "CE1",
|
||||
"imat": "7500",
|
||||
"interiorColor": "NNB",
|
||||
"maintSyncCapable": "YES",
|
||||
"mapProvider": "HERE",
|
||||
"mit": "500",
|
||||
"modelCode": "IONIQ 6",
|
||||
"modelYear": "2025",
|
||||
"nadid": "908123745",
|
||||
"nickName": "2025 IONIQ 6",
|
||||
"odometer": "6942",
|
||||
"odometerUpdateDate": "20251217130716",
|
||||
"ownersuccession": "1",
|
||||
"preferredDealerCode": "ZZ012",
|
||||
"rearWindowHeatCapable": "YES",
|
||||
"regid": "H********",
|
||||
"remoteStartWakeupDays": "seven",
|
||||
"sapColorCode": "NY9",
|
||||
"series": "IONIQ 6",
|
||||
"setOffPeak": "1",
|
||||
"sideMirrorHeatCapable": "YES",
|
||||
"starttype": "KEY",
|
||||
"steeringWheelHeatCapable": "YES",
|
||||
"svrStatus": "NONE",
|
||||
"targetSOCLevel": "50",
|
||||
"transmissiontype": "AUTO",
|
||||
"trim": "SEL",
|
||||
"ubiCapabilityInd": "N",
|
||||
"userprofilestatus": "Y",
|
||||
"vehicleGeneration": "2",
|
||||
"vin": "VIN1234",
|
||||
"waypointInd": "NO"
|
||||
}
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"accountId": "9182345",
|
||||
"additionalUserDetails": {
|
||||
"appRating": "N",
|
||||
"billingAccountNumber": "0",
|
||||
"emailVerifyStatus": "Y",
|
||||
"enableGenesisRecall": "N",
|
||||
"enableHyundaiRecall": "N",
|
||||
"geoLocationConsent": "Y",
|
||||
"otaAcceptance": "N",
|
||||
"telematicsPhoneNumber": "5555551234",
|
||||
"timezoneAbbr": "EST",
|
||||
"timezoneOffset": -5,
|
||||
"tncFlagBlueLink": "N",
|
||||
"tncFlagGCS": "N",
|
||||
"userProfileUpdate": "N"
|
||||
},
|
||||
"collisionAssistanceFlag": "Y",
|
||||
"email": "***",
|
||||
"firstName": "James",
|
||||
"idmId": "A8123D8F",
|
||||
"lastName": "Bond",
|
||||
"loginId": "***",
|
||||
"mobileDetectionFlag": "Y",
|
||||
"notificationEmail": "***",
|
||||
"phones": [
|
||||
{
|
||||
"number": "5555551234",
|
||||
"order": 1,
|
||||
"type": "cell"
|
||||
}
|
||||
],
|
||||
"phonesOptIn": [
|
||||
{
|
||||
"fccOptIn": "YES",
|
||||
"number": "5555551234",
|
||||
"primaryPhoneIndicator": "YES",
|
||||
"type": "MOBILE"
|
||||
}
|
||||
],
|
||||
"tncFlag": "N",
|
||||
"towServiceFlag": "N",
|
||||
"userId": "***"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"hataTID": "Iebung0Aquai8Coo",
|
||||
"vehicleStatus": {
|
||||
"acc": false,
|
||||
"airConditionStatus": "false",
|
||||
"airCtrlOn": false,
|
||||
"airTemp": {
|
||||
"hvacTempType": 1,
|
||||
"unit": 0,
|
||||
"value": "LO"
|
||||
},
|
||||
"battery": {
|
||||
"batSignalReferenceValue": {
|
||||
"batWarning": 65
|
||||
},
|
||||
"batSoc": 75,
|
||||
"powerAutoCutMode": 2,
|
||||
"sjbDeliveryMode": 1
|
||||
},
|
||||
"breakOilStatus": false,
|
||||
"dateTime": "2025-12-18T04:09:28Z",
|
||||
"defrost": false,
|
||||
"defrostStatus": "false",
|
||||
"doorLock": true,
|
||||
"doorLockStatus": "true",
|
||||
"doorOpen": {
|
||||
"backLeft": 0,
|
||||
"backRight": 0,
|
||||
"frontLeft": 0,
|
||||
"frontRight": 0
|
||||
},
|
||||
"engine": false,
|
||||
"evStatus": {
|
||||
"batteryCharge": true,
|
||||
"batteryDisChargePlugin": 1,
|
||||
"batteryDischrgPower": 7.4,
|
||||
"batteryFstChrgPower": 0.0,
|
||||
"batteryPlugin": 2,
|
||||
"batteryPrecondition": false,
|
||||
"batteryStatus": 42,
|
||||
"batteryStndChrgPower": 7.4,
|
||||
"disChargeRemaintime": 0,
|
||||
"drvDistance": [
|
||||
{
|
||||
"rangeByFuel": {
|
||||
"evModeRange": {
|
||||
"unit": 1,
|
||||
"value": 184.0
|
||||
},
|
||||
"gasModeRange": {
|
||||
"unit": 1,
|
||||
"value": 0.0
|
||||
},
|
||||
"totalAvailableRange": {
|
||||
"unit": 1,
|
||||
"value": 184.0
|
||||
}
|
||||
},
|
||||
"type": 2
|
||||
}
|
||||
],
|
||||
"evIgnitionStatus": true,
|
||||
"inletLockModeStatus": 0,
|
||||
"remainTime": [],
|
||||
"remainTime2": {
|
||||
"atc": {
|
||||
"unit": 1,
|
||||
"value": 265
|
||||
},
|
||||
"etc1": {
|
||||
"unit": 1,
|
||||
"value": 41
|
||||
},
|
||||
"etc2": {
|
||||
"unit": 1,
|
||||
"value": 1680
|
||||
},
|
||||
"etc3": {
|
||||
"unit": 1,
|
||||
"value": 175
|
||||
}
|
||||
},
|
||||
"reservChargeInfos": {
|
||||
"offpeakPowerInfo": {
|
||||
"offPeakPowerFlag": 0,
|
||||
"offPeakPowerTime1": {
|
||||
"endTime": {
|
||||
"time": "1200",
|
||||
"timeSection": 0
|
||||
},
|
||||
"startTime": {
|
||||
"time": "1200",
|
||||
"timeSection": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"reservChargeInfo": {
|
||||
"dateTime": "2025/12/17T20:10:16Z",
|
||||
"reservChargeInfoDetail": {
|
||||
"reservChargeSet": false,
|
||||
"reservEndTime": "",
|
||||
"reservFatcSet": {
|
||||
"airCtrl": 0,
|
||||
"airTemp": {
|
||||
"unit": 0,
|
||||
"value": "0BH"
|
||||
},
|
||||
"defrost": false
|
||||
},
|
||||
"reservInfo": {
|
||||
"day": [
|
||||
3
|
||||
],
|
||||
"time": {
|
||||
"time": "0710",
|
||||
"timeSection": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reservFlag": 0,
|
||||
"reserveChargeInfo2": {
|
||||
"reservChargeInfoDetail": {
|
||||
"reservChargeSet": false,
|
||||
"reservEndTime": "",
|
||||
"reservFatcSet": {
|
||||
"airCtrl": 0,
|
||||
"airTemp": {
|
||||
"unit": 0,
|
||||
"value": "0BH"
|
||||
},
|
||||
"defrost": false
|
||||
},
|
||||
"reservInfo": {
|
||||
"day": [
|
||||
9
|
||||
],
|
||||
"time": {
|
||||
"time": "1200",
|
||||
"timeSection": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"targetSOClist": [
|
||||
{
|
||||
"plugType": 0,
|
||||
"rangeByFuel": {
|
||||
"evModeRange": {
|
||||
"unit": 1,
|
||||
"value": 184
|
||||
},
|
||||
"gasModeRange": {
|
||||
"unit": 1,
|
||||
"value": 0
|
||||
},
|
||||
"totalAvailableRange": {
|
||||
"unit": 1,
|
||||
"value": 184
|
||||
}
|
||||
},
|
||||
"targetSOClevel": 80,
|
||||
"type": 2
|
||||
},
|
||||
{
|
||||
"plugType": 1,
|
||||
"rangeByFuel": {
|
||||
"evModeRange": {
|
||||
"unit": 1,
|
||||
"value": 184
|
||||
},
|
||||
"gasModeRange": {
|
||||
"unit": 1,
|
||||
"value": 0
|
||||
},
|
||||
"totalAvailableRange": {
|
||||
"unit": 1,
|
||||
"value": 184
|
||||
}
|
||||
},
|
||||
"targetSOClevel": 80,
|
||||
"type": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeDiff": 0,
|
||||
"v2L": false,
|
||||
"valueDiff": 0
|
||||
},
|
||||
"hazardStatus": 0,
|
||||
"hoodOpen": false,
|
||||
"ign3": true,
|
||||
"ignitionStatus": "false",
|
||||
"lampWireStatus": {
|
||||
"headLamp": {
|
||||
"headLampStatus": false,
|
||||
"leftBifuncLamp": false,
|
||||
"leftHighLamp": false,
|
||||
"leftLowLamp": false,
|
||||
"rightBifuncLamp": false,
|
||||
"rightHighLamp": false,
|
||||
"rightLowLamp": false
|
||||
},
|
||||
"stopLamp": {
|
||||
"leftLamp": false,
|
||||
"rightLamp": false
|
||||
},
|
||||
"turnSignalLamp": {
|
||||
"leftFrontLamp": false,
|
||||
"leftRearLamp": false,
|
||||
"rightFrontLamp": false,
|
||||
"rightRearLamp": false
|
||||
}
|
||||
},
|
||||
"lowFuelLight": false,
|
||||
"remoteWaitingTimeAlert": {
|
||||
"remoteControlAvailable": 1,
|
||||
"remoteControlWaitingTime": 168
|
||||
},
|
||||
"sideBackWindowHeat": 0,
|
||||
"sleepModeCheck": false,
|
||||
"smartKeyBatteryWarning": false,
|
||||
"steerWheelHeat": 0,
|
||||
"tailLampStatus": 0,
|
||||
"tirePressureLamp": {
|
||||
"tirePressureWarningLampAll": 0,
|
||||
"tirePressureWarningLampFrontLeft": 0,
|
||||
"tirePressureWarningLampFrontRight": 0,
|
||||
"tirePressureWarningLampRearLeft": 0,
|
||||
"tirePressureWarningLampRearRight": 0
|
||||
},
|
||||
"transCond": true,
|
||||
"trunkOpen": false,
|
||||
"trunkOpenStatus": "false",
|
||||
"vehicleLocation": {
|
||||
"coord": {
|
||||
"alt": 55.0,
|
||||
"lat": 43.23436388,
|
||||
"lon": -99.15509166666666,
|
||||
"type": 0
|
||||
}
|
||||
},
|
||||
"washerFluidStatus": false,
|
||||
"windowOpen": {
|
||||
"backLeft": 0,
|
||||
"backRight": 0,
|
||||
"frontLeft": 0,
|
||||
"frontRight": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@
|
||||
<module>org.openhab.binding.bambulab</module>
|
||||
<module>org.openhab.binding.benqprojector</module>
|
||||
<module>org.openhab.binding.bigassfan</module>
|
||||
<module>org.openhab.binding.bluelink</module>
|
||||
<module>org.openhab.binding.bluetooth</module>
|
||||
<module>org.openhab.binding.bluetooth.airthings</module>
|
||||
<module>org.openhab.binding.bluetooth.am43</module>
|
||||
|
||||
Reference in New Issue
Block a user