[bluelink] Initial contribution (#19823)

* Hyundai/Genesis Bluelink binding

Signed-off-by: Marcus Better <marcus@better.se>
This commit is contained in:
Marcus Better
2025-12-31 23:17:01 +01:00
committed by GitHub
parent 3ab5c04770
commit de599807ba
40 changed files with 4082 additions and 0 deletions
+1
View File
@@ -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
+5
View File
@@ -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>
@@ -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";
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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());
}
}
@@ -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;
}
}
}
@@ -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) {
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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) {
}
@@ -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) {
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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;
}
}
@@ -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) {
}
}
@@ -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) {
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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>
@@ -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>
@@ -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));
}
}
}
}
@@ -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());
}
}
@@ -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
}
}
}
+1
View File
@@ -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>