mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[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:
@@ -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
|
||||
|
||||
+29
-9
@@ -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";
|
||||
}
|
||||
|
||||
+7
-1
@@ -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;
|
||||
}
|
||||
|
||||
+6
-10
@@ -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
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+124
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-33
@@ -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) {
|
||||
|
||||
+1
-9
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+77
-6
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+9
-16
@@ -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);
|
||||
}
|
||||
|
||||
+332
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-9
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -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);
|
||||
}
|
||||
|
||||
+6
-11
@@ -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);
|
||||
|
||||
+29
@@ -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) {
|
||||
}
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+6
-5
@@ -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>
|
||||
|
||||
+128
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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);
|
||||
|
||||
+287
@@ -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());
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user