[evcc] Add plan thing, make repeating plans and one-time vehicle plans configurable (#19822)

* First test version for planner implementation

Signed-off-by: Marcel Goerentz <m.goerentz@t-online.de>
This commit is contained in:
Marcel Goerentz
2026-01-06 22:00:46 +01:00
committed by GitHub
parent 22d66387ad
commit 0a2b47bb5e
35 changed files with 1471 additions and 154 deletions
+214 -6
View File
@@ -17,13 +17,14 @@ This will help to add them to the binding and make them available with the next
## Supported Things
- `server`: A running evcc instance. It will be used as the bridge for the other things
- `server`: A running evcc instance. It will be used as the bridge for the other things.
- `battery`: A battery configured in your evcc instance.
- `heating`: A heating loadpoint configured in your evcc instance.
- `loadpoint`: A loadpoint configured in your evcc instance.
- `pv`: A photovoltaic system configured in your evcc instance.
- `site`: The relevant site data from your evcc instance.
- `vehicle`: A vehicle configured in your evcc instance
- `vehicle`: A vehicle configured in your evcc instance.
- `plan`: A One-time or repeating charging plan linked to a vehicle in your evcc instance.
## Discovery
@@ -42,8 +43,21 @@ Default value for _refreshInterval_ is 30 seconds.
## Thing(s) Configuration
The Things don't have a configuration. They will be set up automatically when a bridge has been configured.
You can add them via file configuration or in the openHAB UI.
Things will be set up automatically when a bridge has been configured and will appear in your inbox.
It is not possible to add things manually in the UI.
Nevertheless, you can add them manually via things file.
All things need to be configured via index, except vehicles, they need to be configured via the database id.
| Thing | Parameter | Type | Description | Advanced | Required |
|---------------------------------------|-----------|--------|-------------------------------------------|----------|----------|
| battery, heating, loadpoint, pv, plan | index | String | Index of this thing in your evcc instance | No | Yes |
| plan, vehicle | vehicleID | String | Database ID of your vehicle | No | Yes |
### Thing `plan`
The `plan` Thing represents a charging plan for a vehicle configured in your evcc instance.
Needs the index of the plan and the database id of the vehicle as configuration parameters.
The index 0 is always the One-time plan, higher indices (1...N) are repeating plans.
## Channels
@@ -68,15 +82,209 @@ Bridge evcc:server:demo-server "Demo" [scheme="https", host="demo.evcc.io", port
Thing loadpoint demo-loadpoint-carport "Loadpoint - evcc Demo Loadpoint 1"[index=0]
..
// You can define as many Vehicle things as you have vehicles configured in your evcc instance
Thing vehicle demo-vehicle1 "Vehicle - evcc Demo Vehicle 1"[id="vehicle_1"]
Thing vehicle demo-vehicle1 "Vehicle - evcc Demo Vehicle 1"[vehicleId="vehicle_1"]
..
// You can define as many Plan things as you have plans for your vehicle configured
Thing plan demo-one-time-plan-for-vehicle1 "One-time plan for vehicle 1"[index=0, vehicleId="vehicle_1"]
Thing plan demo-repeating-plan-1-for-vehicle1 "Repeating plan 1 for vehicle 1"[index=1, vehicleId="vehicle_1"]
..
}
```
### `demo.items` Example
Here you will find items examples sorted by thing.
#### Battery
```java
Number:Energy Evcc_Battery_Capacity "Battery Capacity [%s]" { channel="evcc:battery:demo-server:demo-battery:battery-capacity" }
Switch Evcc_Battery_Controllable "Battery Controllable [%s]" { channel="evcc:battery:demo-server:demo-battery:battery-controllable" }
Number:Power Evcc_Battery_Power "Battery Power [%s]" { channel="evcc:battery:demo-server:demo-battery:battery-power" }
Number:Dimensionless Evcc_Battery_Soc "Battery SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:battery-soc" }
String Evcc_Battery_Title "Title [%s]" { channel="evcc:battery:demo-server:demo-battery:battery-title" }
```
#### Loadpoint and Heating
```java
Number GridPower "Grid Power" { channel="evcc:site:demo-server:demo-site:site-grid-power" }
Number:Temperature Evcc_Loadpoint_Effective_Limit_Temperature "Effective Charging Limit Temperature [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-limit-temperature" }
Number:Temperature Evcc_Loadpoint_Effective_Plan_Temperature "Effective Plan Limit Temperature [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-plan-temperature" }
Number:Temperature Evcc_Loadpoint_Limit_Temperature "Temperature Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-limit-temperature" }
Number:Temperature Evcc_Loadpoint_Vehicle_Limit_Temperature "Device Temperature Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-limit-temperature" }
Number:Temperature Evcc_Loadpoint_Vehicle_Temperature "Device Temperature [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-temperature" }
Switch Evcc_Loadpoint_Battery_Boost "Battery Boost [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-battery-boost" }
Number:Time Evcc_Loadpoint_Charge_Duration "Charging Duration [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-duration" }
Number:Power Evcc_Loadpoint_Charge_Power "Charging Power [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-power" }
Number:Energy Evcc_Loadpoint_Charge_Total_Import "Charge Total Import [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-total-import" }
Number:Time Evcc_Loadpoint_Charge_Remaining_Duration "Charging Remaining Duration [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-remaining-duration" }
Number:Energy Evcc_Loadpoint_Charge_Remaining_Energy "Charging Remaining Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-remaining-energy" }
Number:Energy Evcc_Loadpoint_Charged_Energy "Charged Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charged-energy" }
String Evcc_Loadpoint_Charger_Icon "Charger Icon [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-icon" }
Switch Evcc_Loadpoint_Charger_Feature_Heating "Heating [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-feature-heating" }
Switch Evcc_Loadpoint_Charger_Feature_Integrated_Device "Integrated Device [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-feature-integrated-device" }
Switch Evcc_Loadpoint_Charger_Phases1p3p "Phase Switching [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-phases1p3p" }
Switch Evcc_Loadpoint_Charger_Single_Phase "1 Phase Charging [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-single-phase" }
String Evcc_Loadpoint_Charger_Status_Reason "Charger Status Reason [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charger-status-reason" }
Switch Evcc_Loadpoint_Charging "Charging State [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charging" }
Switch Evcc_Loadpoint_Connected "Vehicle Connected [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-connected" }
Number:Time Evcc_Loadpoint_Connected_Duration "Vehicle Conn. Duration [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-connected-duration" }
Number:Time Evcc_Loadpoint_Disable_Delay "Disable Delay [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-disable-delay" }
Number:Power Evcc_Loadpoint_Disable_Threshold "Disable Threshold [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-disable-threshold" }
Number:Dimensionless Evcc_Loadpoint_Effective_Limit_Soc "Active Charging Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-limit-soc" }
Number:ElectricCurrent Evcc_Loadpoint_Effective_Max_Current "Active Maximum Current [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-max-current" }
Number:ElectricCurrent Evcc_Loadpoint_Effective_Min_Current "Active Minimum Current [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-min-current" }
Number Evcc_Loadpoint_Effective_Plan_Id "Active Plan ID [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-plan-id" }
Number:Dimensionless Evcc_Loadpoint_Effective_Plan_Soc "Active Plan SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-plan-soc" }
DateTime Evcc_Loadpoint_Effective_Plan_Time "Active Plan Time [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-plan-time" }
DateTime Evcc_Loadpoint_Plan_Projected_End "Plan Projected End [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-projected-end" }
DateTime Evcc_Loadpoint_Plan_Projected_Start "Plan Projected Start [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-projected-start" }
Number Evcc_Loadpoint_Effective_Priority "Active Priority [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-effective-priority" }
Number:Time Evcc_Loadpoint_Enable_Delay "Enable Delay [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-enable-delay" }
Number:Power Evcc_Loadpoint_Enable_Threshold "Enable Threshold [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-enable-threshold" }
Switch Evcc_Loadpoint_Enabled "Loadpoint Enabled [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-enabled" }
Number:Energy Evcc_Loadpoint_Limit_Energy "Energy Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-limit-energy" }
Number:Dimensionless Evcc_Loadpoint_Limit_Soc "SoC Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-limit-soc" }
Number:ElectricCurrent Evcc_Loadpoint_Max_Current "Maximum Current [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-max-current" }
Number:ElectricCurrent Evcc_Loadpoint_Min_Current "Minimum Current [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-min-current" }
String Evcc_Loadpoint_Mode "Mode [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-mode" }
Number:ElectricCurrent Evcc_Loadpoint_Offered_Current "Offered Current [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-offered-current" }
String Evcc_Loadpoint_Phase_Action "Phase Scaling [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-phase-action" }
Number:Time Evcc_Loadpoint_Phase_Remaining "Phase Remaining Timer [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-phase-remaining" }
Number:ElectricCurrent Evcc_Loadpoint_Charge_Current_L1 "Charge Current 1 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-current-l1" }
Number:ElectricCurrent Evcc_Loadpoint_Charge_Current_L2 "Charge Current 2 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-current-l2" }
Number:ElectricCurrent Evcc_Loadpoint_Charge_Current_L3 "Charge Current 3 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-current-l3" }
Number:ElectricPotential Evcc_Loadpoint_Charge_Voltage_L1 "Charge Voltage 1 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-voltage-l1" }
Number:ElectricPotential Evcc_Loadpoint_Charge_Voltage_L2 "Charge Voltage 2 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-voltage-l2" }
Number:ElectricPotential Evcc_Loadpoint_Charge_Voltage_L3 "Charge Voltage 3 [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-charge-voltage-l3" }
Number Evcc_Loadpoint_Phases_Active "Active Phases [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-phases-active" }
Number Evcc_Loadpoint_Phases_Configured "Configured Phases [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-phases-configured" }
Switch Evcc_Loadpoint_Plan_Active "Plan Activated [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-active" }
Number:Energy Evcc_Loadpoint_Plan_Energy "Plan Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-energy" }
Number:Time Evcc_Loadpoint_Plan_Overrun "Plan Overrun [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-overrun" }
Number:Time Evcc_Loadpoint_Plan_Precondition "Plan Precondition [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-plan-precondition" }
Number Evcc_Loadpoint_Priority "Priority [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-priority" }
String Evcc_Loadpoint_Pv_Action "PV Action [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-pv-action" }
Number:Time Evcc_Loadpoint_Pv_Remaining "Pv Remaining [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-pv-remaining" }
Number:EmissionIntensity Evcc_Loadpoint_Session_Co2_Per_K_Wh "Session CO2 Per kWh [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-session-co2-per-k-wh" }
Number:Energy Evcc_Loadpoint_Session_Energy "Session Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-session-energy" }
Number:Dimensionless Evcc_Loadpoint_Session_Solar_Percentage "Session Solar Percentage [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-session-solar-percentage" }
Number:Currency Evcc_Loadpoint_Session_Price "Session Price [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-session-price" }
Number:EnergyPrice Evcc_Loadpoint_Session_Price_Per_K_Wh "Session Price Per kWh [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-session-price-per-k-wh" }
Switch Evcc_Loadpoint_Smart_Cost_Active "Smart Cost Active [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-smart-cost-active" }
Number:EmissionIntensity Evcc_Loadpoint_Smart_Cost_Limit_Co2 "Smart Cost Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-smart-cost-limit-co2" }
Number:EnergyPrice Evcc_Loadpoint_Smart_Cost_Limit_Price "Smart Cost Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-smart-cost-limit-price" }
DateTime Evcc_Loadpoint_Smart_Cost_Next_Start "Smart Cost Next Start [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-smart-cost-next-start" }
Switch Evcc_Loadpoint_Smart_Feed_In_Priority_Active "Smart Feed In Priority Active [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-smart-feed-in-priority-active" }
String Evcc_Loadpoint_Title "Title [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-title" }
Switch Evcc_Loadpoint_Vehicle_Climater_Active "Vehicle Climate Active [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-climater-active" }
Switch Evcc_Loadpoint_Vehicle_Detection_Active "Vehicle Detection Active [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-detection-active" }
String Evcc_Loadpoint_Vehicle_Identity "Vehicle Identity [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-identity" }
Number:Dimensionless Evcc_Loadpoint_Vehicle_Limit_Soc "Vehicle API SoC Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-limit-soc" }
String Evcc_Loadpoint_Vehicle_Name "Vehicle Name [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-name" }
Number:Length Evcc_Loadpoint_Vehicle_Odometer "Vehicle Odometer [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-odometer" }
Number:Length Evcc_Loadpoint_Vehicle_Range "Vehicle Range [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-range" }
Number:Dimensionless Evcc_Loadpoint_Vehicle_Soc "Vehicle API SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-soc" }
String Evcc_Loadpoint_Vehicle_Title "Vehicle Title [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-title" }
Switch Evcc_Loadpoint_Vehicle_Welcome_Active "Vehicle Welcome Active [%s]" { channel="evcc:battery:demo-server:demo-battery:loadpoint-vehicle-welcome-active" }
```
#### Plan
Channels plan-weekdays and plan-tz are only available for repeating plans.
```java
Number:Dimensionless Evcc_Plan_Soc "Plan SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-soc" }
Number:Time Evcc_Plan_Precondition "Precondition Time [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-precondition" }
DateTime Evcc_Plan_Time "Plan Time [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-time" }
String Evcc_Plan_Weekdays "Plan Weekdays [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-weekdays" }
String Evcc_Plan_Tz "Plan Timezone [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-tz" }
Switch Evcc_Plan_Active "Active [%s]" { channel="evcc:battery:demo-server:demo-battery:plan-active" }
```
#### PV
```java
Number:Power Evcc_Pv_Power "PV Power [%s]" { channel="evcc:battery:demo-server:demo-battery:pv-power" }
String Evcc_Pv_Title "Title [%s]" { channel="evcc:battery:demo-server:demo-battery:pv-title" }
```
#### Site
```java
String Evcc_Site_Available_Version "Available Version [%s]" { channel="evcc:battery:demo-server:demo-battery:site-available-version" }
Number:Energy Evcc_Site_Battery_Capacity "Site Battery Capacity [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-capacity" }
Switch Evcc_Site_Battery_Discharge_Control "Battery Discharge Control [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-discharge-control" }
Number:Energy Evcc_Site_Battery_Energy "Battery Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-energy" }
Switch Evcc_Site_Battery_Grid_Charge_Active "Grid Charging Active [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-grid-charge-active" }
Number:EmissionIntensity Evcc_Site_Battery_Grid_Charge_Limit_Co2 "Grid Charging Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-grid-charge-limit-co2" }
Number:EnergyPrice Evcc_Site_Battery_Grid_Charge_Limit_Price "Grid Charging Limit [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-grid-charge-limit-price" }
String Evcc_Site_Battery_Mode "Battery Mode [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-mode" }
String Evcc_Site_Battery_Mode_External "Battery Mode [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-mode-external" }
Number:Power Evcc_Site_Battery_Power "Battery Power [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-power" }
Number:Dimensionless Evcc_Site_Battery_Soc "Battery SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:site-battery-soc" }
Number:Dimensionless Evcc_Site_Buffer_Soc "Battery Buffer SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:site-buffer-soc" }
Number:Dimensionless Evcc_Site_Buffer_Start_Soc "Battery Buffer Start SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:site-buffer-start-soc" }
String Evcc_Site_Currency "Currency [%s]" { channel="evcc:battery:demo-server:demo-battery:site-currency" }
Switch Evcc_Site_Demo_Mode "Demo Mode [%s]" { channel="evcc:battery:demo-server:demo-battery:site-demo-mode" }
Switch Evcc_Site_Eebus "EEBUS [%s]" { channel="evcc:battery:demo-server:demo-battery:site-eebus" }
Number Evcc_Site_Green_Share_Home "Green Share Home [%s]" { channel="evcc:battery:demo-server:demo-battery:site-green-share-home" }
Number Evcc_Site_Green_Share_Loadpoints "Green Share Loadpoints [%s]" { channel="evcc:battery:demo-server:demo-battery:site-green-share-loadpoints" }
Number:ElectricCurrent Evcc_Site_Grid_Current_L1 "Grid Current 1 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-current-l1" }
Number:ElectricCurrent Evcc_Site_Grid_Current_L2 "Grid Current 2 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-current-l2" }
Number:ElectricCurrent Evcc_Site_Grid_Current_L3 "Grid Current 3 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-current-l3" }
Number:ElectricPotential Evcc_Site_Grid_Voltage_L1 "Charge Voltage 1 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-voltage-l1" }
Number:ElectricPotential Evcc_Site_Grid_Voltage_L2 "Charge Voltage 2 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-voltage-l2" }
Number:ElectricPotential Evcc_Site_Grid_Voltage_L3 "Charge Voltage 3 [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-voltage-l3" }
Number:Energy Evcc_Site_Grid_Energy "Grid Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-energy" }
Number:Power Evcc_Site_Grid_Power "Grid Power [%s]" { channel="evcc:battery:demo-server:demo-battery:site-grid-power" }
Number:Power Evcc_Site_Home_Power "Home Power [%s]" { channel="evcc:battery:demo-server:demo-battery:site-home-power" }
Switch Evcc_Site_Messaging "Site Messaging [%s]" { channel="evcc:battery:demo-server:demo-battery:site-messaging" }
Number:Dimensionless Evcc_Site_Priority_Soc "Battery Priority SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:site-priority-soc" }
Number:Time Evcc_Site_Interval "Interval [%s]" { channel="evcc:battery:demo-server:demo-battery:site-interval" }
Number:Energy Evcc_Site_Pv_Energy "PV Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:site-pv-energy" }
Number:Power Evcc_Site_Pv_Power "PV Power [%s]" { channel="evcc:battery:demo-server:demo-battery:site-pv-power" }
Number:Power Evcc_Site_Residual_Power "Grid Residual Power [%s]" { channel="evcc:battery:demo-server:demo-battery:site-residual-power" }
Switch Evcc_Site_Smart_Cost_Available "Smart Cost Available [%s]" { channel="evcc:battery:demo-server:demo-battery:site-smart-cost-available" }
String Evcc_Site_Smart_Cost_Type "Smart Cost Type [%s]" { channel="evcc:battery:demo-server:demo-battery:site-smart-cost-type" }
Switch Evcc_Site_Smart_Feed_In_Priority_Available "Smart Feed In Priority Available [%s]" { channel="evcc:battery:demo-server:demo-battery:site-smart-feed-in-priority-available" }
Switch Evcc_Site_Startup_Completed "Start Up Complete [%s]" { channel="evcc:battery:demo-server:demo-battery:site-startup-completed" }
Number:EmissionIntensity Evcc_Site_Tariff_Co2 "CO2 Concentration [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-co2" }
Number:EmissionIntensity Evcc_Site_Tariff_Co2_Home "CO2 Concentration Home [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-co2-home" }
Number:EmissionIntensity Evcc_Site_Tariff_Co2_Loadpoints "CO2 Concentration Loadpoints [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-co2-loadpoints" }
Number:EnergyPrice Evcc_Site_Tariff_Feed_In "Feed in price per kWh [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-feed-in" }
Number:EnergyPrice Evcc_Site_Tariff_Grid "Grid Price [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-grid" }
Number:EnergyPrice Evcc_Site_Tariff_Price_Home "Home Price [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-price-home" }
Number:EnergyPrice Evcc_Site_Tariff_Price_Loadpoints "Loadpoint price per kWh [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-price-loadpoints" }
Number:Power Evcc_Site_Tariff_Solar "Solar Tariff [%s]" { channel="evcc:battery:demo-server:demo-battery:site-tariff-solar" }
String Evcc_Site_Site_Title "Site Title [%s]" { channel="evcc:battery:demo-server:demo-battery:site-site-title" }
String Evcc_Site_Version "Version [%s]" { channel="evcc:battery:demo-server:demo-battery:site-version" }
```
#### Statistics
Here you need to add the channel group: 30d#,365d#,this-year#,total# for placeholder <group>
```java
Number:EmissionIntensity Evcc_Avg_Co2_Type "Average CO2 [%s]" { channel="evcc:battery:demo-server:demo-battery:<group>avg-co2-type" }
Number:EnergyPrice Evcc_Avg_Price_Type "Average Price [%s]" { channel="evcc:battery:demo-server:demo-battery:<group>avg-price-type" }
Number:Energy Evcc_Charged_Energy_Type "Charged Energy [%s]" { channel="evcc:battery:demo-server:demo-battery:<group>charged-energy-type" }
Number:Dimensionless Evcc_Solar_Percentage_Type "Solar Percentage [%s]" { channel="evcc:battery:demo-server:demo-battery:<group>solar-percentage-type" }
```
#### Vehicle
```java
Number:Energy Evcc_Vehicle_Capacity "Vehicle Capacity [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-capacity" }
String Evcc_Vehicle_Icon "Vehicle Icon [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-icon" }
Number:Dimensionless Evcc_Vehicle_Limit_Soc "Vehicle Limit SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-limit-soc" }
Number:Dimensionless Evcc_Vehicle_Min_Soc "Vehicle Min SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-min-soc" }
Number:ElectricCurrent Evcc_Vehicle_Max_Current "Vehicle Max Current [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-max-current" }
Number:ElectricCurrent Evcc_Vehicle_Min_Current "Vehicle Min Current [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-min-current" }
Number:Time Evcc_Vehicle_Plan_Precondition "Plan Precondition Duration [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-plan-precondition" }
Number:Dimensionless Evcc_Vehicle_Plan_Soc "Planned SoC [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-plan-soc" }
DateTime Evcc_Vehicle_Plan_Time "Plan Datetime [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-plan-time" }
String Evcc_Vehicle_Title "Vehicle Title [%s]" { channel="evcc:battery:demo-server:demo-battery:vehicle-title" }
```
## Troubleshooting
@@ -30,35 +30,45 @@ public class EvccBindingConstants {
public static final String BINDING_ID = "evcc";
public static final ThingTypeUID THING_TYPE_SERVER = new ThingTypeUID(BINDING_ID, "server");
public static final ThingTypeUID THING_TYPE_SITE = new ThingTypeUID(BINDING_ID, "site");
public static final ThingTypeUID THING_TYPE_LOADPOINT = new ThingTypeUID(BINDING_ID, "loadpoint");
public static final ThingTypeUID THING_TYPE_VEHICLE = new ThingTypeUID(BINDING_ID, "vehicle");
public static final ThingTypeUID THING_TYPE_PV = new ThingTypeUID(BINDING_ID, "pv");
public static final String CHANNEL_PLAN_SOC = "plan-soc";
public static final String CHANNEL_PLAN_PRECONDITION = "plan-precondition";
public static final ThingTypeUID THING_TYPE_BATTERY = new ThingTypeUID(BINDING_ID, "battery");
public static final ThingTypeUID THING_TYPE_HEATING = new ThingTypeUID(BINDING_ID, "heating");
public static final ThingTypeUID THING_TYPE_LOADPOINT = new ThingTypeUID(BINDING_ID, "loadpoint");
public static final ThingTypeUID THING_TYPE_PLAN = new ThingTypeUID(BINDING_ID, "plan");
public static final ThingTypeUID THING_TYPE_PV = new ThingTypeUID(BINDING_ID, "pv");
public static final ThingTypeUID THING_TYPE_SERVER = new ThingTypeUID(BINDING_ID, "server");
public static final ThingTypeUID THING_TYPE_SITE = new ThingTypeUID(BINDING_ID, "site");
public static final ThingTypeUID THING_TYPE_STATISTICS = new ThingTypeUID(BINDING_ID, "statistics");
public static final ThingTypeUID THING_TYPE_VEHICLE = new ThingTypeUID(BINDING_ID, "vehicle");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_SITE, THING_TYPE_VEHICLE,
THING_TYPE_LOADPOINT, THING_TYPE_BATTERY, THING_TYPE_PV, THING_TYPE_HEATING, THING_TYPE_STATISTICS);
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_BATTERY, THING_TYPE_HEATING,
THING_TYPE_LOADPOINT, THING_TYPE_PLAN, THING_TYPE_PV, THING_TYPE_SITE, THING_TYPE_STATISTICS,
THING_TYPE_VEHICLE);
public static final String PROPERTY_ID = "id";
public static final String PROPERTY_INDEX = "index";
public static final String PROPERTY_TYPE = "type";
public static final String PROPERTY_TITLE = "title";
public static final String PROPERTY_SITE_TITLE = "siteTitle";
public static final String PROPERTY_VEHICLE_ID = "vehicleId";
public static final String API_PATH_LOADPOINTS = "/loadpoints";
public static final String API_PATH_VEHICLES = "/vehicles";
public static final String API_PATH_LOADPOINTS = "loadpoints";
public static final String API_PATH_VEHICLES = "vehicles";
public static final String API_PATH_PLAN_SOC = "plan/soc";
public static final String API_PATH_PLAN_REPEATING = "plan/repeating";
public static final String PROPERTY_TYPE_BATTERY = "battery";
public static final String PROPERTY_TYPE_HEATING = "heating";
public static final String PROPERTY_TYPE_LOADPOINT = "loadpoint";
public static final String PROPERTY_TYPE_PLAN = "plan";
public static final String PROPERTY_TYPE_PV = "pv";
public static final String PROPERTY_TYPE_SITE = "site";
public static final String PROPERTY_TYPE_STATISTICS = "statistics";
public static final String PROPERTY_TYPE_VEHICLE = "vehicle";
public static final String JSON_KEY_ACTIVE = "active";
public static final String JSON_KEY_BATTERY = "battery";
public static final String JSON_KEY_CHARGE_CURRENT = "chargeCurrent";
public static final String JSON_KEY_CHARGE_CURRENTS = "chargeCurrents";
@@ -76,14 +86,21 @@ public class EvccBindingConstants {
public static final String JSON_KEY_OFFERED_CURRENT = "offeredCurrent";
public static final String JSON_KEY_PHASES = "phases";
public static final String JSON_KEY_PHASES_CONFIGURED = "phasesConfigured";
public static final String JSON_KEY_PLAN = "plan";
public static final String JSON_KEY_PRECONDITION = "precondition";
public static final String JSON_KEY_PV = "pv";
public static final String JSON_KEY_REPEATING_PLANS = "repeatingPlans";
public static final String JSON_KEY_SMART_COST_TYPE = "smartCostType";
public static final String JSON_KEY_SOC = "soc";
public static final String JSON_KEY_STATISTICS = "statistics";
public static final String JSON_KEY_TIME = "time";
public static final String JSON_KEY_TZ = "tz";
public static final String JSON_KEY_TITLE = "title";
public static final String JSON_KEY_VEHICLE_LIMIT_SOC = "vehicleLimitSoc";
public static final String JSON_KEY_VEHICLE_PRESENT = "vehiclePresent";
public static final String JSON_KEY_VEHICLE_SOC = "vehicleSoc";
public static final String JSON_KEY_VEHICLES = "vehicles";
public static final String JSON_KEY_WEEKDAYS = "weekdays";
public static final String NUMBER_CURRENCY = CoreItemFactory.NUMBER + ":Currency";
public static final String NUMBER_DIMENSIONLESS = CoreItemFactory.NUMBER + ":Dimensionless";
@@ -95,4 +112,7 @@ public class EvccBindingConstants {
public static final String NUMBER_LENGTH = CoreItemFactory.NUMBER + ":Length";
public static final String NUMBER_ENERGY_PRICE = CoreItemFactory.NUMBER + ":EnergyPrice";
public static final String NUMBER_TEMPERATURE = CoreItemFactory.NUMBER + ":Temperature";
public static final String POST = "POST";
public static final String DELETE = "DELETE";
}
@@ -20,11 +20,13 @@ import org.openhab.binding.evcc.internal.handler.EvccBatteryHandler;
import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.binding.evcc.internal.handler.EvccHeatingHandler;
import org.openhab.binding.evcc.internal.handler.EvccLoadpointHandler;
import org.openhab.binding.evcc.internal.handler.EvccPlanHandler;
import org.openhab.binding.evcc.internal.handler.EvccPvHandler;
import org.openhab.binding.evcc.internal.handler.EvccSiteHandler;
import org.openhab.binding.evcc.internal.handler.EvccStatisticsHandler;
import org.openhab.binding.evcc.internal.handler.EvccVehicleHandler;
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;
@@ -53,15 +55,17 @@ public class EvccHandlerFactory extends BaseThingHandlerFactory {
private final ChannelTypeRegistry channelTypeRegistry;
private final TranslationProvider i18nProvider;
private final LocaleProvider localeProvider;
private final TimeZoneProvider timeZoneProvider;
@Activate
public EvccHandlerFactory(@Reference HttpClientFactory httpClientFactory,
@Reference ChannelTypeRegistry channelTypeRegistry, @Reference TranslationProvider i18nProvider,
@Reference LocaleProvider localeProvider) {
@Reference LocaleProvider localeProvider, @Reference TimeZoneProvider timeZoneProvider) {
this.httpClientFactory = httpClientFactory;
this.channelTypeRegistry = channelTypeRegistry;
this.i18nProvider = i18nProvider;
this.localeProvider = localeProvider;
this.timeZoneProvider = timeZoneProvider;
}
@Override
@@ -90,6 +94,8 @@ public class EvccHandlerFactory extends BaseThingHandlerFactory {
handler = new EvccPvHandler(thing, channelTypeRegistry);
} else if (THING_TYPE_STATISTICS.equals(type)) {
handler = new EvccStatisticsHandler(thing, channelTypeRegistry);
} else if (THING_TYPE_PLAN.equals(type)) {
handler = new EvccPlanHandler(thing, channelTypeRegistry, timeZoneProvider.getTimeZone());
}
return handler;
}
@@ -22,17 +22,13 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.evcc.internal.discovery.mapper.BatteryDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.EvccDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.LoadpointDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.PvDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.SiteDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.StatisticsDiscoveryMapper;
import org.openhab.binding.evcc.internal.discovery.mapper.VehicleDiscoveryMapper;
import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.thing.ThingTypeUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ServiceScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,14 +50,14 @@ public class EvccDiscoveryService extends AbstractThingHandlerDiscoveryService<E
private final Logger logger = LoggerFactory.getLogger(getClass());
private final List<EvccDiscoveryMapper> mappers = List.of(new LoadpointDiscoveryMapper(),
new VehicleDiscoveryMapper(), new BatteryDiscoveryMapper(), new SiteDiscoveryMapper(),
new PvDiscoveryMapper(), new StatisticsDiscoveryMapper());
private final List<EvccDiscoveryMapper> mappers;
private @Nullable ScheduledFuture<?> evccDiscoveryJob;
public EvccDiscoveryService() {
@Activate
public EvccDiscoveryService(@Reference List<EvccDiscoveryMapper> mappers) {
super(EvccBridgeHandler.class, SUPPORTED_THING_TYPES, TIMEOUT, true);
this.mappers = mappers;
}
@Override
@@ -13,6 +13,7 @@
package org.openhab.binding.evcc.internal.discovery;
import java.text.Normalizer;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -36,6 +37,6 @@ public class Utils {
result = Normalizer.normalize(name, Normalizer.Form.NFKD);
result = result.replaceAll("\\p{M}", "");
}
return result.replaceAll("[^a-zA-Z0-9_]", "-").toLowerCase();
return result.replaceAll("[^a-zA-Z0-9_]", "-").toLowerCase(Locale.ROOT);
}
}
@@ -26,6 +26,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
@@ -35,6 +36,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class BatteryDiscoveryMapper implements EvccDiscoveryMapper {
@@ -28,6 +28,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
@@ -37,6 +38,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class LoadpointDiscoveryMapper implements EvccDiscoveryMapper {
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2010-2026 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.evcc.internal.discovery.mapper;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.evcc.internal.EvccBindingConstants;
import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.util.HexUtils;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* The {@link PlanDiscoveryMapper} is responsible for mapping the discovered plans from a vehicle to discovery results
*
* @author Marcel Goerentz - Initial contribution
*/
@NonNullByDefault
@Component(service = EvccDiscoveryMapper.class)
public class PlanDiscoveryMapper implements EvccDiscoveryMapper {
private final Logger logger = LoggerFactory.getLogger(PlanDiscoveryMapper.class);
private final BundleContext ctx;
private final TranslationProvider tp;
private final LocaleProvider lp;
@Activate
public PlanDiscoveryMapper(BundleContext ctx, @Reference TranslationProvider tp, @Reference LocaleProvider lp) {
this.ctx = ctx;
this.tp = tp;
this.lp = lp;
}
@Override
public Collection<DiscoveryResult> discover(JsonObject state, EvccBridgeHandler bridgeHandler) {
List<DiscoveryResult> results = new ArrayList<>();
JsonObject vehicles = state.getAsJsonObject(JSON_KEY_VEHICLES);
if (vehicles == null) {
return results;
}
for (Map.Entry<String, JsonElement> entry : vehicles.entrySet()) {
JsonObject v = entry.getValue().getAsJsonObject();
String id = entry.getKey();
String title = v.has(JSON_KEY_TITLE) ? v.get(JSON_KEY_TITLE).getAsString() : id;
if (v.has(JSON_KEY_PLAN) || v.has(JSON_KEY_REPEATING_PLANS)) {
try {
results.addAll(discoverFromVehicle(v, id, title, bridgeHandler));
} catch (NoSuchAlgorithmException e) {
// should not happen
logger.warn("Could not get hash algorithm instance");
}
}
}
return results;
}
public Collection<DiscoveryResult> discoverFromVehicle(JsonObject vehicle, String id, String title,
EvccBridgeHandler bridgeHandler) throws NoSuchAlgorithmException {
List<DiscoveryResult> results = new ArrayList<>();
JsonObject plan = vehicle.getAsJsonObject(JSON_KEY_PLAN);
if (plan != null) {
String localizedLabel = tp.getText(ctx.getBundle(), "discovery.evcc.plan.one-time.label",
"One-time charging plan for {0}", lp.getLocale(), title);
String label = localizedLabel == null ? "One-time charging plan for " + title : localizedLabel;
results.add(createPlanDiscoveryResult(label, createIdString(id, 0), 0, id, bridgeHandler));
}
if (vehicle.has(JSON_KEY_REPEATING_PLANS) && vehicle.get(JSON_KEY_REPEATING_PLANS).isJsonArray()) {
for (int index = 1; index <= vehicle.get(JSON_KEY_REPEATING_PLANS).getAsJsonArray().size(); index++) {
String localizedLabel = tp.getText(ctx.getBundle(), "discovery.evcc.plan.repeating.label",
"Repeating plan {0} for {1}", lp.getLocale(), index, title);
String label = localizedLabel == null ? "Repeating plan " + index + " for " + title : localizedLabel;
results.add(createPlanDiscoveryResult(label, createIdString(id, index), index, id, bridgeHandler));
}
}
return results;
}
private DiscoveryResult createPlanDiscoveryResult(String label, String planID, int index, String vehicleID,
EvccBridgeHandler bridgeHandler) {
ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_PLAN, bridgeHandler.getThing().getUID(), planID);
return DiscoveryResultBuilder.create(uid).withLabel(label).withBridge(bridgeHandler.getThing().getUID())
.withProperty(PROPERTY_ID, planID).withProperty(PROPERTY_VEHICLE_ID, vehicleID)
.withProperty(PROPERTY_INDEX, String.valueOf(index)).withRepresentationProperty(PROPERTY_ID).build();
}
private String createIdString(String id, int index) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest((id + "Plan" + index).getBytes(StandardCharsets.UTF_8));
// Use first 10 hex chars of the SHA to generate a stable, compact plan ID
return HexUtils.bytesToHex(digest).substring(0, 10);
}
}
@@ -26,6 +26,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
@@ -35,6 +36,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class PvDiscoveryMapper implements EvccDiscoveryMapper {
@@ -24,6 +24,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonObject;
@@ -32,6 +33,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class SiteDiscoveryMapper implements EvccDiscoveryMapper {
@@ -24,6 +24,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonObject;
@@ -32,6 +33,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class StatisticsDiscoveryMapper implements EvccDiscoveryMapper {
@@ -26,6 +26,7 @@ import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
@@ -35,6 +36,7 @@ import com.google.gson.JsonObject;
*
* @author Marcel Goerentz - Initial contribution
*/
@Component(service = EvccDiscoveryMapper.class)
@NonNullByDefault
public class VehicleDiscoveryMapper implements EvccDiscoveryMapper {
@@ -53,12 +55,10 @@ public class VehicleDiscoveryMapper implements EvccDiscoveryMapper {
ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_VEHICLE, bridgeHandler.getThing().getUID(),
Utils.sanitizeName(title));
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(title)
.withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_ID, id)
.withRepresentationProperty(PROPERTY_ID).build();
.withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_VEHICLE_ID, id)
.withRepresentationProperty(PROPERTY_VEHICLE_ID).build();
results.add(result);
}
return results;
}
}
@@ -16,6 +16,7 @@ import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import static org.openhab.core.util.StringUtils.capitalize;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -29,14 +30,10 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.Bridge;
@@ -118,6 +115,21 @@ public abstract class EvccBaseThingHandler extends BaseThingHandler implements E
() -> logger.error("No bridgeHandler present when initializing the thing"));
}
protected String getPropertyOrConfigValue(String propertyName) {
Object value = thing.getConfiguration().get(propertyName);
if (value instanceof String s) {
return s;
} else if (value instanceof BigDecimal bd) {
return bd.toString();
} else {
return switch (propertyName) {
case PROPERTY_INDEX -> thing.getProperties().getOrDefault(propertyName, "0");
case PROPERTY_VEHICLE_ID -> thing.getProperties().getOrDefault(propertyName, "");
default -> "";
};
}
}
@Override
public void initialize() {
updateStatus(ThingStatus.UNKNOWN);
@@ -245,37 +257,37 @@ public abstract class EvccBaseThingHandler extends BaseThingHandler implements E
});
}
protected boolean sendCommand(String url) {
AtomicBoolean successful = new AtomicBoolean(false);
protected void performApiRequest(String url, String method, JsonElement payload) {
Optional.ofNullable(bridgeHandler).ifPresent(handler -> {
HttpClient httpClient = handler.getHttpClient();
try {
ContentResponse response = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS)
.method(HttpMethod.POST).header(HttpHeader.ACCEPT, "application/json").send();
if (response.getStatus() == 200) {
logger.debug("Sending command was successful");
successful.set(true);
} else {
@Nullable
JsonObject responseJson = gson.fromJson(response.getContentAsString(), JsonObject.class);
Optional.ofNullable(responseJson).ifPresent(json -> {
if (json.has("error")) {
logger.debug("Sending command was unsuccessful, got this error:\n {}",
json.get("error").getAsString());
updateStatus(getThing().getStatus(), ThingStatusDetail.COMMUNICATION_ERROR,
json.get("error").getAsString());
} else {
updateStatus(getThing().getStatus(), ThingStatusDetail.COMMUNICATION_ERROR);
logger.warn("evcc API error: HTTP {}", response.getStatus());
}
});
}
} catch (Exception e) {
logger.warn("evcc bridge couldn't call the API", e);
}
HttpMethod httpMethod = HttpMethod.valueOf(method);
handler.enqueueRequest(url, httpMethod, payload, this::checkResponse, error -> {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
});
});
return successful.get();
}
private void checkResponse(ContentResponse response) {
if (response.getStatus() == 200) {
logger.debug("Sending command was successful");
} else {
try {
@Nullable
JsonObject responseJson = gson.fromJson(response.getContentAsString(), JsonObject.class);
Optional.ofNullable(responseJson).ifPresent(json -> {
if (json.has("error")) {
logger.debug("Sending command was unsuccessful, got this error:\n {}",
json.get("error").getAsString());
updateStatus(getThing().getStatus(), ThingStatusDetail.COMMUNICATION_ERROR,
json.get("error").getAsString());
} else {
updateStatus(getThing().getStatus(), ThingStatusDetail.COMMUNICATION_ERROR);
logger.warn("evcc API error: HTTP {}", response.getStatus());
}
});
} catch (Exception e) {
logger.warn("evcc bridge couldn't parse the API error response", e);
}
}
}
private ChannelUID channelUID(String id) {
@@ -14,7 +14,6 @@ package org.openhab.binding.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.math.BigDecimal;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -37,14 +36,7 @@ public class EvccBatteryHandler extends EvccBaseThingHandler {
public EvccBatteryHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) {
super(thing, channelTypeRegistry);
Object index = thing.getConfiguration().get(PROPERTY_INDEX);
String indexString;
if (index instanceof BigDecimal s) {
indexString = s.toString();
} else {
indexString = thing.getProperties().getOrDefault(PROPERTY_INDEX, "0");
}
this.index = Integer.parseInt(indexString);
this.index = Integer.parseInt(getPropertyOrConfigValue(PROPERTY_INDEX));
type = PROPERTY_TYPE_BATTERY;
}
@@ -14,16 +14,23 @@ package org.openhab.binding.evcc.internal.handler;
import java.util.Collection;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.evcc.internal.EvccBridgeConfiguration;
import org.openhab.binding.evcc.internal.discovery.EvccDiscoveryService;
import org.openhab.core.i18n.LocaleProvider;
@@ -41,6 +48,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
@@ -54,6 +62,9 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
private final Logger logger = LoggerFactory.getLogger(EvccBridgeHandler.class);
private final Gson gson = new Gson();
private final Queue<QueuedRequest> requestQueue = new ConcurrentLinkedQueue<>();
private @Nullable ScheduledFuture<?> queueWorker;
private final AtomicBoolean isPulling = new AtomicBoolean(false);
private final HttpClient httpClient;
private final TranslationProvider i18nProvider;
@@ -82,6 +93,7 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
endpoint = config.scheme + "://" + config.host + ":" + config.port + "/api/state";
startPolling(config.pollInterval);
startQueueWorker();
fetchEvccState().ifPresent(state -> {
this.lastState = state;
@@ -92,11 +104,11 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
@Override
public void dispose() {
Optional.ofNullable(pollJob).ifPresent(job -> job.cancel(true));
pollJob = null;
Optional.ofNullable(queueWorker).ifPresent(job -> job.cancel(true));
queueWorker = null;
listeners.clear();
}
public HttpClient getHttpClient() {
return httpClient;
requestQueue.clear();
}
public String getBaseURL() {
@@ -123,7 +135,65 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
}), refreshInterval, refreshInterval, TimeUnit.SECONDS);
}
private void startQueueWorker() {
queueWorker = scheduler.scheduleWithFixedDelay(() -> {
try {
processQueue();
} catch (Exception e) {
logger.warn("Error processing evcc request queue", e);
}
}, 50, 50, TimeUnit.MILLISECONDS);
}
private void processQueue() {
if (isPulling.get() || requestQueue.isEmpty()
|| !ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
return; // Pause während Polling
}
QueuedRequest queued = requestQueue.poll();
if (queued == null) {
return;
}
try {
ContentResponse response = queued.request().send();
try {
queued.onSuccess().accept(response);
} catch (Exception callbackError) {
logger.warn("Error in onSuccess callback: ", callbackError);
}
} catch (Exception e) {
try {
queued.onError().accept(e);
} catch (Exception callbackError) {
logger.warn("Error in onError callback: ", callbackError);
}
}
}
public void enqueueRequest(String url, HttpMethod method, JsonElement payload, Consumer<ContentResponse> onSuccess,
Consumer<Exception> onError) {
if (url.isBlank()) {
onError.accept(new IllegalArgumentException("URL must not be empty"));
return;
}
try {
Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(method)
.header(HttpHeader.ACCEPT, "application/json");
if (!payload.isJsonNull()) {
request.content(new StringContentProvider(payload.toString())).header(HttpHeader.CONTENT_TYPE,
"application/json");
}
requestQueue.add(new QueuedRequest(request, onSuccess, onError));
} catch (Exception e) {
logger.warn("evcc bridge couldn't call the API", e);
onError.accept(e);
}
}
public Optional<JsonObject> fetchEvccState() {
isPulling.set(true);
try {
ContentResponse response = httpClient.newRequest(endpoint).timeout(5, TimeUnit.SECONDS)
.header(HttpHeader.ACCEPT, "application/json").send();
@@ -142,8 +212,9 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
}
} catch (Exception e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getLocalizedMessage());
} finally {
isPulling.set(false);
}
return Optional.empty();
}
@@ -162,7 +233,7 @@ public class EvccBridgeHandler extends BaseBridgeHandler {
}
public JsonObject getCachedEvccState() {
return lastState;
return lastState.deepCopy();
}
public void register(EvccThingLifecycleAware handler) {
@@ -46,6 +46,7 @@ public class EvccHeatingHandler extends EvccLoadpointHandler {
public EvccHeatingHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) {
super(thing, channelTypeRegistry);
type = PROPERTY_TYPE_HEATING;
}
@Override
@@ -78,7 +79,7 @@ public class EvccHeatingHandler extends EvccLoadpointHandler {
protected void updateJSON(JsonObject state) {
JsonObject heatingState = state.getAsJsonArray(JSON_KEY_LOADPOINTS).get(index).getAsJsonObject();
renameJsonKeys(heatingState); // rename the json keys
renameJsonKeys(heatingState); // rename the JSON keys
state.getAsJsonArray(JSON_KEY_LOADPOINTS).set(index, heatingState); // Update the keys in the original JSON
}
@@ -14,7 +14,7 @@ package org.openhab.binding.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -32,6 +32,7 @@ import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
/**
@@ -54,14 +55,7 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler {
public EvccLoadpointHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) {
super(thing, channelTypeRegistry);
Object index = thing.getConfiguration().get(PROPERTY_INDEX);
String indexString;
if (index instanceof BigDecimal s) {
indexString = s.toString();
} else {
indexString = thing.getProperties().getOrDefault(PROPERTY_INDEX, "0");
}
this.index = Integer.parseInt(indexString);
this.index = Integer.parseInt(getPropertyOrConfigValue(PROPERTY_INDEX));
type = PROPERTY_TYPE_LOADPOINT;
}
@@ -69,7 +63,7 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler {
public void initialize() {
super.initialize();
Optional.ofNullable(bridgeHandler).ifPresent(handler -> {
endpoint = handler.getBaseURL() + API_PATH_LOADPOINTS + "/" + (index + 1);
endpoint = String.join("/", handler.getBaseURL(), API_PATH_LOADPOINTS, String.valueOf(index + 1));
JsonObject stateOpt = handler.getCachedEvccState().deepCopy();
if (stateOpt.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
@@ -90,7 +84,7 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler {
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof State state) {
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase();
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase(Locale.ROOT);
// Correct the datapoint for the API call
if ("phasesconfigured".equals(datapoint)) {
datapoint = JSON_KEY_PHASES;
@@ -101,7 +95,7 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler {
} else if (datapoint.contains("disable")) {
datapoint += "/disable/" + datapoint.replace("disable", "");
}
String value = "";
String value;
if (state instanceof OnOffType) {
value = state == OnOffType.ON ? "true" : "false";
} else {
@@ -110,11 +104,10 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler {
value = value.substring(0, state.toString().indexOf(" "));
}
}
String url = endpoint + "/" + datapoint + "/" + value;
String url = String.join("/", endpoint, datapoint, value);
logger.debug("Sending command to this url: {}", url);
if (sendCommand(url)) {
updateState(channelUID, state);
}
performApiRequest(url, POST, JsonNull.INSTANCE);
updateState(channelUID, state);
} else {
super.handleCommand(channelUID, command);
}
@@ -0,0 +1,332 @@
/*
* Copyright (c) 2010-2026 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.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.format.TextStyle;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.i18n.LocaleProvider;
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.type.ChannelTypeRegistry;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
/**
* The {@link EvccPlanHandler} is responsible for fetching the data from the API response for Plan things
*
* @author Marcel Goerentz - Initial contribution
*/
@NonNullByDefault
public class EvccPlanHandler extends EvccBaseThingHandler {
private static final String DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
private final int index;
private final String vehicleID;
private final JsonArray cachedRepeatingPlans = new JsonArray();
private JsonObject cachedOneTimePlan = new JsonObject();
private final Map<Integer, String> localizedDayOfWeekMap = new HashMap<>();
private final Map<String, Integer> localizedReverseMap = new HashMap<>();
private final ZoneId localZone;
public EvccPlanHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry, ZoneId zoneId) {
super(thing, channelTypeRegistry);
localZone = zoneId; // necessary for testing
index = Integer.parseInt(getPropertyOrConfigValue(PROPERTY_INDEX));
vehicleID = getPropertyOrConfigValue(PROPERTY_VEHICLE_ID);
type = PROPERTY_TYPE_PLAN;
}
private void buildLocalizedMaps(EvccBridgeHandler bridgeHandler) {
LocaleProvider localeProvider = bridgeHandler.getLocaleProvider();
Locale locale = localeProvider.getLocale();
localizedDayOfWeekMap.putAll(IntStream.rangeClosed(0, 6).boxed().collect(Collectors.toUnmodifiableMap(d -> d,
d -> (d == 0 ? DayOfWeek.SUNDAY : DayOfWeek.of(d)).getDisplayName(TextStyle.FULL, locale))));
localizedReverseMap.putAll(localizedDayOfWeekMap.entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getValue, Map.Entry::getKey)));
}
@Override
public void initialize() {
super.initialize();
Optional.ofNullable(bridgeHandler).ifPresent(handler -> {
JsonObject stateOpt = handler.getCachedEvccState().deepCopy();
if (stateOpt.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
return;
}
buildLocalizedMaps(handler);
endpoint = String.join("/", handler.getBaseURL(), API_PATH_VEHICLES, vehicleID);
if (index == 0) {
endpoint = String.join("/", endpoint, API_PATH_PLAN_SOC);
} else {
endpoint = String.join("/", endpoint, API_PATH_PLAN_REPEATING);
}
handler.register(this);
updateStatus(ThingStatus.ONLINE);
isInitialized = true;
prepareApiResponseForChannelStateUpdate(stateOpt);
});
}
@Override
public void prepareApiResponseForChannelStateUpdate(JsonObject state) {
if (state.has(JSON_KEY_VEHICLES)) {
state = state.getAsJsonObject(JSON_KEY_VEHICLES).getAsJsonObject(vehicleID);
if (!isInitialized || state.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
return;
}
updateStatus(ThingStatus.ONLINE);
if (index == 0) {
if (state.has(JSON_KEY_PLAN)) {
state = state.getAsJsonObject(JSON_KEY_PLAN);
state.addProperty(JSON_KEY_ACTIVE, true);
cachedOneTimePlan = state.deepCopy();
} else {
JsonObject plan = new JsonObject();
plan.addProperty(JSON_KEY_ACTIVE, false);
state = plan;
}
} else if (index > 0 && state.has(JSON_KEY_REPEATING_PLANS)) {
// Cache the plans
while (!cachedRepeatingPlans.isEmpty()) {
cachedRepeatingPlans.remove(0);
}
cachedRepeatingPlans.addAll(state.getAsJsonArray(JSON_KEY_REPEATING_PLANS).deepCopy());
// Check the bounds
if (cachedRepeatingPlans.size() < index) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
return;
}
// Get the corresponding repeating plan
state = state.getAsJsonArray(JSON_KEY_REPEATING_PLANS).get(index - 1).getAsJsonObject();
if (state.has(JSON_KEY_TIME) && state.has(JSON_KEY_TZ)) {
String time = state.get(JSON_KEY_TIME).getAsString();
String tz = state.get(JSON_KEY_TZ).getAsString();
ZonedDateTime zdt = convertEvccTimeToLocal(time, tz);
state.addProperty(JSON_KEY_TIME, zdt.toString());
}
if (state.has(JSON_KEY_WEEKDAYS)) {
parseWeekdaysResponse(state);
}
cachedRepeatingPlans.set(index - 1, state);
}
updateStatesFromApiResponse(state);
}
}
private void parseWeekdaysResponse(JsonObject state) {
StringBuilder weekDays = new StringBuilder();
for (JsonElement dayElement : state.getAsJsonArray(JSON_KEY_WEEKDAYS)) {
String day = localizedDayOfWeekMap.get(dayElement.getAsInt());
if (null != day && !day.isEmpty()) {
weekDays.append(day).append(";");
}
}
// Delete last semicolon
if (!weekDays.isEmpty()) {
weekDays.setLength(weekDays.length() - 1);
}
// Update the weekdays property to a localized string
state.addProperty(JSON_KEY_WEEKDAYS, weekDays.toString());
}
@Override
public JsonObject getStateFromCachedState(JsonObject state) {
if (index == 0 && !cachedOneTimePlan.isEmpty()) {
return cachedOneTimePlan;
} else if (index > 0 && cachedRepeatingPlans.size() >= index) {
return cachedRepeatingPlans.get(index - 1).getAsJsonObject();
} else {
return new JsonObject();
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof State state) {
String stateString = state.toString();
if (CHANNEL_PLAN_SOC.equals(channelUID.getId()) || CHANNEL_PLAN_PRECONDITION.equals(channelUID.getId())) {
int spaceIdx = stateString.indexOf(" ");
if (spaceIdx != -1) {
stateString = stateString.substring(0, spaceIdx);
}
}
String channelKey = Utils.getKeyFromChannelUID(channelUID);
updatePlan(channelKey, stateString);
} else {
super.handleCommand(channelUID, command);
}
}
private void updatePlan(String channelKey, String value) {
if (index > 0 && (index - 1) < cachedRepeatingPlans.size()) {
updateRepeatingPlan(channelKey, value);
} else {
updateOneTimePlan(channelKey, value);
}
}
private void updateRepeatingPlan(String channelKey, String changedValue) {
JsonArray payload = cachedRepeatingPlans.deepCopy();
JsonObject plan = payload.get(index - 1).getAsJsonObject();
switch (channelKey) {
case JSON_KEY_ACTIVE -> plan.addProperty(channelKey, "ON".equals(changedValue));
case JSON_KEY_SOC, JSON_KEY_PRECONDITION ->
plan.add(channelKey, new JsonPrimitive(Integer.parseInt(changedValue)));
default -> plan.addProperty(channelKey, changedValue);
}
for (Map.Entry<String, JsonElement> entry : plan.entrySet()) {
String key = entry.getKey();
String value = entry.getValue().getAsString();
switch (key) {
case JSON_KEY_WEEKDAYS -> {
JsonArray weekdaysArray = new JsonArray();
String[] days = value.split(";");
for (String day : days) {
Integer dayValue = localizedReverseMap.get(day);
if (dayValue != null) {
weekdaysArray.add(new JsonPrimitive(dayValue));
}
}
plan.add(JSON_KEY_WEEKDAYS, weekdaysArray);
}
case JSON_KEY_TIME -> {
JsonElement tzElement = plan.get(JSON_KEY_TZ);
if (tzElement == null || tzElement.isJsonNull()) {
return;
}
try {
ZonedDateTime.parse(value);
} catch (DateTimeParseException ignored) {
try {
OffsetDateTime odt = OffsetDateTime.parse(value,
DateTimeFormatter.ofPattern(DATE_TIME_FORMAT));
value = odt.toString();
} catch (DateTimeParseException e) {
return;
}
}
String tz = tzElement.getAsString();
ZonedDateTime evccTime = ZonedDateTime.parse(value);
String time = convertLocalTimeToEvcc(evccTime, tz);
plan.add(JSON_KEY_TIME, new JsonPrimitive(time));
}
default -> plan.add(key, entry.getValue());
}
}
payload.set(index - 1, plan);
performApiRequest(endpoint, POST, payload);
}
private void updateOneTimePlan(String channelKey, String value) {
if (JSON_KEY_ACTIVE.equals(channelKey) && "OFF".equals(value)) {
performApiRequest(endpoint, DELETE, JsonNull.INSTANCE);
return;
}
ZonedDateTime zdt = ZonedDateTime.now(localZone).plusHours(1).withSecond(0).withNano(0);
String time = zdt.toInstant().toString(); // Default time
if (JSON_KEY_TIME.equals(channelKey)) {
if (!TimeFormatValidator.isExactTimeFormat(value)) {
try {
OffsetDateTime odt = OffsetDateTime.parse(value, DateTimeFormatter.ofPattern(DATE_TIME_FORMAT));
time = odt.toZonedDateTime().toInstant().toString();
} catch (DateTimeParseException ignored) {
return; // time is not null and is not matching the time formats
}
}
} else if (cachedOneTimePlan.has(JSON_KEY_TIME)) {
time = cachedOneTimePlan.get(JSON_KEY_TIME).getAsString();
}
String soc = "100";
if (JSON_KEY_SOC.equals(channelKey)) {
soc = value;
} else if (cachedOneTimePlan.has(JSON_KEY_SOC)) {
soc = cachedOneTimePlan.get(JSON_KEY_SOC).getAsString();
}
String url = String.join("/", endpoint, soc, time);
String precondition = "";
if (JSON_KEY_PRECONDITION.equals(channelKey)) {
precondition = value;
} else if (cachedOneTimePlan.has(JSON_KEY_PRECONDITION)) {
precondition = cachedOneTimePlan.get(JSON_KEY_PRECONDITION).getAsString();
}
if (!precondition.isEmpty()) {
url = String.join("?", url, "precondition=" + precondition);
}
performApiRequest(url, POST, JsonNull.INSTANCE);
}
private ZonedDateTime convertEvccTimeToLocal(String time, String tz) {
LocalTime lt = LocalTime.parse(time, DateTimeFormatter.ofPattern("HH:mm"));
ZoneId evccZone = ZoneId.of(tz);
ZonedDateTime evccTime = lt.atDate(LocalDate.now(localZone)).atZone(evccZone);
return evccTime.withZoneSameInstant(localZone);
}
private String convertLocalTimeToEvcc(ZonedDateTime localTime, String tz) {
LocalTime lt = localTime.toLocalTime();
ZonedDateTime evccTime = lt.atDate(LocalDate.now(localZone)).atZone(ZoneId.of(tz));
return evccTime.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm"));
}
private static final class TimeFormatValidator {
// Exact: yyyy-MM-dd'T'HH:mm:ss'Z'
private static final DateTimeFormatter EXACT_TIME_FMT = new DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").toFormatter(Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
/**
* true, if input is exact yyyy-MM-dd'T'HH:mm:ss'Z'
*/
public static boolean isExactTimeFormat(String input) {
try {
EXACT_TIME_FMT.parse(input);
return true;
} catch (DateTimeParseException ex) {
return false;
}
}
}
}
@@ -14,7 +14,6 @@ package org.openhab.binding.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.math.BigDecimal;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -37,14 +36,7 @@ public class EvccPvHandler extends EvccBaseThingHandler {
public EvccPvHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) {
super(thing, channelTypeRegistry);
Object index = thing.getConfiguration().get(PROPERTY_INDEX);
String indexString;
if (index instanceof BigDecimal s) {
indexString = s.toString();
} else {
indexString = thing.getProperties().getOrDefault(PROPERTY_INDEX, "0");
}
this.index = Integer.parseInt(indexString);
this.index = Integer.parseInt(getPropertyOrConfigValue(PROPERTY_INDEX));
type = PROPERTY_TYPE_PV;
}
@@ -14,6 +14,7 @@ package org.openhab.binding.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -31,6 +32,7 @@ import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
/**
@@ -51,7 +53,8 @@ public class EvccSiteHandler extends EvccBaseThingHandler {
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof State state) {
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase().replaceAll("co2|price", "");
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase(Locale.ROOT).replaceAll("co2|price",
"");
String value;
if (state instanceof OnOffType) {
value = state == OnOffType.ON ? "true" : "false";
@@ -63,9 +66,7 @@ public class EvccSiteHandler extends EvccBaseThingHandler {
}
String url = endpoint + "/" + datapoint + "/" + value;
logger.debug("Sending command to this url: {}", url);
if (sendCommand(url)) {
updateState(channelUID, state);
}
performApiRequest(url, POST, JsonNull.INSTANCE);
} else {
super.handleCommand(channelUID, command);
}
@@ -14,6 +14,7 @@ package org.openhab.binding.evcc.internal.handler;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.util.Locale;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -28,6 +29,7 @@ import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
/**
@@ -46,28 +48,21 @@ public class EvccVehicleHandler extends EvccBaseThingHandler {
public EvccVehicleHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) {
super(thing, channelTypeRegistry);
Object id = thing.getConfiguration().get(PROPERTY_ID);
if (id instanceof String s) {
vehicleId = s;
} else {
vehicleId = thing.getProperties().getOrDefault(PROPERTY_ID, "");
}
vehicleId = getPropertyOrConfigValue(PROPERTY_VEHICLE_ID);
type = PROPERTY_TYPE_VEHICLE;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof State state) {
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase();
String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase(Locale.ROOT);
String value = state.toString();
if (value.contains(" ")) {
value = value.substring(0, state.toString().indexOf(" "));
}
String url = endpoint + "/" + vehicleId + "/" + datapoint + "/" + value;
logger.debug("Sending command to this url: {}", url);
if (sendCommand(url)) {
updateState(channelUID, state);
}
performApiRequest(url, POST, JsonNull.INSTANCE);
} else {
super.handleCommand(channelUID, command);
}
@@ -83,7 +78,7 @@ public class EvccVehicleHandler extends EvccBaseThingHandler {
public void initialize() {
super.initialize();
Optional.ofNullable(bridgeHandler).ifPresent(handler -> {
endpoint = handler.getBaseURL() + API_PATH_VEHICLES;
endpoint = String.join("/", handler.getBaseURL(), API_PATH_VEHICLES);
JsonObject stateOpt = handler.getCachedEvccState().deepCopy();
if (stateOpt.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2010-2026 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.evcc.internal.handler;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
/**
* The {@link QueuedRequest} wraps a Jetty {@link Request} together with its success and error handlers.
* Used by the evcc request queue to process API calls in a controlled manner.
*
* @author Marcel Goerentz - Initial contribution
*/
@NonNullByDefault
public record QueuedRequest(Request request, Consumer<ContentResponse> onSuccess, Consumer<Exception> onError) {
}
@@ -12,6 +12,7 @@
*/
package org.openhab.binding.evcc.internal.handler;
import java.util.Locale;
import java.util.regex.Pattern;
import javax.measure.Unit;
@@ -178,7 +179,7 @@ public final class StateResolver {
* @return the inferred {@link Unit}
*/
private Unit<?> determineBaseUnitFromKey(String key) {
String lower = key.toLowerCase();
String lower = key.toLowerCase(Locale.ROOT);
if (lower.contains("soc") || lower.contains("percentage")) {
return Units.PERCENT;
@@ -21,6 +21,7 @@ import static org.openhab.binding.evcc.internal.EvccBindingConstants.NUMBER_POWE
import static org.openhab.binding.evcc.internal.EvccBindingConstants.NUMBER_TIME;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -61,7 +62,7 @@ public class Utils {
* @return A string that contains hyphens
*/
public static String sanitizeChannelID(String input) {
return input.replaceAll("(?<!^)(?=[A-Z])", "-").toLowerCase();
return input.replaceAll("(?<!^)(?=[A-Z])", "-").toLowerCase(Locale.ROOT);
}
/**
@@ -78,10 +79,10 @@ public class Utils {
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (i == 0) {
camelCase.append(part.toLowerCase());
camelCase.append(part.toLowerCase(Locale.ROOT));
} else {
camelCase.append(part.substring(0, 1).toUpperCase());
camelCase.append(part.substring(1).toLowerCase());
camelCase.append(part.substring(0, 1).toUpperCase(Locale.ROOT));
camelCase.append(part.substring(1).toLowerCase(Locale.ROOT));
}
}
@@ -112,6 +113,6 @@ public class Utils {
if (input.isEmpty()) {
return input;
}
return input.substring(0, 1).toUpperCase() + input.substring(1);
return input.substring(0, 1).toUpperCase(Locale.ROOT) + input.substring(1);
}
}
@@ -11,6 +11,10 @@ thing-type.evcc.heating.label = Heating - Loadpoint
thing-type.evcc.heating.description = A heating loadpoint configured in your evcc instance
thing-type.evcc.loadpoint.label = Loadpoint
thing-type.evcc.loadpoint.description = A loadpoint configured in your evcc instance
thing-type.evcc.plan.label = Plan
thing-type.evcc.plan.description = A plan configured for your vehicle in evcc
thing-type.evcc.plan.channel.send-update.label = Update Plan
thing-type.evcc.plan.channel.send-update.description = Sends the updated plan to evcc
thing-type.evcc.pv.label = Photovoltaic
thing-type.evcc.pv.description = A photovoltaic system configured in your evcc instance
thing-type.evcc.server.label = Server
@@ -38,6 +42,10 @@ thing-type.config.evcc.heating.index.label = Index
thing-type.config.evcc.heating.index.description = The index of the heating loadpoint in your evcc setup (starting with 0)
thing-type.config.evcc.loadpoint.index.label = Index
thing-type.config.evcc.loadpoint.index.description = The index of the loadpoint in your evcc setup (starting with 0)
thing-type.config.evcc.plan.index.label = Index
thing-type.config.evcc.plan.index.description = The plan index: 0 for one-time plan, 1...N for repeating plans for your vehicle in the evcc setup
thing-type.config.evcc.plan.vehicleId.label = Vehicle ID
thing-type.config.evcc.plan.vehicleId.description = The ID of the vehicle in your evcc setup (database ID)
thing-type.config.evcc.pv.index.label = Index
thing-type.config.evcc.pv.index.description = The index of the photovoltaic system in your evcc setup (starting with 0)
thing-type.config.evcc.server.host.label = Hostname or IP
@@ -50,8 +58,8 @@ thing-type.config.evcc.server.scheme.label = Protocol Scheme
thing-type.config.evcc.server.scheme.description = The protocol scheme that should be used to connect to your instance
thing-type.config.evcc.server.scheme.option.http = HTTP
thing-type.config.evcc.server.scheme.option.https = HTTPS
thing-type.config.evcc.vehicle.id.label = ID
thing-type.config.evcc.vehicle.id.description = The ID of the vehicle in your evcc setup (database ID)
thing-type.config.evcc.vehicle.vehicleId.label = Vehicle ID
thing-type.config.evcc.vehicle.vehicleId.description = The ID of the vehicle in your evcc setup (database ID)
# channel group types
@@ -270,6 +278,20 @@ channel-type.evcc.loadpoint-vehicle-welcome-active.label = Vehicle Welcome Activ
channel-type.evcc.loadpoint-vehicle-welcome-active.description = Indicates whether the charger performs an initial charge or not when the vehicle gets connected
channel-type.evcc.loadpoint-vehicle-welcome-active.state.option.ON = Active
channel-type.evcc.loadpoint-vehicle-welcome-active.state.option.OFF = Inactive
channel-type.evcc.plan-active.label = Active
channel-type.evcc.plan-active.description = Indicates whether the repeating plan is active
channel-type.evcc.plan-active.state.option.ON = Active
channel-type.evcc.plan-active.state.option.OFF = Inactive
channel-type.evcc.plan-precondition.label = Precondition Time
channel-type.evcc.plan-precondition.description = Scheduled preconditioning time (time in seconds to start charging before departure)
channel-type.evcc.plan-soc.label = Plan SoC
channel-type.evcc.plan-soc.description = Scheduled SoC
channel-type.evcc.plan-time.label = Plan Time
channel-type.evcc.plan-time.description = Scheduled departure time
channel-type.evcc.plan-update.label = Trigger Plan Update
channel-type.evcc.plan-update.description = The binding will send an API request to update the plan
channel-type.evcc.plan-weekdays.label = Plan Weekdays
channel-type.evcc.plan-weekdays.description = Planned days on which the vehicle will be charged (only in repeating plans)
channel-type.evcc.pv-power.label = PV Power
channel-type.evcc.pv-power.description = Current power of this photovoltaic system in Watt
channel-type.evcc.pv-title.label = Title
@@ -419,7 +441,19 @@ channel-type.evcc.vehicle-title.description = Name of the vehicle as displayed i
# thing types config
thing-type.config.evcc.server.schema.label = Protocol schema
thing-type.config.evcc.server.schema.description = The protocol schema that should be used to connect to your instance
thing-type.config.evcc.server.schema.option.http = HTTP
thing-type.config.evcc.server.schema.option.https = HTTPS
thing-type.config.evcc.plan.vehicle-id.label = Vehicle ID
thing-type.config.evcc.plan.vehicle-id.description = The database id of the vehicle in your evcc instance
thing-type.config.evcc.vehicle.vehicle-id.label = Vehicle ID
thing-type.config.evcc.vehicle.vehicle-id.description = The ID of the vehicle in your evcc setup (database ID)
# channel types
channel-type.evcc.plan-repeating-time.label = Plan Time
channel-type.evcc.plan-repeating-time.description = Scheduled departure time (hh:mm)
channel-type.evcc.plan-tz.label = Plan Timezone
channel-type.evcc.plan-tz.description = Timezone for the Plan (only in repeating plans)
# discovery
discovery.evcc.plan.one-time.label = One-Time charging plan for {0}
discovery.evcc.plan.repeating.label = Repeating charging plan {0} for {1}
@@ -10,6 +10,7 @@
<label>Battery</label>
<description>A battery configured in your evcc instance</description>
<semantic-equipment-tag>Battery</semantic-equipment-tag>
<config-description>
<parameter name="index" type="integer">
<label>Index</label>
@@ -9,6 +9,7 @@
</supported-bridge-type-refs>
<label>Loadpoint</label>
<description>A loadpoint configured in your evcc instance</description>
<semantic-equipment-tag>EVSE</semantic-equipment-tag>
<representation-property>index</representation-property>
<config-description>
<parameter name="index" type="integer">
@@ -290,21 +291,6 @@
</tags>
<state readOnly="true"/>
</channel-type>
<channel-type id="loadpoint-enabled">
<item-type>Switch</item-type>
<label>Enabled</label>
<description>Indicating whether this loadpoint is enabled or not</description>
<tags>
<tag>Status</tag>
<tag>Enabled</tag>
</tags>
<state readOnly="true">
<options>
<option value="ON">Enabled</option>
<option value="OFF">Disabled</option>
</options>
</state>
</channel-type>
<channel-type id="loadpoint-plan-projected-end">
<item-type>DateTime</item-type>
<label>Plan Projected End</label>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="evcc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="plan">
<supported-bridge-type-refs>
<bridge-type-ref id="server"/>
</supported-bridge-type-refs>
<label>Plan</label>
<description>A plan configured for your vehicle in evcc</description>
<representation-property>id</representation-property>
<config-description>
<parameter name="index" type="text">
<label>Index</label>
<description>The plan index: 0 for one-time plan, 1...N for repeating plans for your vehicle in the evcc setup</description>
</parameter>
<parameter name="vehicleId" type="text">
<label>Vehicle ID</label>
<description>The ID of the vehicle in your evcc setup (database ID)</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="plan-soc">
<item-type unitHint="%">Number:Dimensionless</item-type>
<label>Plan SoC</label>
<description>Scheduled SoC</description>
<tags>
<tag>Setpoint</tag>
<tag>Energy</tag>
</tags>
</channel-type>
<channel-type id="plan-precondition">
<item-type unitHint="s">Number:Time</item-type>
<label>Precondition Time</label>
<description>Scheduled preconditioning time (time in seconds to start charging before departure)</description>
<tags>
<tag>Setpoint</tag>
<tag>Duration</tag>
</tags>
</channel-type>
<channel-type id="plan-time">
<item-type>DateTime</item-type>
<label>Plan Time</label>
<description>Scheduled departure time</description>
<tags>
<tag>Setpoint</tag>
<tag>Timestamp</tag>
</tags>
</channel-type>
<channel-type id="plan-weekdays">
<item-type>String</item-type>
<label>Plan Weekdays</label>
<description>Planned days on which the vehicle will be charged (only in repeating plans)</description>
<tags>
<tag>Setpoint</tag>
<tag>Channel</tag>
</tags>
</channel-type>
<channel-type id="plan-active">
<item-type>Switch</item-type>
<label>Active</label>
<description>Indicates whether the repeating plan is active</description>
<tags>
<tag>Control</tag>
<tag>Enabled</tag>
</tags>
<state>
<options>
<option value="ON">Active</option>
<option value="OFF">Inactive</option>
</options>
</state>
</channel-type>
</thing:thing-descriptions>
@@ -10,6 +10,7 @@
<label>Photovoltaic</label>
<description>A photovoltaic system configured in your evcc instance</description>
<semantic-equipment-tag>Inverter</semantic-equipment-tag>
<config-description>
<parameter name="index" type="integer">
<label>Index</label>
@@ -9,10 +9,11 @@
</supported-bridge-type-refs>
<label>Vehicle</label>
<description>A vehicle configured in your evcc instance</description>
<representation-property>id</representation-property>
<semantic-equipment-tag>Vehicle</semantic-equipment-tag>
<representation-property>vehicleId</representation-property>
<config-description>
<parameter name="id" type="text">
<label>ID</label>
<parameter name="vehicleId" type="text">
<label>Vehicle ID</label>
<description>The ID of the vehicle in your evcc setup (database ID)</description>
</parameter>
</config-description>
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2010-2026 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.evcc.internal.discovery.mapper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openhab.binding.evcc.internal.handler.EvccBatteryHandlerTest;
import org.openhab.binding.evcc.internal.handler.EvccBridgeHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingUID;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* The {@link PlanDiscoveryMapperTest} is responsible for testing the PlanDiscoveryMapper implementation
*
* @author Marcel Goerentz - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public class PlanDiscoveryMapperTest {
private static JsonObject exampleResponse = new JsonObject();
private final EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class);
private final Bridge bridge = mock(Bridge.class);
private final LocaleProvider lp = mock(LocaleProvider.class);
private final TranslationProvider tp = mock(TranslationProvider.class);
private final Bundle bundle = mock(Bundle.class);
private final BundleContext ctx = mock(BundleContext.class);
@BeforeAll
static void setUpOnce() {
try (InputStream is = EvccBatteryHandlerTest.class.getClassLoader()
.getResourceAsStream("responses/example_response.json")) {
if (is == null) {
throw new IllegalArgumentException("Couldn't find response file");
}
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
exampleResponse = JsonParser.parseString(json).getAsJsonObject();
} catch (IOException e) {
fail("Failed to read example response file", e);
}
}
@Test
void oneTimeOnlyShouldDiscoverOneResult() throws NoSuchAlgorithmException {
// vehicle JSON with only a "plan" section
JsonObject state = exampleResponse.deepCopy();
JsonObject vehicle = state.getAsJsonObject(JSON_KEY_VEHICLES).get("vehicle_2").getAsJsonObject();
when(bridgeHandler.getThing()).thenReturn(bridge);
when(bridge.getUID()).thenReturn(new ThingUID("evcc:server:dummy"));
when(bundle.getBundleContext()).thenReturn(ctx);
PlanDiscoveryMapper mapper = new PlanDiscoveryMapper(ctx, tp, lp);
Collection<DiscoveryResult> results = mapper.discoverFromVehicle(vehicle, "vehicle_2", "My Car", bridgeHandler);
assertEquals(1, results.size());
DiscoveryResult r = results.iterator().next();
Map<String, Object> props = r.getProperties();
assertEquals("0", props.get(PROPERTY_INDEX));
assertEquals("vehicle_2", props.get(PROPERTY_VEHICLE_ID));
assertTrue(props.containsKey(PROPERTY_ID));
assertEquals(THING_TYPE_PLAN, r.getThingTypeUID());
}
@Test
void repeatingOnlyShouldDiscoverAllRepeatingIndices() throws NoSuchAlgorithmException {
// vehicle JSON with only "repeatingPlans" (size = 2)
JsonObject vehicle = new JsonObject();
JsonArray repeating = new JsonArray();
JsonObject p1 = new JsonObject();
p1.addProperty("time", "07:30");
JsonObject p2 = new JsonObject();
p2.addProperty("time", "19:00");
repeating.add(p1);
repeating.add(p2);
vehicle.add("repeatingPlans", repeating);
when(bridgeHandler.getThing()).thenReturn(bridge);
when(bridge.getUID()).thenReturn(new ThingUID("evcc:server:dummy"));
when(bundle.getBundleContext()).thenReturn(ctx);
PlanDiscoveryMapper mapper = new PlanDiscoveryMapper(ctx, tp, lp);
Collection<DiscoveryResult> results = mapper.discoverFromVehicle(vehicle, "vehicle_1", "My Car", bridgeHandler);
assertEquals(2, results.size());
// indices should be 1..N (because 0 is one-time)
for (DiscoveryResult r : results) {
Map<String, Object> props = r.getProperties();
assertTrue(props.get(PROPERTY_INDEX).equals("1") || props.get(PROPERTY_INDEX).equals("2"));
assertEquals("vehicle_1", props.get(PROPERTY_VEHICLE_ID));
assertTrue(props.containsKey(PROPERTY_ID));
// UID must be of thing type "plan"
assertEquals(THING_TYPE_PLAN, r.getThingTypeUID());
}
}
}
@@ -19,6 +19,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -28,11 +29,15 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.type.ChannelTypeRegistry;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -73,7 +78,6 @@ public abstract class AbstractThingHandlerTestClass<T extends EvccBaseThingHandl
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
exampleResponse = JsonParser.parseString(json).getAsJsonObject();
verifyObject = exampleResponse.deepCopy();
} catch (IOException e) {
fail("Failed to read example response file", e);
}
@@ -106,6 +110,14 @@ public abstract class AbstractThingHandlerTestClass<T extends EvccBaseThingHandl
EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class);
handler.bridgeHandler = bridgeHandler;
when(bridgeHandler.getCachedEvccState()).thenReturn(new JsonObject());
LocaleProvider lp = mock(LocaleProvider.class);
TranslationProvider tp = mock(TranslationProvider.class);
Bundle bundle = mock(Bundle.class);
BundleContext ctx = mock(BundleContext.class);
when(lp.getLocale()).thenReturn(Locale.ENGLISH);
when(bridgeHandler.getLocaleProvider()).thenReturn(lp);
when(bridgeHandler.getI18nProvider()).thenReturn(tp);
when(bundle.getBundleContext()).thenReturn(ctx);
handler.initialize();
assertEquals(ThingStatus.OFFLINE, lastThingStatus);
@@ -0,0 +1,287 @@
/*
* Copyright (c) 2010-2026 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.evcc.internal.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.*;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
/**
* The {@link EvccPlanHandlerTest} is responsible for testing the EvccPlanHandler implementation
*
* @author Marcel Goerentz - Initial contribution
*/
@SuppressWarnings("null")
@NonNullByDefault
public class EvccPlanHandlerTest extends AbstractThingHandlerTestClass<EvccPlanHandler> {
private boolean updateStateCalled = false;
private int updateStateCounter = 0;
private String capturedUrl = "";
private JsonElement capturedPayload = JsonNull.INSTANCE;
private String capturedMethod = "";
@Override
protected EvccPlanHandler createHandler() {
return new EvccPlanHandler(thing, channelTypeRegistry, ZoneId.of("CET")) {
@Override
protected void updateStatus(ThingStatus status, ThingStatusDetail detail) {
lastThingStatus = status;
lastThingStatusDetail = detail;
}
@Override
protected void updateStatus(ThingStatus status) {
lastThingStatus = status;
}
@Override
public void logUnknownChannelXmlAsync(String key, String itemType) {
}
@Nullable
@Override
protected Bridge getBridge() {
return null;
}
@Override
public void updateState(ChannelUID uid, State state) {
updateStateCalled = true;
updateStateCounter++;
}
@Override
protected void performApiRequest(String url, String method, JsonElement payload) {
capturedUrl = url;
capturedPayload = payload;
capturedMethod = method;
}
};
}
@SuppressWarnings("null")
@BeforeEach
public void setup() {
when(thing.getUID()).thenReturn(new ThingUID("evcc:plan:uid"));
when(thing.getProperties())
.thenReturn(Map.of(PROPERTY_INDEX, "0", PROPERTY_VEHICLE_ID, "vehicle_1", PROPERTY_TYPE, "plan"));
when(thing.getChannels()).thenReturn(new ArrayList<>());
Configuration configuration = mock(Configuration.class);
when(configuration.get(PROPERTY_INDEX)).thenReturn("0");
when(configuration.get(PROPERTY_VEHICLE_ID)).thenReturn("vehicle_1");
when(thing.getConfiguration()).thenReturn(configuration);
handler = spy(createHandler());
EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class);
LocaleProvider lp = mock(LocaleProvider.class);
TranslationProvider tp = mock(TranslationProvider.class);
Bundle bundle = mock(Bundle.class);
BundleContext ctx = mock(BundleContext.class);
when(bridgeHandler.getBaseURL()).thenReturn("http://evcc/api");
when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse);
when(lp.getLocale()).thenReturn(Locale.ENGLISH);
when(bridgeHandler.getLocaleProvider()).thenReturn(lp);
when(bridgeHandler.getI18nProvider()).thenReturn(tp);
when(bundle.getBundleContext()).thenReturn(ctx);
handler.bridgeHandler = bridgeHandler;
}
private void changeConfiguration() {
when(thing.getUID()).thenReturn(new ThingUID("evcc:plan:uid"));
when(thing.getProperties())
.thenReturn(Map.of(PROPERTY_INDEX, "1", PROPERTY_VEHICLE_ID, "vehicle_1", PROPERTY_TYPE, "plan"));
when(thing.getChannels()).thenReturn(new ArrayList<>());
Configuration configuration = mock(Configuration.class);
when(configuration.get(PROPERTY_INDEX)).thenReturn("1");
when(configuration.get(PROPERTY_VEHICLE_ID)).thenReturn("vehicle_1");
when(thing.getConfiguration()).thenReturn(configuration);
handler = spy(createHandler());
EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class);
LocaleProvider lp = mock(LocaleProvider.class);
TranslationProvider tp = mock(TranslationProvider.class);
Bundle bundle = mock(Bundle.class);
BundleContext ctx = mock(BundleContext.class);
when(bridgeHandler.getBaseURL()).thenReturn("http://evcc/api");
when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse);
when(lp.getLocale()).thenReturn(Locale.ENGLISH);
when(bridgeHandler.getLocaleProvider()).thenReturn(lp);
when(bridgeHandler.getI18nProvider()).thenReturn(tp);
when(bundle.getBundleContext()).thenReturn(ctx);
handler.bridgeHandler = bridgeHandler;
}
@Test
void updatingOneTimePlanShouldNormalizeTimeAndBuildUrl() {
handler.initialize();
assertTrue(updateStateCalled);
assertEquals(4, updateStateCounter);
updateStateCalled = false;
updateStateCounter = 0;
handler.prepareApiResponseForChannelStateUpdate(exampleResponse.deepCopy());
assertTrue(updateStateCalled);
assertEquals(4, updateStateCounter);
// Set new SoC & time via handleCommand (pending commands collection)
ChannelUID socCh = new ChannelUID(thing.getUID(), CHANNEL_PLAN_SOC);
ChannelUID timeCh = new ChannelUID(thing.getUID(), "plan-time");
ChannelUID precCh = new ChannelUID(thing.getUID(), CHANNEL_PLAN_PRECONDITION);
State socState = new StringType("85 %");
// Offset + milliseconds (should normalize to Instant Z)
State timeState = new StringType("2025-12-20T09:00:00.000+0100");
State precState = new StringType("1800 s");
handler.handleCommand(socCh, (Command) socState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
// Expect: base / vehicles / vehicle_1 / plan/soc / 85 / 2025-12-20T08:00:00Z ?precondition=1800
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/soc/85/"));
assertEquals("POST", capturedMethod);
handler.handleCommand(timeCh, (Command) timeState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
// Expect: base / vehicles / vehicle_1 / plan/soc / 85 / 2025-12-20T08:00:00Z ?precondition=1800
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/soc/80/"));
assertTrue(capturedUrl.contains("2025-12-20T08:00:00Z")); // normalized to Instant (Z)
assertEquals("POST", capturedMethod);
handler.handleCommand(precCh, (Command) precState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
// Expect: base / vehicles / vehicle_1 / plan/soc / 85 / 2025-12-20T08:00:00Z ?precondition=1800
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/soc/80/"));
assertTrue(capturedUrl.endsWith("?precondition=1800"));
assertEquals("POST", capturedMethod);
}
@Test
void updatingSocForRepeatingPlanShouldTriggerApiRequest() {
changeConfiguration();
handler.initialize();
handler.prepareApiResponseForChannelStateUpdate(exampleResponse.deepCopy());
ChannelUID socCh = new ChannelUID(thing.getUID(), CHANNEL_PLAN_SOC);
State socState = new StringType("85 %");
handler.handleCommand(socCh, (Command) socState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/repeating"));
assertEquals("POST", capturedMethod);
assertNotEquals(JsonNull.INSTANCE, capturedPayload, "Payload must be captured");
JsonObject plan = capturedPayload.getAsJsonArray().get(0).getAsJsonObject();
assertEquals(85, plan.get("soc").getAsInt());
}
@Test
void updateWeekdaysForRepeatingPlanShouldConvertWeekdaysAndTriggerApiRequest() {
changeConfiguration();
handler.initialize();
handler.prepareApiResponseForChannelStateUpdate(exampleResponse.deepCopy());
ChannelUID wdCh = new ChannelUID(thing.getUID(), "plan-weekdays");
State wdState = new StringType("Monday;Wednesday;Sunday"); // Sunday maps to 0
handler.handleCommand(wdCh, (Command) wdState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/repeating"));
assertEquals("POST", capturedMethod);
assertNotEquals(JsonNull.INSTANCE, capturedPayload, "Payload must be captured");
JsonObject plan = capturedPayload.getAsJsonArray().get(0).getAsJsonObject();
// weekdays should be numeric array [1,3,0]
assertTrue(plan.has("weekdays"));
JsonArray w = plan.get("weekdays").getAsJsonArray();
// assertEquals(3, w.size());
assertEquals(1, w.get(0).getAsInt()); // Monday
assertEquals(3, w.get(1).getAsInt()); // Wednesday
assertEquals(0, w.get(2).getAsInt()); // Sunday
}
@Test
void updatingTimeForRepeatingPlanShouldConvertTimeAndTriggerApiRequest() {
changeConfiguration();
handler.initialize();
handler.prepareApiResponseForChannelStateUpdate(exampleResponse.deepCopy());
ChannelUID timeCh = new ChannelUID(thing.getUID(), "plan-time");
State timeState = new StringType("2025-12-20T09:00:00.000+0100");
handler.handleCommand(timeCh, (Command) timeState);
handler.handleCommand(timeCh, (Command) timeState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/repeating"));
assertEquals("POST", capturedMethod);
assertNotEquals(JsonNull.INSTANCE, capturedPayload, "Payload must be captured");
JsonObject plan = capturedPayload.getAsJsonArray().get(0).getAsJsonObject();
// time changend in payload
assertTrue(plan.has("time"));
assertEquals("09:00", plan.get("time").getAsString());
}
@Test
void updatingPreconditionForRepeatingPlanShouldTriggerApiRequest() {
changeConfiguration();
handler.initialize();
handler.prepareApiResponseForChannelStateUpdate(exampleResponse.deepCopy());
ChannelUID preCCh = new ChannelUID(thing.getUID(), CHANNEL_PLAN_PRECONDITION);
State precState = new StringType("1800 s");
handler.handleCommand(preCCh, (Command) precState);
assertFalse(capturedUrl.isEmpty(), "Url should not be empty!");
assertTrue(capturedUrl.startsWith("http://evcc/api/vehicles/vehicle_1/plan/repeating"));
assertEquals("POST", capturedMethod);
assertNotEquals(JsonNull.INSTANCE, capturedPayload, "Payload must be captured");
JsonObject plan = capturedPayload.getAsJsonArray().get(0).getAsJsonObject();
assertEquals(1800, plan.get("precondition").getAsInt());
}
}
@@ -15,6 +15,8 @@ package org.openhab.binding.evcc.internal.handler;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.when;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.PROPERTY_INDEX;
import static org.openhab.binding.evcc.internal.EvccBindingConstants.PROPERTY_VEHICLE_ID;
import java.util.ArrayList;
import java.util.Map;
@@ -79,11 +81,11 @@ public class EvccVehicleHandlerTest extends AbstractThingHandlerTestClass<EvccVe
@BeforeEach
public void setup() {
when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid"));
when(thing.getProperties()).thenReturn(Map.of("id", "vehicle_1", "type", "vehicle"));
when(thing.getProperties()).thenReturn(Map.of(PROPERTY_VEHICLE_ID, "vehicle_1", "type", "vehicle"));
when(thing.getChannels()).thenReturn(new ArrayList<>());
Configuration configuration = mock(Configuration.class);
when(configuration.get("index")).thenReturn("0");
when(configuration.get("id")).thenReturn("vehicle_1");
when(configuration.get(PROPERTY_INDEX)).thenReturn("0");
when(configuration.get(PROPERTY_VEHICLE_ID)).thenReturn("vehicle_1");
when(thing.getConfiguration()).thenReturn(configuration);
handler = spy(createHandler());
EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class);
@@ -1179,6 +1179,11 @@
"capacity": 44,
"minSoc": 15,
"limitSoc": 80,
"plan": {
"soc": 80,
"precondition": 3600,
"time": "2025-09-08T06:00:00Z"
},
"repeatingPlans": [
{
"weekdays": [