]> git.basschouten.com Git - openhab-addons.git/commitdiff
[saicismart] Initial contribution (#15894)
authorDoug Culnane <32482395+dougculnane@users.noreply.github.com>
Sat, 27 Apr 2024 20:49:34 +0000 (22:49 +0200)
committerGitHub <noreply@github.com>
Sat, 27 Apr 2024 20:49:34 +0000 (22:49 +0200)
* [saicismart] initial binding creation

Signed-off-by: Markus Heberling <markus@heberling.net>
Signed-off-by: dougculnane <doug@culnane.net>
23 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.saicismart/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/README.md [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/ChargeStateUpdater.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTVehicleConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleDiscovery.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleStateUpdater.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/ChargingStatusAPIException.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/VehicleStatusAPIException.java [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/bridge-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/thing-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/bridge-ismart.xml [new file with mode: 0644]
bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/pom.xml

index ba7010f42f0d8be127c504632a344b4d720d4643..9a61eff6f43a7331ff8018818451c27c81ccf880 100644 (file)
 /bundles/org.openhab.binding.rotel/ @lolodomo
 /bundles/org.openhab.binding.russound/ @openhab/add-ons-maintainers
 /bundles/org.openhab.binding.sagercaster/ @clinique
+/bundles/org.openhab.binding.saicismart/ @tisoft @dougculnane
 /bundles/org.openhab.binding.samsungtv/ @paulianttila
 /bundles/org.openhab.binding.satel/ @druciak
 /bundles/org.openhab.binding.semsportal/ @itb3
index 3c2c2b5e7a8aefcddc9194f013e9b481d02aabd9..96a0264b851e922205928f6fc4dfd8423903a770 100644 (file)
       <artifactId>org.openhab.binding.sagercaster</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.saicismart</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.samsungtv</artifactId>
diff --git a/bundles/org.openhab.binding.saicismart/NOTICE b/bundles/org.openhab.binding.saicismart/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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
diff --git a/bundles/org.openhab.binding.saicismart/README.md b/bundles/org.openhab.binding.saicismart/README.md
new file mode 100644 (file)
index 0000000..a86c0d4
--- /dev/null
@@ -0,0 +1,131 @@
+# SAICiSMART Binding
+
+OpenHAB binding to the SAIC-API used by MG cars (MG4, MG5 EV, MG ZSV...)
+
+It enables iSMART users to get battery status and other data from their cars. 
+They can also pre-heat their cars by turning ON the AC.
+
+Based on the work done here: https://github.com/SAIC-iSmart-API
+
+## Supported Things
+
+European iSMART accounts and vehicles.
+
+- `account`: Bridge representing an iSMART Account
+- `vehicle`: Thing representing an iSMART MG Car
+
+
+## Discovery
+
+Vehicle discovery is implemented. 
+Once an account has been configured it can be scanned for vehicles.
+
+## Thing Configuration
+
+### `account` iSMART Account Configuration
+
+| Name     | Type    | Description                 | Default | Required | Advanced |
+|----------|---------|-----------------------------|---------|----------|----------|
+| username | text    | iSMART username             | N/A     | yes      | no       |
+| password | text    | iSMART password             | N/A     | yes      | no       |
+
+### `vehicle` An iSMART MG Car
+
+| Name          | Type | Description                          | Default | Required | Advanced |
+|---------------|------|--------------------------------------|---------|----------|----------|
+| vin           | text | Vehicle identification number (VIN)  | N/A     | yes      | no       |
+| abrpUserToken | text | User token for A Better Routeplanner | N/A     | no       | no       |
+
+
+## Channels
+
+| Channel                    | Type                     | Read/Write | Description                                         | Advanced |
+|----------------------------|--------------------------|------------|-----------------------------------------------------|----------|
+| odometer                   | Number:Length            | R          | Total distance driven                               | no       |
+| range-electric             | Number:Length            | R          | Electric range                                      | no       |
+| soc                        | Number                   | R          | State of the battery in %                           | no       |
+| power                      | Number:Power             | R          | Power usage                                         | no       |
+| charging                   | Switch                   | R          | Charging                                            | no       |
+| engine                     | Switch                   | R          | Engine state                                        | no       |
+| speed                      | Number:Speed             | R          | Vehicle speed                                       | no       |
+| location                   | Location                 | R          | The actual position of the vehicle                  | no       |
+| heading                    | Number:Angle             | R          | The compass heading of the car, (0-360 degrees)     | no       |
+| auxiliary-battery-voltage  | Number:ElectricPotential | R          | Auxiliary battery voltage                           | no       |
+| tyre-pressure-front-left   | Number:Pressure          | R          | Pressure front left                                 | no       |
+| tyre-pressure-front-right  | Number:Pressure          | R          | Pressure front right                                | no       |
+| tyre-pressure-rear-left    | Number:Pressure          | R          | Pressure rear left                                  | no       |
+| tyre-pressure-rear-right   | Number:Pressure          | R          | Pressure rear right                                 | no       |
+| interior-temperature       | Number:Temperature       | R          | Interior temperature                                | no       |
+| exterior-temperature       | Number:Temperature       | R          | Exterior temperature                                | no       |
+| door-driver                | Contact                  | R          | Driver door open state                              | no       |
+| door-passenger             | Contact                  | R          | Passenger door open state                           | no       |
+| door-rear-left             | Contact                  | R          | Rear left door open state                           | no       |
+| door-rear-right            | Contact                  | R          | Rear right door open state                          | no       |
+| window-driver              | Contact                  | R          | Driver window open state                            | no       |
+| window-passenger           | Contact                  | R          | Passenger window open state                         | no       |
+| window-rear-left           | Contact                  | R          | Rear left window open state                         | no       |
+| window-rear-right          | Contact                  | R          | Rear right window open state                        | no       |
+| window-sun-roof            | Contact                  | R          | Sun roof open state                                 | no       |
+| last-activity              | DateTime                 | R          | Last time the engine was on or the car was charging | no       |
+| last-position-update       | DateTime                 | R          | Last time the Position data was updated             | no       |
+| last-charge-state-update   | DateTime                 | R          | Last time the Charge State data was updated         | no       |
+| remote-ac-status           | Number                   | R          | Status of the A/C                                   | no       |
+| switch-ac                  | Switch                   | R/W        | Control the A/C remotely                            | no       |
+| force-refresh              | Switch                   | R/W        | Force an immediate refresh of the car data          | yes      |
+| last-alarm-message-date    | DateTime                 | R          | Last time an alarm message was sent                 | no       |
+| last-alarm-message-content | String                   | R          | Vehicle message                                     | no       |
+
+# Example
+
+demo.things:
+
+```java
+Bridge saicismart:account:myaccount "My iSMART Account" [ username="MyEmail@domian.com", password="MyPassword" ] {
+  Thing vehicle mymg5 "MG5" [ vin="XXXXXXXXXXXXXXXXX", abrpUserToken="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ]
+}
+```
+
+demo.items:
+
+```java
+Number                 MG5_Total_Distance_Driven               "MG5 Total Distance Driven"     ["Length"]              {channel="saicismart:vehicle:myaccount:mymg5:odometer"}
+Number                 MG5_Electric_Range                      "MG5 Electric Range"            ["Length"]              {channel="saicismart:vehicle:myaccount:mymg5:range-electric"}
+Number         MG5_Battery_Level                       "MG5 Battery Level"             ["Energy"]              {channel="saicismart:vehicle:myaccount:mymg5:soc"}  
+Number                 MG5_Power_Usage                         "MG5 Power Usage"               ["Power"]               {channel="saicismart:vehicle:myaccount:mymg5:power"}
+Switch                 MG5_Charging                            "MG5 Charging"                                          {channel="saicismart:vehicle:myaccount:mymg5:charging"}
+Switch                 MG5_Engine_State                        "MG5 Engine State"                                      {channel="saicismart:vehicle:myaccount:mymg5:engine"}
+Number                 MG5_Speed                               "MG5 Speed"                     ["Speed"]               {channel="saicismart:vehicle:myaccount:mymg5:speed"}
+Location       MG5_Location                            "MG5 Location"                                          {channel="saicismart:vehicle:myaccount:mymg5:location"}
+Number         MG5_Heading                             "MG5 Heading"                   ["Angle"]               {channel="saicismart:vehicle:myaccount:mymg5:heading"}
+Number                 MG5_Auxiliary_Battery_Voltage           "MG5 Auxiliary Battery Voltage" ["ElectricPotential"]   {channel="saicismart:vehicle:myaccount:mymg5:auxiliary-battery-voltage"}
+Number                 MG5_Pressure_Front_Left                 "MG5 Pressure Front Left"       ["Pressure"]            {channel="saicismart:vehicle:myaccount:mymg5:tyre-pressure-front-left"}
+Number                 MG5_Pressure_Front_Right                "MG5 Pressure Front Right       ["Pressure"]            {channel="saicismart:vehicle:myaccount:mymg5:tyre-pressure-front-right"}
+Number         MG5_Pressure_Rear_Left                  "MG5 Pressure Rear Left"        ["Pressure"]            {channel="saicismart:vehicle:myaccount:mymg5:tyre-pressure-rear-left"}
+Number         MG5_Pressure_Rear_Right                 "MG5 Pressure Rear Right"       ["Pressure"]            {channel="saicismart:vehicle:myaccount:mymg5:tyre-pressure-rear-right"}
+Number         MG5_Interior_Temperature                "MG5 Interior Temperature"      ["Temperature"]         {channel="saicismart:vehicle:myaccount:mymg5:interior-temperature"}
+Number         MG5_Exterior_Temperature                "MG5 Exterior Temperature"      ["Temperature"]         {channel="saicismart:vehicle:myaccount:mymg5:exterior-temperature"}
+Contact        MG5_Driver_Door                         "MG5 Driver Door"                                       {channel="saicismart:vehicle:myaccount:mymg5:door-driver"}
+Contact                MG5_Passenger_Door                      "MG5 Passenger Door"                                    {channel="saicismart:vehicle:myaccount:mymg5:door-passenger"}
+Contact        MG5_Rear_Left_Door                      "MG5 Rear Left Door"                                    {channel="saicismart:vehicle:myaccount:mymg5:door-rear-left"}
+Contact                MG5_Rear_Right_Door                     "MG5 Rear Right Door"                                   {channel="saicismart:vehicle:myaccount:mymg5:door-rear-right"}
+Contact                MG5_Driver_Window                       "MG5 Driver Window"                                     {channel="saicismart:vehicle:myaccount:mymg5:window-driver"}
+Contact                MG5_Passenger_Window                    "MG5 Passenger Window"                                  {channel="saicismart:vehicle:myaccount:mymg5:window-passenger"}
+Contact                MG5_Rear_Left_Window                    "MG5 Rear Left Window"                                  {channel="saicismart:vehicle:myaccount:mymg5:window-rear-left"}
+Contact                MG5_Rear_Right_Window                   "MG5 Rear Right Window"                                 {channel="saicismart:vehicle:myaccount:mymg5:window-rear-right"}
+Contact                MG5_Sun_Roof                            "MG5 Sun Roof"                                          {channel="saicismart:vehicle:myaccount:mymg5:window-sun-roof"}
+DateTime       MG5_Last_Car_Activity                   "MG5 Last Car Activity"                                 {channel="saicismart:vehicle:myaccount:mymg5:last-activity"}
+DateTime       MG5_Last_Position_Timestamp             "MG5 Last Position Timestamp"                           {channel="saicismart:vehicle:myaccount:mymg5:last-position-update"}
+DateTime       MG5_Last_Charge_State_Timestamp         "MG5 Last Charge State Timestamp"                       {channel="saicismart:vehicle:myaccount:mymg5:last-charge-state-update"}
+Number         MG5_Remote_AC                           "MG5 Remote A/C"                                        {channel="saicismart:vehicle:myaccount:mymg5:remote-ac-status"}
+Switch         MG5_Switch_AC                           "MG5 Switch A/C"                                        {channel="saicismart:vehicle:myaccount:mymg5:switch-ac"}
+Switch         MG5_Force_Refresh                       "MG5 Force Refresh"                                     {channel="saicismart:vehicle:myaccount:mymg5:force-refresh"}
+DateTime       MG5_Last_Alarm_Message_Timestamp        "MG5 Last Alarm Message Timestamp"                      {channel="saicismart:vehicle:myaccount:mymg5:last-alarm-message-date"}
+String                 MG5_Vehicle_Message                     "MG5 Vehicle Message"                                   {channel="saicismart:vehicle:myaccount:mymg5:last-alarm-message-content"}
+```
+
+
+## Limitations
+
+The advanced channel "force refresh" if used regularly will drain the 12v car battery and you will be unable to start it!
+
+Only European iSMART accounts and vehicles are supported. API host configuration and testing for other markets is required.
diff --git a/bundles/org.openhab.binding.saicismart/pom.xml b/bundles/org.openhab.binding.saicismart/pom.xml
new file mode 100644 (file)
index 0000000..f77b268
--- /dev/null
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  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>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.saicismart</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: SAICiSMART Binding</name>
+  <properties>
+    <bnd.importpackage>org.brotli.dec;resolution:=optional,org.conscrypt;resolution:=optional</bnd.importpackage>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>io.github.saic-ismart-api</groupId>
+      <artifactId>saic-ismart-client</artifactId>
+      <version>0.3.0</version>
+    </dependency>
+    <dependency>
+      <groupId>io.github.saic-ismart-api</groupId>
+      <artifactId>saic-ismart-api</artifactId>
+      <version>0.3.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents.client5</groupId>
+      <artifactId>httpclient5</artifactId>
+      <version>5.2.1</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents.core5</groupId>
+      <artifactId>httpcore5</artifactId>
+      <version>5.2</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents.core5</groupId>
+      <artifactId>httpcore5-h2</artifactId>
+      <version>5.2</version>
+    </dependency>
+    <dependency>
+      <groupId>net.heberling.binarynotes</groupId>
+      <artifactId>binarynotes</artifactId>
+      <version>1.7.0</version>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/feature/feature.xml b/bundles/org.openhab.binding.saicismart/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..1371384
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.saicismart-${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-saicismart" description="SAICiSMART Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.saicismart/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/ChargeStateUpdater.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/ChargeStateUpdater.java
new file mode 100644 (file)
index 0000000..ebc3d9a
--- /dev/null
@@ -0,0 +1,113 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.API_ENDPOINT_V30;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_POWER;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_SOC;
+
+import java.net.URISyntaxException;
+import java.time.ZonedDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.bn.coders.IASN1PreparedElement;
+import org.eclipse.jdt.annotation.DefaultLocation;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.saicismart.internal.exceptions.ChargingStatusAPIException;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ThingStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.GsonBuilder;
+
+import net.heberling.ismart.asn1.v3_0.Message;
+import net.heberling.ismart.asn1.v3_0.MessageCoder;
+import net.heberling.ismart.asn1.v3_0.entity.OTA_ChrgMangDataResp;
+
+/**
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault({ DefaultLocation.PARAMETER, DefaultLocation.RETURN_TYPE, DefaultLocation.FIELD,
+        DefaultLocation.TYPE_BOUND })
+class ChargeStateUpdater implements Callable<OTA_ChrgMangDataResp> {
+    private final Logger logger = LoggerFactory.getLogger(ChargeStateUpdater.class);
+
+    private final SAICiSMARTHandler saiCiSMARTHandler;
+
+    public ChargeStateUpdater(SAICiSMARTHandler saiCiSMARTHandler) {
+        this.saiCiSMARTHandler = saiCiSMARTHandler;
+    }
+
+    public OTA_ChrgMangDataResp call() throws URISyntaxException, ExecutionException, InterruptedException,
+            TimeoutException, ChargingStatusAPIException {
+        MessageCoder<IASN1PreparedElement> chargingStatusRequestmessageCoder = new MessageCoder<>(
+                IASN1PreparedElement.class);
+        Message<IASN1PreparedElement> chargingStatusMessage = chargingStatusRequestmessageCoder.initializeMessage(
+                saiCiSMARTHandler.getBridgeHandler().getUid(), saiCiSMARTHandler.getBridgeHandler().getToken(),
+                saiCiSMARTHandler.config.vin, "516", 768, 5, null);
+
+        String chargingStatusRequestMessage = chargingStatusRequestmessageCoder.encodeRequest(chargingStatusMessage);
+
+        String chargingStatusResponse = saiCiSMARTHandler.getBridgeHandler().sendRequest(chargingStatusRequestMessage,
+                API_ENDPOINT_V30);
+
+        Message<OTA_ChrgMangDataResp> chargingStatusResponseMessage = new MessageCoder<>(OTA_ChrgMangDataResp.class)
+                .decodeResponse(chargingStatusResponse);
+
+        // we get an eventId back...
+        chargingStatusMessage.getBody().setEventID(chargingStatusResponseMessage.getBody().getEventID());
+        // ... use that to request the data again, until we have it
+        while (chargingStatusResponseMessage.getApplicationData() == null) {
+            if (chargingStatusResponseMessage.getBody().getResult() != 0
+                    || chargingStatusResponseMessage.getBody().isErrorMessagePresent()) {
+                if (chargingStatusResponseMessage.getBody().getResult() == 2) {
+                    saiCiSMARTHandler.getBridgeHandler().relogin();
+                }
+                throw new ChargingStatusAPIException(chargingStatusResponseMessage.getBody());
+            }
+
+            chargingStatusMessage.getBody().setUid(saiCiSMARTHandler.getBridgeHandler().getUid());
+            chargingStatusMessage.getBody().setToken(saiCiSMARTHandler.getBridgeHandler().getToken());
+
+            chargingStatusRequestMessage = chargingStatusRequestmessageCoder.encodeRequest(chargingStatusMessage);
+
+            chargingStatusResponse = saiCiSMARTHandler.getBridgeHandler().sendRequest(chargingStatusRequestMessage,
+                    API_ENDPOINT_V30);
+
+            chargingStatusResponseMessage = new MessageCoder<>(OTA_ChrgMangDataResp.class)
+                    .decodeResponse(chargingStatusResponse);
+        }
+        saiCiSMARTHandler.updateState(CHANNEL_SOC,
+                new DecimalType(chargingStatusResponseMessage.getApplicationData().getBmsPackSOCDsp() / 10.d));
+        logger.debug("Got message: {}",
+                new GsonBuilder().setPrettyPrinting().create().toJson(chargingStatusResponseMessage));
+
+        Double power = (chargingStatusResponseMessage.getApplicationData().getBmsPackCrnt() * 0.05d - 1000.0d)
+                * ((double) chargingStatusResponseMessage.getApplicationData().getBmsPackVol() * 0.25d);
+
+        saiCiSMARTHandler.updateState(CHANNEL_POWER, new QuantityType<>(power.intValue(), Units.WATT));
+
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_LAST_CHARGE_STATE_UPDATE,
+                new DateTimeType(ZonedDateTime.now(saiCiSMARTHandler.getTimeZone())));
+
+        saiCiSMARTHandler.updateStatus(ThingStatus.ONLINE);
+        return chargingStatusResponseMessage.getApplicationData();
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBindingConstants.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBindingConstants.java
new file mode 100644 (file)
index 0000000..87532c8
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link SAICiSMARTBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class SAICiSMARTBindingConstants {
+
+    private static final String BINDING_ID = "saicismart";
+
+    /**
+     * Interval in seconds between polls of API.
+     */
+    public static final int REFRESH_INTERVAL = 10;
+
+    /**
+     * Active polling period in minutes
+     */
+    public static final int POLLING_ACTIVE_MINS = 10;
+
+    /**
+     * URL of the SAIC API Host.
+     */
+    private static final String API_HOST_URL = "https://tap-eu.soimt.com";
+
+    /**
+     * https://github.com/SAIC-iSmart-API/documentation?tab=readme-ov-file#api-v11
+     */
+    public static final String API_ENDPOINT_V11 = API_HOST_URL + "/TAP.Web/ota.mp";
+
+    /**
+     * https://github.com/SAIC-iSmart-API/documentation?tab=readme-ov-file#api-v21
+     */
+    public static final String API_ENDPOINT_V21 = API_HOST_URL + "/TAP.Web/ota.mpv21";
+
+    /**
+     * https://github.com/SAIC-iSmart-API/documentation?tab=readme-ov-file#api-v30
+     */
+    public static final String API_ENDPOINT_V30 = API_HOST_URL + "/TAP.Web/ota.mpv30";
+
+    public static final String ABRP_API_KEY = "8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d";
+
+    // List of all 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");
+
+    // List of all Channel ids
+    public static final String CHANNEL_ODOMETER = "odometer";
+    public static final String CHANNEL_RANGE_ELECTRIC = "range-electric";
+    public static final String CHANNEL_SOC = "soc";
+    public static final String CHANNEL_POWER = "power";
+    public static final String CHANNEL_ENGINE = "engine";
+    public static final String CHANNEL_CHARGING = "charging";
+    public static final String CHANNEL_TYRE_PRESSURE_FRONT_LEFT = "tyre-pressure-front-left";
+    public static final String CHANNEL_TYRE_PRESSURE_FRONT_RIGHT = "tyre-pressure-front-right";
+    public static final String CHANNEL_TYRE_PRESSURE_REAR_LEFT = "tyre-pressure-rear-left";
+    public static final String CHANNEL_TYRE_PRESSURE_REAR_RIGHT = "tyre-pressure-rear-right";
+    public static final String CHANNEL_INTERIOR_TEMPERATURE = "interior-temperature";
+    public static final String CHANNEL_EXTERIOR_TEMPERATURE = "exterior-temperature";
+    public static final String CHANNEL_SPEED = "speed";
+    public static final String CHANNEL_LOCATION = "location";
+    public static final String CHANNEL_HEADING = "heading";
+    public static final String CHANNEL_AUXILIARY_BATTERY_VOLTAGE = "auxiliary-battery-voltage";
+    public static final String CHANNEL_DOOR_DRIVER = "door-driver";
+    public static final String CHANNEL_DOOR_PASSENGER = "door-passenger";
+    public static final String CHANNEL_DOOR_REAR_LEFT = "door-rear-left";
+    public static final String CHANNEL_DOOR_REAR_RIGHT = "door-rear-right";
+    public static final String CHANNEL_WINDOW_DRIVER = "window-driver";
+    public static final String CHANNEL_WINDOW_PASSENGER = "window-passenger";
+    public static final String CHANNEL_WINDOW_REAR_LEFT = "window-rear-left";
+    public static final String CHANNEL_WINDOW_REAR_RIGHT = "window-rear-right";
+    public static final String CHANNEL_WINDOW_SUN_ROOF = "window-sun-roof";
+    public static final String CHANNEL_LAST_ACTIVITY = "last-activity";
+    public static final String CHANNEL_FORCE_REFRESH = "force-refresh";
+    public static final String CHANNEL_REMOTE_AC_STATUS = "remote-ac-status";
+    public static final String CHANNEL_SWITCH_AC = "switch-ac";
+    public static final String CHANNEL_LAST_POSITION_UPDATE = "last-position-update";
+    public static final String CHANNEL_LAST_CHARGE_STATE_UPDATE = "last-charge-state-update";
+    public static final String CHANNEL_ALARM_MESSAGE_DATE = "last-alarm-message-date";
+    public static final String CHANNEL_ALARM_MESSAGE_CONTENT = "last-alarm-message-content";
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeConfiguration.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeConfiguration.java
new file mode 100644 (file)
index 0000000..97d1eb5
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SAICiSMARTBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class SAICiSMARTBridgeConfiguration {
+
+    public String username = "";
+    public String password = "";
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeHandler.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTBridgeHandler.java
new file mode 100644 (file)
index 0000000..2b9d564
--- /dev/null
@@ -0,0 +1,297 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.API_ENDPOINT_V11;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.bn.coders.IASN1PreparedElement;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.openhab.core.thing.Bridge;
+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.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.GsonBuilder;
+
+import net.heberling.ismart.asn1.v1_1.Message;
+import net.heberling.ismart.asn1.v1_1.MessageCoder;
+import net.heberling.ismart.asn1.v1_1.entity.AlarmSwitch;
+import net.heberling.ismart.asn1.v1_1.entity.AlarmSwitchReq;
+import net.heberling.ismart.asn1.v1_1.entity.MP_AlarmSettingType;
+import net.heberling.ismart.asn1.v1_1.entity.MP_UserLoggingInReq;
+import net.heberling.ismart.asn1.v1_1.entity.MP_UserLoggingInResp;
+import net.heberling.ismart.asn1.v1_1.entity.MessageListReq;
+import net.heberling.ismart.asn1.v1_1.entity.MessageListResp;
+import net.heberling.ismart.asn1.v1_1.entity.StartEndNumber;
+import net.heberling.ismart.asn1.v1_1.entity.VinInfo;
+
+/**
+ * The {@link SAICiSMARTBridgeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class SAICiSMARTBridgeHandler extends BaseBridgeHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(SAICiSMARTBridgeHandler.class);
+
+    private @Nullable SAICiSMARTBridgeConfiguration config;
+
+    private @Nullable String uid;
+
+    private @Nullable String token;
+
+    private @Nullable Collection<VinInfo> vinList;
+    private HttpClient httpClient;
+    private @Nullable Future<?> pollingJob;
+
+    public SAICiSMARTBridgeHandler(Bridge bridge, HttpClient httpClient) {
+        super(bridge);
+        this.httpClient = httpClient;
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        // no commands available
+    }
+
+    @Override
+    public void initialize() {
+        config = getConfigAs(SAICiSMARTBridgeConfiguration.class);
+        updateStatus(ThingStatus.UNKNOWN);
+
+        // Validate configuration
+        if (this.config.username.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/thing-type.config.saicismart.bridge.username.required");
+            return;
+        }
+        if (this.config.password.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/thing-type.config.saicismart.bridge.password.required");
+            return;
+        }
+        if (this.config.username.length() > 50) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/thing-type.config.saicismart.bridge.username.toolong");
+            return;
+        }
+        pollingJob = scheduler.scheduleWithFixedDelay(this::updateStatus, 1,
+                SAICiSMARTBindingConstants.REFRESH_INTERVAL, TimeUnit.SECONDS);
+    }
+
+    private void updateStatus() {
+        if (uid == null || token == null) {
+            login();
+        } else {
+            registerForMessages();
+        }
+    }
+
+    private void login() {
+        MessageCoder<MP_UserLoggingInReq> mpUserLoggingInRequestMessageCoder = new MessageCoder<>(
+                MP_UserLoggingInReq.class);
+
+        MP_UserLoggingInReq mpUserLoggingInReq = new MP_UserLoggingInReq();
+        mpUserLoggingInReq.setPassword(config.password);
+        Message<MP_UserLoggingInReq> loginRequestMessage = mpUserLoggingInRequestMessageCoder.initializeMessage(
+                StringUtils.padLeft("#" + config.username, 50, "0"), null, null, "501", 513, 1, mpUserLoggingInReq);
+
+        String loginRequest = mpUserLoggingInRequestMessageCoder.encodeRequest(loginRequestMessage);
+
+        try {
+            String loginResponse = sendRequest(loginRequest, API_ENDPOINT_V11);
+
+            Message<MP_UserLoggingInResp> loginResponseMessage = new MessageCoder<>(MP_UserLoggingInResp.class)
+                    .decodeResponse(loginResponse);
+
+            logger.trace("Got message: {}",
+                    new GsonBuilder().setPrettyPrinting().create().toJson(loginResponseMessage));
+
+            uid = loginResponseMessage.getBody().getUid();
+            token = loginResponseMessage.getApplicationData().getToken();
+            vinList = loginResponseMessage.getApplicationData().getVinList();
+
+            // register for all known alarm types (not all might be actually delivered)
+            for (MP_AlarmSettingType.EnumType type : MP_AlarmSettingType.EnumType.values()) {
+                registerAlarmMessage(loginResponseMessage.getBody().getUid(),
+                        loginResponseMessage.getApplicationData().getToken(), type);
+            }
+
+            updateStatus(ThingStatus.ONLINE);
+        } catch (TimeoutException | URISyntaxException | ExecutionException | InterruptedException
+                | NoSuchAlgorithmException | IOException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+        }
+    }
+
+    private void registerForMessages() {
+        MessageCoder<MessageListReq> messageListReqMessageCoder = new MessageCoder<>(MessageListReq.class);
+        Message<MessageListReq> messageListRequestMessage = messageListReqMessageCoder.initializeMessage(uid, token,
+                null, "531", 513, 1, new MessageListReq());
+
+        messageListRequestMessage.getHeader().setProtocolVersion(18);
+
+        // We currently assume that the newest message is the first.
+        messageListRequestMessage.getApplicationData().setStartEndNumber(new StartEndNumber());
+        messageListRequestMessage.getApplicationData().getStartEndNumber().setStartNumber(1L);
+        messageListRequestMessage.getApplicationData().getStartEndNumber().setEndNumber(5L);
+        messageListRequestMessage.getApplicationData().setMessageGroup("ALARM");
+
+        String messageListRequest = messageListReqMessageCoder.encodeRequest(messageListRequestMessage);
+
+        try {
+            String messageListResponse = sendRequest(messageListRequest, API_ENDPOINT_V11);
+
+            Message<MessageListResp> messageListResponseMessage = new MessageCoder<>(MessageListResp.class)
+                    .decodeResponse(messageListResponse);
+
+            logger.trace("Got message: {}",
+                    new GsonBuilder().setPrettyPrinting().create().toJson(messageListResponseMessage));
+
+            if (messageListResponseMessage.getApplicationData() != null
+                    && messageListResponseMessage.getApplicationData().getMessages() != null) {
+                for (net.heberling.ismart.asn1.v1_1.entity.Message message : messageListResponseMessage
+                        .getApplicationData().getMessages()) {
+                    if (message.isVinPresent()) {
+                        String vin = message.getVin();
+                        getThing().getThings().stream().filter(t -> t.getUID().getId().equals(vin))
+                                .map(Thing::getHandler).filter(Objects::nonNull)
+                                .filter(SAICiSMARTHandler.class::isInstance).map(SAICiSMARTHandler.class::cast)
+                                .forEach(t -> t.handleMessage(message));
+                    }
+                }
+            }
+            updateStatus(ThingStatus.ONLINE);
+        } catch (TimeoutException | URISyntaxException | ExecutionException | InterruptedException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+        }
+    }
+
+    private void registerAlarmMessage(String uid, String token, MP_AlarmSettingType.EnumType type)
+            throws NoSuchAlgorithmException, IOException, URISyntaxException, ExecutionException, InterruptedException,
+            TimeoutException {
+        MessageCoder<AlarmSwitchReq> alarmSwitchReqMessageCoder = new MessageCoder<>(AlarmSwitchReq.class);
+
+        AlarmSwitchReq alarmSwitchReq = new AlarmSwitchReq();
+        alarmSwitchReq
+                .setAlarmSwitchList(Stream.of(type).map(v -> createAlarmSwitch(v, true)).collect(Collectors.toList()));
+        alarmSwitchReq.setPin(hashMD5("123456"));
+
+        Message<AlarmSwitchReq> alarmSwitchMessage = alarmSwitchReqMessageCoder.initializeMessage(uid, token, null,
+                "521", 513, 1, alarmSwitchReq);
+        String alarmSwitchRequest = alarmSwitchReqMessageCoder.encodeRequest(alarmSwitchMessage);
+        String alarmSwitchResponse = sendRequest(alarmSwitchRequest, API_ENDPOINT_V11);
+        final MessageCoder<IASN1PreparedElement> alarmSwitchResMessageCoder = new MessageCoder<>(
+                IASN1PreparedElement.class);
+        Message<IASN1PreparedElement> alarmSwitchResponseMessage = alarmSwitchResMessageCoder
+                .decodeResponse(alarmSwitchResponse);
+
+        logger.trace("Got message: {}",
+                new GsonBuilder().setPrettyPrinting().create().toJson(alarmSwitchResponseMessage));
+
+        if (alarmSwitchResponseMessage.getBody().getErrorMessage() != null) {
+            logger.debug("Could not register for {} messages: {}", type,
+                    new String(alarmSwitchResponseMessage.getBody().getErrorMessage(), StandardCharsets.UTF_8));
+        } else {
+            logger.debug("Registered for {} messages", type);
+        }
+    }
+
+    private static AlarmSwitch createAlarmSwitch(MP_AlarmSettingType.EnumType type, boolean enabled) {
+        AlarmSwitch alarmSwitch = new AlarmSwitch();
+        MP_AlarmSettingType alarmSettingType = new MP_AlarmSettingType();
+        alarmSettingType.setValue(type);
+        alarmSettingType.setIntegerForm(type.ordinal());
+        alarmSwitch.setAlarmSettingType(alarmSettingType);
+        alarmSwitch.setAlarmSwitch(enabled);
+        alarmSwitch.setFunctionSwitch(enabled);
+        return alarmSwitch;
+    }
+
+    public String hashMD5(String password) throws NoSuchAlgorithmException {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        md.update(password.getBytes());
+        byte[] digest = md.digest();
+        return DatatypeConverter.printHexBinary(digest).toUpperCase();
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Collections.singleton(VehicleDiscovery.class);
+    }
+
+    @Nullable
+    public String getUid() {
+        return uid;
+    }
+
+    @Nullable
+    public String getToken() {
+        return token;
+    }
+
+    public Collection<VinInfo> getVinList() {
+        return Optional.ofNullable(vinList).orElse(Collections.emptyList());
+    }
+
+    public String sendRequest(String request, String endpoint)
+            throws URISyntaxException, ExecutionException, InterruptedException, TimeoutException {
+        return httpClient.POST(new URI(endpoint)).content(new StringContentProvider(request), "text/html").send()
+                .getContentAsString();
+    }
+
+    public void relogin() {
+        uid = null;
+        token = null;
+    }
+
+    @Override
+    public void dispose() {
+        Future<?> job = pollingJob;
+        if (job != null) {
+            job.cancel(true);
+            pollingJob = null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandler.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandler.java
new file mode 100644 (file)
index 0000000..6d9f08c
--- /dev/null
@@ -0,0 +1,283 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.ABRP_API_KEY;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.API_ENDPOINT_V21;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_FORCE_REFRESH;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_LAST_ACTIVITY;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_SWITCH_AC;
+
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+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.builder.ThingBuilder;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.GsonBuilder;
+
+import net.heberling.ismart.abrp.ABRP;
+import net.heberling.ismart.asn1.v1_1.entity.Message;
+import net.heberling.ismart.asn1.v2_1.MessageCoder;
+import net.heberling.ismart.asn1.v2_1.entity.OTA_RVCReq;
+import net.heberling.ismart.asn1.v2_1.entity.OTA_RVCStatus25857;
+import net.heberling.ismart.asn1.v2_1.entity.OTA_RVMVehicleStatusResp25857;
+import net.heberling.ismart.asn1.v2_1.entity.RvcReqParam;
+import net.heberling.ismart.asn1.v3_0.entity.OTA_ChrgMangDataResp;
+
+/**
+ * The {@link SAICiSMARTHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class SAICiSMARTHandler extends BaseThingHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(SAICiSMARTHandler.class);
+
+    private final TimeZoneProvider timeZoneProvider;
+
+    @Nullable
+    SAICiSMARTVehicleConfiguration config;
+    private @Nullable Future<?> pollingJob;
+    private ZonedDateTime lastAlarmMessage;
+    private ZonedDateTime lastCarActivity;
+
+    /**
+     * If the binding is initialized, treat the car as active (lastCarActivity = now) to get some first data.
+     * 
+     * @param httpClientFactory
+     * @param timeZoneProvider
+     * @param thing
+     */
+    public SAICiSMARTHandler(TimeZoneProvider timeZoneProvider, Thing thing) {
+        super(thing);
+        this.timeZoneProvider = timeZoneProvider;
+        lastAlarmMessage = ZonedDateTime.now(getTimeZone());
+        lastCarActivity = ZonedDateTime.now(getTimeZone());
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (channelUID.getId().equals(SAICiSMARTBindingConstants.CHANNEL_FORCE_REFRESH) && command == OnOffType.ON) {
+            // reset channel to off
+            updateState(CHANNEL_FORCE_REFRESH, OnOffType.from(false));
+            // update internal activity date, to query the car for about a minute
+            notifyCarActivity(ZonedDateTime.now(getTimeZone()).minus(SAICiSMARTBindingConstants.POLLING_ACTIVE_MINS - 1,
+                    ChronoUnit.MINUTES), true);
+        } else if (channelUID.getId().equals(CHANNEL_SWITCH_AC) && command == OnOffType.ON) {
+            // reset channel to off
+            updateState(CHANNEL_SWITCH_AC, OnOffType.ON);
+            // enable air conditioning
+            try {
+                sendACCommand((byte) 5, (byte) 8);
+            } catch (URISyntaxException | ExecutionException | TimeoutException | InterruptedException e) {
+                logger.warn("A/C On Command failed", e);
+            }
+        } else if (channelUID.getId().equals(CHANNEL_SWITCH_AC) && command == OnOffType.OFF) {
+            // reset channel to off
+            updateState(CHANNEL_SWITCH_AC, OnOffType.OFF);
+            // disable air conditioning
+            try {
+                sendACCommand((byte) 0, (byte) 0);
+            } catch (URISyntaxException | ExecutionException | TimeoutException | InterruptedException e) {
+                logger.warn("A/C Off Command failed", e);
+            }
+        } else if (channelUID.getId().equals(CHANNEL_LAST_ACTIVITY)
+                && command instanceof DateTimeType commnadAsDateTimeType) {
+            // update internal activity date from external date
+            notifyCarActivity(commnadAsDateTimeType.getZonedDateTime(), true);
+        }
+    }
+
+    protected @Nullable SAICiSMARTBridgeHandler getBridgeHandler() {
+        return (SAICiSMARTBridgeHandler) super.getBridge().getHandler();
+    }
+
+    @Override
+    public void initialize() {
+        config = getConfigAs(SAICiSMARTVehicleConfiguration.class);
+
+        ThingBuilder thingBuilder = editThing();
+        updateThing(thingBuilder.build());
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        // Validate configuration
+        if (this.config.vin.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/thing-type.config.saicismart.vehicle.vin.required");
+            return;
+        }
+
+        // just started, make sure we start querying
+        notifyCarActivity(ZonedDateTime.now(getTimeZone()), true);
+        pollingJob = scheduler.scheduleWithFixedDelay(this::updateStatus, 2,
+                SAICiSMARTBindingConstants.REFRESH_INTERVAL, TimeUnit.SECONDS);
+    }
+
+    private void updateStatus() {
+        if (lastCarActivity.isAfter(
+                ZonedDateTime.now().minus(SAICiSMARTBindingConstants.POLLING_ACTIVE_MINS, ChronoUnit.MINUTES))) {
+            if (this.getBridgeHandler().getUid() != null && this.getBridgeHandler().getToken() != null) {
+                try {
+                    OTA_RVMVehicleStatusResp25857 otaRvmVehicleStatusResp25857 = new VehicleStateUpdater(this).call();
+                    OTA_ChrgMangDataResp otaChrgMangDataResp = new ChargeStateUpdater(this).call();
+
+                    if (config.abrpUserToken != null && config.abrpUserToken.length() > 0) {
+                        String execute = ABRP.updateAbrp(ABRP_API_KEY, config.abrpUserToken,
+                                otaRvmVehicleStatusResp25857, otaChrgMangDataResp);
+
+                        logger.debug("ABRP: {}", execute);
+                    }
+                } catch (Exception e) {
+                    logger.warn("Could not refresh car data.", e);
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "@text/addon.saicismart.error.refresh.car.data");
+                }
+            }
+        }
+    }
+
+    private void sendACCommand(byte command, byte temperature)
+            throws URISyntaxException, ExecutionException, InterruptedException, TimeoutException {
+        MessageCoder<OTA_RVCReq> otaRvcReqMessageCoder = new MessageCoder<>(OTA_RVCReq.class);
+
+        // we send a command end expect the car to wake up
+        notifyCarActivity(ZonedDateTime.now(getTimeZone()), false);
+
+        OTA_RVCReq req = new OTA_RVCReq();
+        req.setRvcReqType(new byte[] { 6 });
+        List<RvcReqParam> params = new ArrayList<>();
+        req.setRvcParams(params);
+        RvcReqParam param = new RvcReqParam();
+        param.setParamId(19);
+        param.setParamValue(new byte[] { command });
+        params.add(param);
+        param = new RvcReqParam();
+        param.setParamId(20);
+        param.setParamValue(new byte[] { temperature });
+        params.add(param);
+        param = new RvcReqParam();
+        param.setParamId(255);
+        param.setParamValue(new byte[] { 0 });
+        params.add(param);
+
+        net.heberling.ismart.asn1.v2_1.Message<OTA_RVCReq> enableACRequest = otaRvcReqMessageCoder.initializeMessage(
+                getBridgeHandler().getUid(), getBridgeHandler().getToken(), config.vin, "510", 25857, 1, req);
+
+        String enableACRequestMessage = otaRvcReqMessageCoder.encodeRequest(enableACRequest);
+
+        String enableACResponseMessage = getBridgeHandler().sendRequest(enableACRequestMessage, API_ENDPOINT_V21);
+
+        net.heberling.ismart.asn1.v2_1.Message<OTA_RVCStatus25857> enableACResponse = new net.heberling.ismart.asn1.v2_1.MessageCoder<>(
+                OTA_RVCStatus25857.class).decodeResponse(enableACResponseMessage);
+
+        // ... use that to request the data again, until we have it
+        while (enableACResponse.getApplicationData() == null) {
+            if (enableACResponse.getBody().isErrorMessagePresent()) {
+                if (enableACResponse.getBody().getResult() == 2) {
+                    getBridgeHandler().relogin();
+                }
+                throw new TimeoutException(new String(enableACResponse.getBody().getErrorMessage()));
+            }
+
+            if (enableACResponse.getBody().getResult() == 0) {
+                // we get an eventId back...
+                enableACRequest.getBody().setEventID(enableACResponse.getBody().getEventID());
+            } else {
+                // try a fresh eventId
+                enableACRequest.getBody().setEventID(0);
+            }
+
+            enableACRequestMessage = otaRvcReqMessageCoder.encodeRequest(enableACRequest);
+
+            enableACResponseMessage = getBridgeHandler().sendRequest(enableACRequestMessage, API_ENDPOINT_V21);
+
+            enableACResponse = new net.heberling.ismart.asn1.v2_1.MessageCoder<>(OTA_RVCStatus25857.class)
+                    .decodeResponse(enableACResponseMessage);
+        }
+
+        logger.trace("Got A/C message: {}", new GsonBuilder().setPrettyPrinting().create().toJson(enableACResponse));
+    }
+
+    public void notifyCarActivity(ZonedDateTime now, boolean force) {
+        // if the car activity changed, notify the channel
+        if (force || lastCarActivity.isBefore(now)) {
+            lastCarActivity = now;
+            updateState(CHANNEL_LAST_ACTIVITY, new DateTimeType(lastCarActivity));
+        }
+    }
+
+    @Override
+    public void dispose() {
+        Future<?> job = pollingJob;
+        if (job != null) {
+            job.cancel(true);
+            pollingJob = null;
+        }
+    }
+
+    @Override
+    public void updateState(String channelID, State state) {
+        super.updateState(channelID, state);
+    }
+
+    @Override
+    public void updateStatus(ThingStatus status) {
+        super.updateStatus(status);
+    }
+
+    public void handleMessage(Message message) {
+        ZonedDateTime time = ZonedDateTime.ofInstant(Instant.ofEpochSecond(message.getMessageTime().getSeconds()),
+                getTimeZone());
+
+        if (time.isAfter(lastAlarmMessage)) {
+            lastAlarmMessage = time;
+            updateState(SAICiSMARTBindingConstants.CHANNEL_ALARM_MESSAGE_CONTENT,
+                    new StringType(new String(message.getContent(), StandardCharsets.UTF_8)));
+            updateState(SAICiSMARTBindingConstants.CHANNEL_ALARM_MESSAGE_DATE, new DateTimeType(time));
+        }
+
+        notifyCarActivity(time, false);
+    }
+
+    public ZoneId getTimeZone() {
+        return timeZoneProvider.getTimeZone();
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandlerFactory.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTHandlerFactory.java
new file mode 100644 (file)
index 0000000..abaf4d9
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.i18n.TranslationProvider;
+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 SAICiSMARTHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.saicismart", service = ThingHandlerFactory.class)
+public class SAICiSMARTHandlerFactory extends BaseThingHandlerFactory {
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_VEHICLE);
+    private final TimeZoneProvider timeZoneProvider;
+    private HttpClient httpClient;
+
+    @Activate
+    public SAICiSMARTHandlerFactory(final @Reference TranslationProvider translationProvider,
+            final @Reference LocaleProvider localeProvider, final @Reference HttpClientFactory httpClientFactory,
+            final @Reference TimeZoneProvider timeZoneProvider) {
+        this.httpClient = httpClientFactory.getCommonHttpClient();
+        this.timeZoneProvider = timeZoneProvider;
+    }
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
+            return new SAICiSMARTBridgeHandler((Bridge) thing, httpClient);
+        } else if (THING_TYPE_VEHICLE.equals(thingTypeUID)) {
+            return new SAICiSMARTHandler(timeZoneProvider, thing);
+        }
+
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTVehicleConfiguration.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/SAICiSMARTVehicleConfiguration.java
new file mode 100644 (file)
index 0000000..478d55d
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link SAICiSMARTVehicleConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class SAICiSMARTVehicleConfiguration {
+
+    public String vin = "";
+
+    @Nullable
+    public String abrpUserToken;
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleDiscovery.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleDiscovery.java
new file mode 100644 (file)
index 0000000..5ef6c3f
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.THING_TYPE_VEHICLE;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+
+import net.heberling.ismart.asn1.v1_1.entity.VinInfo;
+
+/**
+ *
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+public class VehicleDiscovery extends AbstractDiscoveryService implements DiscoveryService, ThingHandlerService {
+
+    private @Nullable SAICiSMARTBridgeHandler handler;
+    private static final String PROPERTY_VIN = "vin";
+
+    public VehicleDiscovery() throws IllegalArgumentException {
+        super(Set.of(THING_TYPE_VEHICLE), 0);
+    }
+
+    @Override
+    protected void startScan() {
+        Collection<VinInfo> vinList = handler.getVinList();
+        for (VinInfo vinInfo : vinList) {
+            ThingTypeUID type = THING_TYPE_VEHICLE;
+            ThingUID thingUID = new ThingUID(type, handler.getThing().getUID(), vinInfo.getVin());
+            DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
+                    .withLabel(new String(vinInfo.getBrandName()) + " " + new String(vinInfo.getModelName()))
+                    .withBridge(handler.getThing().getUID()).withProperty(PROPERTY_VIN, vinInfo.getVin())
+                    .withRepresentationProperty(PROPERTY_VIN).build();
+            thingDiscovered(discoveryResult);
+        }
+    }
+
+    @Override
+    public void setThingHandler(ThingHandler handler) {
+        this.handler = (SAICiSMARTBridgeHandler) handler;
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return handler;
+    }
+
+    @Override
+    public void activate(@Nullable Map<String, Object> configProperties) {
+        super.activate(configProperties);
+    }
+
+    @Override
+    public void deactivate() {
+        super.deactivate();
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleStateUpdater.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/VehicleStateUpdater.java
new file mode 100644 (file)
index 0000000..2a66e3e
--- /dev/null
@@ -0,0 +1,242 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal;
+
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.API_ENDPOINT_V21;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_AUXILIARY_BATTERY_VOLTAGE;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_CHARGING;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_ENGINE;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_HEADING;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_LOCATION;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_ODOMETER;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_RANGE_ELECTRIC;
+import static org.openhab.binding.saicismart.internal.SAICiSMARTBindingConstants.CHANNEL_SPEED;
+
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.saicismart.internal.exceptions.VehicleStatusAPIException;
+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.MetricPrefix;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ThingStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.GsonBuilder;
+
+import net.heberling.ismart.asn1.v2_1.Message;
+import net.heberling.ismart.asn1.v2_1.MessageCoder;
+import net.heberling.ismart.asn1.v2_1.entity.OTA_RVMVehicleStatusReq;
+import net.heberling.ismart.asn1.v2_1.entity.OTA_RVMVehicleStatusResp25857;
+
+/**
+ * @author Markus Heberling - Initial contribution
+ */
+@NonNullByDefault
+class VehicleStateUpdater implements Callable<OTA_RVMVehicleStatusResp25857> {
+    private final Logger logger = LoggerFactory.getLogger(VehicleStateUpdater.class);
+
+    private final SAICiSMARTHandler saiCiSMARTHandler;
+
+    public VehicleStateUpdater(SAICiSMARTHandler saiCiSMARTHandler) {
+        this.saiCiSMARTHandler = saiCiSMARTHandler;
+    }
+
+    @Override
+    public OTA_RVMVehicleStatusResp25857 call() throws URISyntaxException, ExecutionException, InterruptedException,
+            TimeoutException, VehicleStatusAPIException {
+        MessageCoder<OTA_RVMVehicleStatusReq> otaRvmVehicleStatusRequstMessageCoder = new MessageCoder<>(
+                OTA_RVMVehicleStatusReq.class);
+
+        OTA_RVMVehicleStatusReq otaRvmVehicleStatusReq = new OTA_RVMVehicleStatusReq();
+        otaRvmVehicleStatusReq.setVehStatusReqType(1);
+
+        Message<OTA_RVMVehicleStatusReq> chargingStatusMessage = otaRvmVehicleStatusRequstMessageCoder
+                .initializeMessage(saiCiSMARTHandler.getBridgeHandler().getUid(),
+                        saiCiSMARTHandler.getBridgeHandler().getToken(), saiCiSMARTHandler.config.vin, "511", 25857, 1,
+                        otaRvmVehicleStatusReq);
+
+        String chargingStatusRequestMessage = otaRvmVehicleStatusRequstMessageCoder
+                .encodeRequest(chargingStatusMessage);
+
+        String chargingStatusResponse = saiCiSMARTHandler.getBridgeHandler().sendRequest(chargingStatusRequestMessage,
+                API_ENDPOINT_V21);
+
+        Message<OTA_RVMVehicleStatusResp25857> chargingStatusResponseMessage = new MessageCoder<>(
+                OTA_RVMVehicleStatusResp25857.class).decodeResponse(chargingStatusResponse);
+
+        // we get an eventId back...
+        chargingStatusMessage.getBody().setEventID(chargingStatusResponseMessage.getBody().getEventID());
+        // ... use that to request the data again, until we have it
+        while (chargingStatusResponseMessage.getApplicationData() == null) {
+            if (chargingStatusResponseMessage.getBody().getResult() != 0
+                    || chargingStatusResponseMessage.getBody().isErrorMessagePresent()) {
+                if (chargingStatusResponseMessage.getBody().getResult() == 2) {
+                    saiCiSMARTHandler.getBridgeHandler().relogin();
+                }
+                throw new VehicleStatusAPIException(chargingStatusResponseMessage.getBody());
+            }
+
+            chargingStatusMessage.getBody().setUid(saiCiSMARTHandler.getBridgeHandler().getUid());
+            chargingStatusMessage.getBody().setToken(saiCiSMARTHandler.getBridgeHandler().getToken());
+
+            chargingStatusRequestMessage = otaRvmVehicleStatusRequstMessageCoder.encodeRequest(chargingStatusMessage);
+
+            chargingStatusResponse = saiCiSMARTHandler.getBridgeHandler().sendRequest(chargingStatusRequestMessage,
+                    API_ENDPOINT_V21);
+
+            chargingStatusResponseMessage = new MessageCoder<>(OTA_RVMVehicleStatusResp25857.class)
+                    .decodeResponse(chargingStatusResponse);
+        }
+
+        logger.trace("Got message: {}",
+                new GsonBuilder().setPrettyPrinting().create().toJson(chargingStatusResponseMessage));
+
+        boolean engineRunning = chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus()
+                .getEngineStatus() == 1;
+        boolean isCharging = chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus()
+                .isExtendedData2Present()
+                && chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getExtendedData2() >= 1;
+        saiCiSMARTHandler.updateState(CHANNEL_ENGINE, OnOffType.from(engineRunning));
+        saiCiSMARTHandler.updateState(CHANNEL_CHARGING, OnOffType.from(isCharging));
+
+        saiCiSMARTHandler.updateState(CHANNEL_AUXILIARY_BATTERY_VOLTAGE, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getBatteryVoltage() / 10.d,
+                Units.VOLT));
+
+        saiCiSMARTHandler.updateState(CHANNEL_SPEED, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getGpsPosition().getWayPoint().getSpeed() / 10.d,
+                SIUnits.KILOMETRE_PER_HOUR));
+        saiCiSMARTHandler.updateState(CHANNEL_HEADING,
+                new QuantityType<>(
+                        chargingStatusResponseMessage.getApplicationData().getGpsPosition().getWayPoint().getHeading(),
+                        Units.DEGREE_ANGLE));
+        saiCiSMARTHandler.updateState(CHANNEL_LOCATION,
+                new PointType(
+                        new DecimalType(chargingStatusResponseMessage.getApplicationData().getGpsPosition()
+                                .getWayPoint().getPosition().getLatitude() / 1000000d),
+                        new DecimalType(chargingStatusResponseMessage.getApplicationData().getGpsPosition()
+                                .getWayPoint().getPosition().getLongitude() / 1000000d),
+                        new DecimalType(chargingStatusResponseMessage.getApplicationData().getGpsPosition()
+                                .getWayPoint().getPosition().getAltitude())));
+
+        saiCiSMARTHandler.updateState(CHANNEL_ODOMETER,
+                new QuantityType<>(
+                        chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getMileage() / 10.d,
+                        MetricPrefix.KILO(SIUnits.METRE)));
+        saiCiSMARTHandler.updateState(CHANNEL_RANGE_ELECTRIC, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getFuelRangeElec() / 10.d,
+                MetricPrefix.KILO(SIUnits.METRE)));
+
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_TYRE_PRESSURE_FRONT_LEFT, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getFrontLeftTyrePressure()
+                        * 4 / 100.d,
+                Units.BAR));
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_TYRE_PRESSURE_FRONT_RIGHT, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getFrontRrightTyrePressure()
+                        * 4 / 100.d,
+                Units.BAR));
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_TYRE_PRESSURE_REAR_LEFT, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearLeftTyrePressure() * 4
+                        / 100.d,
+                Units.BAR));
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_TYRE_PRESSURE_REAR_RIGHT, new QuantityType<>(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearRightTyrePressure()
+                        * 4 / 100.d,
+                Units.BAR));
+
+        Integer interiorTemperature = chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus()
+                .getInteriorTemperature();
+        if (interiorTemperature > -128) {
+            saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_INTERIOR_TEMPERATURE,
+                    new QuantityType<>(interiorTemperature, SIUnits.CELSIUS));
+        }
+        Integer exteriorTemperature = chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus()
+                .getExteriorTemperature();
+        if (exteriorTemperature > -128) {
+            saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_EXTERIOR_TEMPERATURE,
+                    new QuantityType<>(exteriorTemperature, SIUnits.CELSIUS));
+        }
+
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_DOOR_DRIVER,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getDriverDoor()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_DOOR_PASSENGER,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getPassengerDoor()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_DOOR_REAR_LEFT,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearLeftDoor()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_DOOR_REAR_RIGHT,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearRightDoor()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_WINDOW_DRIVER,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getDriverWindow()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_WINDOW_PASSENGER,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getPassengerWindow()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_WINDOW_REAR_LEFT,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearLeftWindow()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_WINDOW_REAR_RIGHT,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRearRightWindow()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_WINDOW_SUN_ROOF,
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getSunroofStatus()
+                        ? OpenClosedType.OPEN
+                        : OpenClosedType.CLOSED);
+
+        boolean acActive = chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus()
+                .getRemoteClimateStatus() > 0;
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_SWITCH_AC, OnOffType.from(acActive));
+        saiCiSMARTHandler.updateState(SAICiSMARTBindingConstants.CHANNEL_REMOTE_AC_STATUS, new DecimalType(
+                chargingStatusResponseMessage.getApplicationData().getBasicVehicleStatus().getRemoteClimateStatus()));
+
+        saiCiSMARTHandler
+                .updateState(SAICiSMARTBindingConstants.CHANNEL_LAST_POSITION_UPDATE,
+                        new DateTimeType(ZonedDateTime.ofInstant(
+                                Instant.ofEpochSecond(chargingStatusResponseMessage.getApplicationData()
+                                        .getGpsPosition().getTimestamp4Short().getSeconds()),
+                                saiCiSMARTHandler.getTimeZone())));
+
+        if (isCharging || acActive || engineRunning) {
+            // update activity date
+            saiCiSMARTHandler.notifyCarActivity(ZonedDateTime.now(saiCiSMARTHandler.getTimeZone()), true);
+        }
+
+        saiCiSMARTHandler.updateStatus(ThingStatus.ONLINE);
+        return chargingStatusResponseMessage.getApplicationData();
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/ChargingStatusAPIException.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/ChargingStatusAPIException.java
new file mode 100644 (file)
index 0000000..c571c60
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import net.heberling.ismart.asn1.v3_0.MP_DispatcherBody;
+
+/**
+ * @author Doug Culnane - Initial contribution
+ */
+@NonNullByDefault
+public class ChargingStatusAPIException extends Exception {
+
+    public ChargingStatusAPIException(MP_DispatcherBody body) {
+        super("[" + body.getResult() + "] " + new String(body.getErrorMessage()));
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/VehicleStatusAPIException.java b/bundles/org.openhab.binding.saicismart/src/main/java/org/openhab/binding/saicismart/internal/exceptions/VehicleStatusAPIException.java
new file mode 100644 (file)
index 0000000..1e76d00
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2024 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.saicismart.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import net.heberling.ismart.asn1.v2_1.MP_DispatcherBody;
+
+/**
+ * @author Doug Culnane - Initial contribution
+ */
+@NonNullByDefault
+public class VehicleStatusAPIException extends Exception {
+
+    public VehicleStatusAPIException(MP_DispatcherBody body) {
+        super("[" + body.getResult() + "] " + new String(body.getErrorMessage()));
+    }
+}
diff --git a/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..9aa858b
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="saicismart" 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>SAICiSMART Binding</name>
+       <description>This is the binding for SAIC (MG) iSMART Cars.</description>
+       <connection>cloud</connection>
+</addon:addon>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/bridge-config.xml b/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/bridge-config.xml
new file mode 100644 (file)
index 0000000..801d534
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:saicismart:bridge">
+               <parameter name="username" type="text" required="true">
+                       <label>Username</label>
+                       <description>iSMART Username</description>
+               </parameter>
+               <parameter name="password" type="text" required="true">
+                       <label>Password</label>
+                       <description>iSMART Password</description>
+                       <context>password</context>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/thing-config.xml b/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/config/thing-config.xml
new file mode 100644 (file)
index 0000000..1cdb9c7
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:saicismart:vehicle">
+               <parameter name="vin" type="text" required="true">
+                       <label>VIN</label>
+                       <description>Unique Vehicle Identification Number (VIN) given by SAIC</description>
+               </parameter>
+               <parameter name="abrpUserToken" type="text" required="false">
+                       <label>ABRP User Token</label>
+                       <description>User Token for A Better Routeplanner.</description>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/bridge-ismart.xml b/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/bridge-ismart.xml
new file mode 100644 (file)
index 0000000..c650c1f
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="saicismart"
+       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>iSMART Account</label>
+               <description>Your iSMART account data</description>
+               <config-description-ref uri="thing-type:saicismart:bridge"/>
+       </bridge-type>
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.saicismart/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..b1ae62f
--- /dev/null
@@ -0,0 +1,229 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="saicismart"
+       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">
+
+       <!-- Sample Thing Type -->
+       <thing-type id="vehicle">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="account"/>
+               </supported-bridge-type-refs>
+
+               <label>SAIC Car</label>
+               <description>iSMART enabled car</description>
+               <category>Car</category>
+
+               <channels>
+                       <channel id="odometer" typeId="odometer-channel"/>
+                       <channel id="range-electric" typeId="range-electric-channel"/>
+                       <channel id="soc" typeId="system.battery-level"/>
+                       <channel id="power" typeId="power-channel"/>
+                       <channel id="charging" typeId="charging-channel"/>
+                       <channel id="engine" typeId="engine-channel"/>
+                       <channel id="speed" typeId="speed-channel"/>
+                       <channel id="location" typeId="location-channel"/>
+                       <channel id="heading" typeId="heading-channel"/>
+                       <channel id="auxiliary-battery-voltage" typeId="auxiliary-battery-voltage-channel"/>
+                       <channel id="tyre-pressure-front-left" typeId="tyre-pressure-channel">
+                               <label>Pressure Front Left</label>
+                       </channel>
+                       <channel id="tyre-pressure-front-right" typeId="tyre-pressure-channel">
+                               <label>Pressure Front Right</label>
+                       </channel>
+                       <channel id="tyre-pressure-rear-left" typeId="tyre-pressure-channel">
+                               <label>Pressure Rear Left</label>
+                       </channel>
+                       <channel id="tyre-pressure-rear-right" typeId="tyre-pressure-channel">
+                               <label>Pressure Rear Right</label>
+                       </channel>
+                       <channel id="interior-temperature" typeId="temperature-channel">
+                               <label>Interior Temperature</label>
+                       </channel>
+                       <channel id="exterior-temperature" typeId="temperature-channel">
+                               <label>Exterior Temperature</label>
+                       </channel>
+                       <channel id="door-driver" typeId="door-channel">
+                               <label>Driver Door</label>
+                       </channel>
+                       <channel id="door-passenger" typeId="door-channel">
+                               <label>Passenger Door</label>
+                       </channel>
+                       <channel id="door-rear-left" typeId="door-channel">
+                               <label>Rear Left Door</label>
+                       </channel>
+                       <channel id="door-rear-right" typeId="door-channel">
+                               <label>Rear Right Door</label>
+                       </channel>
+                       <channel id="window-driver" typeId="window-channel">
+                               <label>Driver Window</label>
+                       </channel>
+                       <channel id="window-passenger" typeId="window-channel">
+                               <label>Passenger Window</label>
+                       </channel>
+                       <channel id="window-rear-left" typeId="window-channel">
+                               <label>Rear Left Window</label>
+                       </channel>
+                       <channel id="window-rear-right" typeId="window-channel">
+                               <label>Rear Right Window</label>
+                       </channel>
+                       <channel id="window-sun-roof" typeId="window-channel">
+                               <label>Sun Roof</label>
+                       </channel>
+                       <channel id="last-activity" typeId="timestamp-channel">
+                               <label>Last Car Activity</label>
+                               <description>Last time either the engine was on or the car was charging</description>
+                       </channel>
+                       <channel id="last-position-update" typeId="timestamp-channel">
+                               <label>Last Position Timestamp</label>
+                               <description>Last time the Position data was updated</description>
+                       </channel>
+                       <channel id="last-charge-state-update" typeId="timestamp-channel">
+                               <label>Last Charge State Timestamp</label>
+                               <description>Last time the Charge State data was updated</description>
+                       </channel>
+                       <channel id="remote-ac-status" typeId="remote-ac-status-channel"/>
+                       <channel id="switch-ac" typeId="switch-ac-channel"/>
+                       <channel id="force-refresh" typeId="force-refresh-channel"/>
+                       <channel id="last-alarm-message-date" typeId="timestamp-channel">
+                               <label>Last Alarm Message Timestamp</label>
+                               <description>Last time an alarm message was sent</description>
+                       </channel>
+                       <channel id="last-alarm-message-content" typeId="alarm-message-channel"/>
+               </channels>
+
+               <representation-property>vin</representation-property>
+
+               <config-description-ref uri="thing-type:saicismart:vehicle"/>
+       </thing-type>
+
+       <channel-type id="odometer-channel">
+               <item-type>Number:Length</item-type>
+               <label>Total Distance Driven</label>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="range-electric-channel">
+               <item-type>Number:Length</item-type>
+               <label>Electric Range</label>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-channel">
+               <item-type>Number:Power</item-type>
+               <label>Power Usage</label>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Power</tag>
+               </tags>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="charging-channel">
+               <item-type>Switch</item-type>
+               <label>Charging</label>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="engine-channel">
+               <item-type>Switch</item-type>
+               <label>Engine State</label>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="tyre-pressure-channel">
+               <item-type>Number:Pressure</item-type>
+               <label>Pressure</label>
+               <category>Pressure</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Pressure</tag>
+               </tags>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="temperature-channel">
+               <item-type>Number:Temperature</item-type>
+               <label>Temperature</label>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="speed-channel">
+               <item-type>Number:Speed</item-type>
+               <label>Speed</label>
+               <description>Vehicle speed</description>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="location-channel">
+               <item-type>Location</item-type>
+               <label>Location</label>
+               <description>The actual position of the vehicle</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="heading-channel">
+               <item-type>Number:Angle</item-type>
+               <label>Heading</label>
+               <description>Indicates the (compass) heading of the car, in 0-360 degrees</description>
+               <state pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="auxiliary-battery-voltage-channel">
+               <item-type>Number:ElectricPotential</item-type>
+               <label>Auxiliary Battery Voltage</label>
+               <description>Voltage (V) of the auxiliary battery</description>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Voltage</tag>
+               </tags>
+               <state pattern="%.1f V" readOnly="true"/>
+       </channel-type>
+       <channel-type id="door-channel">
+               <item-type>Contact</item-type>
+               <label>Door</label>
+               <description>Indicates if the door is opened</description>
+               <category>door</category>
+               <tags>
+                       <tag>OpenState</tag>
+               </tags>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="window-channel">
+               <item-type>Contact</item-type>
+               <label>Window</label>
+               <description>Indicates if the window is opened</description>
+               <category>window</category>
+               <tags>
+                       <tag>OpenState</tag>
+               </tags>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="timestamp-channel">
+               <item-type>DateTime</item-type>
+               <label>Timestamp</label>
+               <description>The time of the event</description>
+               <state readOnly="true" pattern="%1$tF %1$tR"/>
+       </channel-type>
+       <channel-type id="remote-ac-status-channel">
+               <item-type>Number</item-type>
+               <label>Remote A/C</label>
+               <description>Status of remote A/C</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="0">Off</option>
+                               <option value="5">On</option>
+                       </options>
+               </state>
+       </channel-type>
+       <channel-type id="switch-ac-channel">
+               <item-type>Switch</item-type>
+               <label>Switch A/C</label>
+               <description>Control the A/C remotely</description>
+       </channel-type>
+       <channel-type id="force-refresh-channel" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Force Refresh</label>
+               <description>Force an immediate refresh of the car data</description>
+       </channel-type>
+       <channel-type id="alarm-message-channel">
+               <item-type>String</item-type>
+               <label>Vehicle Message</label>
+               <description>Vehicle Message</description>
+       </channel-type>
+</thing:thing-descriptions>
index 9f582c6c11198cfd0b63c69173ab97990869db14..ee0252513256fe03d8a2dfcadf6f5e10f8150a10 100644 (file)
     <module>org.openhab.binding.rotel</module>
     <module>org.openhab.binding.russound</module>
     <module>org.openhab.binding.sagercaster</module>
+    <module>org.openhab.binding.saicismart</module>
     <module>org.openhab.binding.samsungtv</module>
     <module>org.openhab.binding.satel</module>
     <module>org.openhab.binding.semsportal</module>