diff --git a/bundles/org.openhab.binding.evcc/README.md b/bundles/org.openhab.binding.evcc/README.md index 5f96a3e6cc372..9923f1dfb7dc2 100644 --- a/bundles/org.openhab.binding.evcc/README.md +++ b/bundles/org.openhab.binding.evcc/README.md @@ -55,20 +55,20 @@ These channels are dynamically added to the Thing during their initialization; t ### `demo.things` Example ```java -Bridge evcc:server:demo-server "Demo" [scheme="https", url="demo.evcc.io", port=80, refreshInterval=30] { +Bridge evcc:server:demo-server "Demo" [scheme="https", host="demo.evcc.io", port=443, refreshInterval=30] { // This thing will only exist once per evcc instance Thing site demo-site "Site - evcc Demo" // You can define as many Battery things as you have batteries configured in your evcc instance - Thing battery demo-battery1 "Battery - evcc Demo Battery 1" + Thing battery demo-battery1 "Battery - evcc Demo Battery 1"[index=0] .. // You can define as many PV things as you have photovoltaics configured in your evcc instance - Thing pv demo-pv1 "PV - evcc Demo Photovoltaic 1" + Thing pv demo-pv1 "PV - evcc Demo Photovoltaic 1"[index=0] .. // You can define as many Loadpoint things as you have loadpoints configured in your evcc instance - Thing loadpoint demo-loadpoint-carport "Loadpoint - evcc Demo Loadpoint 1" + 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" + Thing vehicle demo-vehicle1 "Vehicle - evcc Demo Vehicle 1"[id="vehicle_1"] .. } ``` diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBindingConstants.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBindingConstants.java index 71e073af29af4..48b52b503693c 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBindingConstants.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBindingConstants.java @@ -59,12 +59,31 @@ public class EvccBindingConstants { public static final String PROPERTY_TYPE_STATISTICS = "statistics"; public static final String PROPERTY_TYPE_VEHICLE = "vehicle"; - public static final String JSON_MEMBER_BATTERY = "battery"; - public static final String JSON_MEMBER_LOADPOINTS = "loadpoints"; - public static final String JSON_MEMBER_PV = "pv"; - public static final String JSON_MEMBER_STATISTICS = "statistics"; - public static final String JSON_MEMBER_VEHICLES = "vehicles"; - public static final String JSON_MEMBER_CHARGER_FEATURE_HEATING = "chargerFeatureHeating"; + 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"; + public static final String JSON_KEY_CHARGE_VOLTAGES = "chargeVoltages"; + public static final String JSON_KEY_CHARGER_FEATURE_HEATING = "chargerFeatureHeating"; + public static final String JSON_KEY_CHARGING = "charging"; + public static final String JSON_KEY_CONNECTED = "connected"; + public static final String JSON_KEY_EFFECTIVE_LIMIT_SOC = "effectiveLimitSoc"; + public static final String JSON_KEY_EFFECTIVE_PLAN_SOC = "effectivePlanSoc"; + public static final String JSON_KEY_ENABLED = "enabled"; + public static final String JSON_KEY_GRID = "grid"; + public static final String JSON_KEY_GRID_CONFIGURED = "gridConfigured"; + public static final String JSON_KEY_LIMIT_SOC = "limitSoc"; + public static final String JSON_KEY_LOADPOINTS = "loadpoints"; + 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_PV = "pv"; + public static final String JSON_KEY_SMART_COST_TYPE = "smartCostType"; + public static final String JSON_KEY_STATISTICS = "statistics"; + 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 NUMBER_CURRENCY = CoreItemFactory.NUMBER + ":Currency"; public static final String NUMBER_DIMENSIONLESS = CoreItemFactory.NUMBER + ":Dimensionless"; diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccConfiguration.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBridgeConfiguration.java similarity index 83% rename from bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccConfiguration.java rename to bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBridgeConfiguration.java index 476034f5422ce..f9baa3dabf671 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccConfiguration.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/EvccBridgeConfiguration.java @@ -15,13 +15,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * The {@link EvccConfiguration} class contains fields mapping thing configuration parameters. + * The {@link EvccBridgeConfiguration} class contains fields mapping thing configuration parameters. * * @author Florian Hotze - Initial contribution * @author Marcel Goerentz - Rework the binding */ @NonNullByDefault -public class EvccConfiguration { +public class EvccBridgeConfiguration { public String host = ""; public int pollInterval = 30; public int port = 7070; diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/EvccDiscoveryService.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/EvccDiscoveryService.java index 9557457feabb9..5b84a29e5fee8 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/EvccDiscoveryService.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/EvccDiscoveryService.java @@ -67,7 +67,7 @@ public EvccDiscoveryService() { @Override protected void startScan() { logger.debug("Starting evcc Discover"); - JsonObject state = thingHandler.getCachedEvccState(); + JsonObject state = thingHandler.getCachedEvccState().deepCopy(); if (!state.isEmpty()) { for (EvccDiscoveryMapper mapper : mappers) { mapper.discover(state, thingHandler).forEach(thing -> { diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/BatteryDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/BatteryDiscoveryMapper.java index 170811960f0c6..ceb993f78ca37 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/BatteryDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/BatteryDiscoveryMapper.java @@ -41,21 +41,21 @@ public class BatteryDiscoveryMapper implements EvccDiscoveryMapper { @Override public Collection discover(JsonObject state, EvccBridgeHandler bridgeHandler) { List results = new ArrayList<>(); - JsonArray batteries = state.getAsJsonArray(JSON_MEMBER_BATTERY); + JsonArray batteries = state.getAsJsonArray(JSON_KEY_BATTERY); if (batteries == null) { return results; } for (int i = 0; i < batteries.size(); i++) { JsonObject battery = batteries.get(i).getAsJsonObject(); - String title = battery.has("title") ? battery.get("title").getAsString().toLowerCase(Locale.ROOT) - : "battery" + i; + String title = battery.has(JSON_KEY_TITLE) + ? battery.get(JSON_KEY_TITLE).getAsString().toLowerCase(Locale.ROOT) + : JSON_KEY_BATTERY + i; ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_BATTERY, bridgeHandler.getThing().getUID(), Utils.sanitizeName(title)); DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(title) .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_INDEX, i) - .withProperty(PROPERTY_TYPE, PROPERTY_TYPE_BATTERY).withProperty(PROPERTY_TITLE, title) - .withRepresentationProperty(PROPERTY_TITLE).build(); + .withProperty(PROPERTY_TITLE, title).withRepresentationProperty(PROPERTY_TITLE).build(); results.add(result); } return results; diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/LoadpointDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/LoadpointDiscoveryMapper.java index cb28619302758..740a3610fc72c 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/LoadpointDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/LoadpointDiscoveryMapper.java @@ -43,28 +43,26 @@ public class LoadpointDiscoveryMapper implements EvccDiscoveryMapper { @Override public Collection discover(JsonObject state, EvccBridgeHandler bridgeHandler) { List results = new ArrayList<>(); - JsonArray loadpoints = state.getAsJsonArray(JSON_MEMBER_LOADPOINTS); + JsonArray loadpoints = state.getAsJsonArray(JSON_KEY_LOADPOINTS); if (loadpoints == null) { return results; } for (int i = 0; i < loadpoints.size(); i++) { JsonObject lp = loadpoints.get(i).getAsJsonObject(); - String title = lp.has("title") ? lp.get("title").getAsString().toLowerCase(Locale.ROOT) : "loadpoint" + i; + String title = lp.has(JSON_KEY_TITLE) ? lp.get(JSON_KEY_TITLE).getAsString().toLowerCase(Locale.ROOT) + : "loadpoint" + i; ThingUID uid = new ThingUID("DUMMY:DUMMY:DUMMY"); Map properties = new HashMap<>(); properties.put(PROPERTY_INDEX, i); properties.put(PROPERTY_TITLE, title); - if (lp.has(JSON_MEMBER_CHARGER_FEATURE_HEATING) - && lp.get(JSON_MEMBER_CHARGER_FEATURE_HEATING).getAsBoolean()) { + if (lp.has(JSON_KEY_CHARGER_FEATURE_HEATING) && lp.get(JSON_KEY_CHARGER_FEATURE_HEATING).getAsBoolean()) { uid = new ThingUID(EvccBindingConstants.THING_TYPE_HEATING, bridgeHandler.getThing().getUID(), Utils.sanitizeName(title)); - properties.put(PROPERTY_TYPE, PROPERTY_TYPE_HEATING); } else { uid = new ThingUID(EvccBindingConstants.THING_TYPE_LOADPOINT, bridgeHandler.getThing().getUID(), Utils.sanitizeName(title)); - properties.put(PROPERTY_TYPE, PROPERTY_TYPE_LOADPOINT); } DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(title) diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/PvDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/PvDiscoveryMapper.java index d5db3bae1c41b..11c10abcf41d0 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/PvDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/PvDiscoveryMapper.java @@ -41,20 +41,20 @@ public class PvDiscoveryMapper implements EvccDiscoveryMapper { @Override public Collection discover(JsonObject state, EvccBridgeHandler bridgeHandler) { List results = new ArrayList<>(); - JsonArray pvs = state.getAsJsonArray(JSON_MEMBER_PV); + JsonArray pvs = state.getAsJsonArray(JSON_KEY_PV); if (pvs == null) { return results; } for (int i = 0; i < pvs.size(); i++) { JsonObject pv = pvs.get(i).getAsJsonObject(); - String title = pv.has("title") ? pv.get("title").getAsString().toLowerCase(Locale.ROOT) : "pv" + i; + String title = pv.has(JSON_KEY_TITLE) ? pv.get(JSON_KEY_TITLE).getAsString().toLowerCase(Locale.ROOT) + : JSON_KEY_PV + i; ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_PV, bridgeHandler.getThing().getUID(), Utils.sanitizeName(title)); DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(title) .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_INDEX, i) - .withProperty(PROPERTY_TYPE, PROPERTY_TYPE_PV).withProperty(PROPERTY_TITLE, title) - .withRepresentationProperty(PROPERTY_TITLE).build(); + .withProperty(PROPERTY_TITLE, title).withRepresentationProperty(PROPERTY_TITLE).build(); results.add(result); } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/SiteDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/SiteDiscoveryMapper.java index 3dddbd95182f8..4ed32b5421c40 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/SiteDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/SiteDiscoveryMapper.java @@ -44,8 +44,8 @@ public Collection discover(JsonObject state, EvccBridgeHandler String siteTitle = state.get("siteTitle").getAsString(); ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_SITE, bridgeHandler.getThing().getUID(), "site"); DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(siteTitle) - .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_TYPE, PROPERTY_TYPE_SITE) - .withProperty(PROPERTY_SITE_TITLE, siteTitle).withRepresentationProperty(PROPERTY_SITE_TITLE).build(); + .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_SITE_TITLE, siteTitle) + .withRepresentationProperty(PROPERTY_SITE_TITLE).build(); results.add(result); return results; } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/StatisticsDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/StatisticsDiscoveryMapper.java index ef7a97b0ef2f4..8fd85a3423722 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/StatisticsDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/StatisticsDiscoveryMapper.java @@ -38,12 +38,12 @@ public class StatisticsDiscoveryMapper implements EvccDiscoveryMapper { @Override public Collection discover(JsonObject state, EvccBridgeHandler bridgeHandler) { List results = new ArrayList<>(); - JsonObject statistics = state.getAsJsonObject(JSON_MEMBER_STATISTICS); + JsonObject statistics = state.getAsJsonObject(JSON_KEY_STATISTICS); if (statistics == null) { return results; } ThingUID uid = new ThingUID(EvccBindingConstants.THING_TYPE_STATISTICS, bridgeHandler.getThing().getUID(), - "statistics"); + JSON_KEY_STATISTICS); DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel("Statistics") .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_TYPE, PROPERTY_TYPE_STATISTICS) .withRepresentationProperty(PROPERTY_TYPE).build(); diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/VehicleDiscoveryMapper.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/VehicleDiscoveryMapper.java index 98bea915c5845..f1bcb3c6563f4 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/VehicleDiscoveryMapper.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/discovery/mapper/VehicleDiscoveryMapper.java @@ -12,10 +12,7 @@ */ package org.openhab.binding.evcc.internal.discovery.mapper; -import static org.openhab.binding.evcc.internal.EvccBindingConstants.JSON_MEMBER_VEHICLES; -import static org.openhab.binding.evcc.internal.EvccBindingConstants.PROPERTY_ID; -import static org.openhab.binding.evcc.internal.EvccBindingConstants.PROPERTY_TYPE; -import static org.openhab.binding.evcc.internal.EvccBindingConstants.PROPERTY_TYPE_VEHICLE; +import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; import java.util.ArrayList; import java.util.Collection; @@ -44,20 +41,20 @@ public class VehicleDiscoveryMapper implements EvccDiscoveryMapper { @Override public Collection discover(JsonObject state, EvccBridgeHandler bridgeHandler) { List results = new ArrayList<>(); - JsonObject vehicles = state.getAsJsonObject(JSON_MEMBER_VEHICLES); + JsonObject vehicles = state.getAsJsonObject(JSON_KEY_VEHICLES); if (vehicles == null) { return results; } for (Map.Entry entry : vehicles.entrySet()) { JsonObject v = entry.getValue().getAsJsonObject(); String id = entry.getKey(); - String title = v.has("title") ? v.get("title").getAsString() : id; + String title = v.has(JSON_KEY_TITLE) ? v.get(JSON_KEY_TITLE).getAsString() : id; 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_TYPE, PROPERTY_TYPE_VEHICLE) - .withProperty(PROPERTY_ID, id).withRepresentationProperty(PROPERTY_ID).build(); + .withBridge(bridgeHandler.getThing().getUID()).withProperty(PROPERTY_ID, id) + .withRepresentationProperty(PROPERTY_ID).build(); results.add(result); } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandler.java index f2fb22fe9eca4..d1b12c2a81dab 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandler.java @@ -13,6 +13,7 @@ package org.openhab.binding.evcc.internal.handler; import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; +import static org.openhab.core.util.StringUtils.capitalize; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -20,6 +21,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -27,8 +31,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import javax.measure.Unit; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; @@ -36,13 +38,6 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.openhab.core.i18n.TranslationProvider; -import org.openhab.core.library.CoreItemFactory; -import org.openhab.core.library.types.DateTimeType; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.QuantityType; -import org.openhab.core.library.types.StringType; -import org.openhab.core.library.unit.Units; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; @@ -58,11 +53,13 @@ import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; import org.osgi.framework.BundleContext; import org.osgi.framework.FrameworkUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -76,10 +73,13 @@ public abstract class EvccBaseThingHandler extends BaseThingHandler implements E private final Logger logger = LoggerFactory.getLogger(EvccBaseThingHandler.class); private final ChannelTypeRegistry channelTypeRegistry; + private final Gson gson = new Gson(); + protected String type = ""; protected @Nullable EvccBridgeHandler bridgeHandler; protected boolean isInitialized = false; protected String endpoint = ""; protected String smartCostType = ""; + protected @Nullable StateResolver stateResolver = StateResolver.getInstance(); public EvccBaseThingHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing); @@ -87,7 +87,7 @@ public EvccBaseThingHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry } protected void commonInitialize(JsonObject state) { - ThingBuilder builder = editThing(); + List newChannels = new ArrayList<>(); for (Map.Entry<@Nullable String, @Nullable JsonElement> entry : state.entrySet()) { String key = entry.getKey(); @@ -102,10 +102,15 @@ protected void commonInitialize(JsonObject state) { continue; } - createChannel(thingKey, builder, value); + @Nullable + Channel channel = createChannel(thingKey, value); + if (null != channel) { + newChannels.add(channel); + } } - updateThing(builder.build()); + newChannels.sort(Comparator.comparing(channel -> channel.getUID().getId())); + updateThing(editThing().withChannels(newChannels).build()); updateStatus(ThingStatus.ONLINE); isInitialized = true; Optional.ofNullable(bridgeHandler).ifPresentOrElse(handler -> handler.register(this), @@ -133,38 +138,48 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof RefreshType) { String key = Utils.getKeyFromChannelUID(channelUID); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { - JsonObject state = getStateFromCachedState(handler.getCachedEvccState()); - if (!state.isEmpty()) { - JsonElement value = state.get(key); - ItemTypeUnit typeUnit = getItemType(new ChannelTypeUID(BINDING_ID, channelUID.getId())); - if (null != value) { - setItemValue(typeUnit, channelUID, value); - } + JsonObject jsonState = getStateFromCachedState(handler.getCachedEvccState()); + if (!jsonState.isEmpty()) { + JsonElement value = jsonState.get(key); + Optional.ofNullable(stateResolver).ifPresent(resolver -> { + State state = resolver.resolveState(key, value); + if (null != state) { + updateState(channelUID, state); + } + }); } }); } } - private void createChannel(String thingKey, ThingBuilder builder, JsonElement value) { - ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, thingKey); - ItemTypeUnit typeUnit = getItemType(channelTypeUID); - String itemType = typeUnit.itemType; + private String getItemType(ChannelTypeUID channelTypeUID) { + ChannelType channelType = channelTypeRegistry.getChannelType(channelTypeUID); + if (null != channelType) { + String itemType = channelType.getItemType(); + return Objects.requireNonNullElse(itemType, "Unknown"); + } + return "Unknown"; + } + @Nullable + protected Channel createChannel(String thingKey, JsonElement value) { + ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, thingKey); + String itemType = getItemType(channelTypeUID); if (!"Unknown".equals(itemType)) { String label = getChannelLabel(thingKey); Channel channel = ChannelBuilder.create(new ChannelUID(getThing().getUID(), thingKey)).withLabel(label) .withType(channelTypeUID).withAcceptedItemType(itemType).build(); if (getThing().getChannels().stream().noneMatch(c -> c.getUID().equals(channel.getUID()))) { - builder.withChannel(channel); + return channel; } } else { String valString = Objects.requireNonNullElse(value.toString(), "Null"); logUnknownChannelXmlAsync(thingKey, "Hint for type: " + valString); } + return null; } private String getChannelLabel(String thingKey) { - String returnValue = thingKey; @Nullable String tmp = Optional.ofNullable(bridgeHandler).map(handler -> { String labelKey = "channel-type.evcc." + thingKey + ".label"; @@ -173,71 +188,10 @@ private String getChannelLabel(String thingKey) { Locale locale = handler.getLocaleProvider().getLocale(); return tp.getText(ctx.getBundle(), labelKey, thingKey, locale); }).orElse(thingKey); - if (null != tmp) { - returnValue = tmp; - } - return returnValue; - } - - private ItemTypeUnit getItemType(ChannelTypeUID channelTypeUID) { - ChannelType channelType = channelTypeRegistry.getChannelType(channelTypeUID); - if (null != channelType) { - String itemType = channelType.getItemType(); - if (null != itemType) { - Unit unit = Utils.getUnitFromChannelType(itemType); - return new ItemTypeUnit(channelType, unit); - } - } - return new ItemTypeUnit(channelType, Units.ONE); - } - - private void setItemValue(ItemTypeUnit itemTypeUnit, ChannelUID channelUID, JsonElement value) { - if (value.isJsonNull() || itemTypeUnit.itemType.isEmpty()) { - return; - } - switch (itemTypeUnit.itemType) { - case CoreItemFactory.NUMBER: - case NUMBER_CURRENCY: - case NUMBER_ENERGY_PRICE: - updateState(channelUID, new DecimalType(value.getAsDouble())); - break; - case NUMBER_DIMENSIONLESS: - case NUMBER_ELECTRIC_CURRENT: - case NUMBER_EMISSION_INTENSITY: - case NUMBER_ENERGY: - case NUMBER_LENGTH: - case NUMBER_POWER: - Double finalValue = "%".equals(itemTypeUnit.unitHint) ? value.getAsDouble() / 100 : value.getAsDouble(); - if (channelUID.getId().contains("capacity") || "km".equals(itemTypeUnit.unitHint)) { - updateState(channelUID, new QuantityType<>(finalValue, itemTypeUnit.unit.multiply(1000))); - } else if ("Wh".equals(itemTypeUnit.unitHint)) { - updateState(channelUID, new QuantityType<>(finalValue, itemTypeUnit.unit.divide(1000))); - } else { - updateState(channelUID, new QuantityType<>(finalValue, itemTypeUnit.unit)); - } - break; - case NUMBER_TIME: - updateState(channelUID, QuantityType.valueOf(value.getAsDouble() + " s")); - break; - case NUMBER_TEMPERATURE: - updateState(channelUID, QuantityType.valueOf(value.getAsDouble() + " " + itemTypeUnit.unitHint)); - break; - case CoreItemFactory.DATETIME: - updateState(channelUID, new DateTimeType(value.getAsString())); - break; - case CoreItemFactory.STRING: - updateState(channelUID, new StringType(value.getAsString())); - break; - case CoreItemFactory.SWITCH: - updateState(channelUID, value.getAsBoolean() ? OnOffType.ON : OnOffType.OFF); - break; - default: - logUnknownChannelXmlAsync(channelUID.getId(), "Hint for type: " + value.toString()); - } + return null != tmp ? tmp : thingKey; } protected String getThingKey(String key) { - Map props = getThing().getProperties(); if ("batteryGridChargeLimit".equals(key) || "smartCostLimit".equals(key)) { if ("co2".equals(smartCostType)) { key += "Co2"; @@ -245,17 +199,16 @@ protected String getThingKey(String key) { key += "Price"; } } - String type = "heating".equals(props.get("type")) ? "loadpoint" : props.get("type"); + String type = "heating".equals(this.type) ? "loadpoint" : this.type; return (type + "-" + Utils.sanitizeChannelID(key)); } - @Override - public void updateFromEvccState(JsonObject state) { - if (!isInitialized || state.isEmpty()) { + public void updateStatesFromApiResponse(JsonObject jsonState) { + if (!isInitialized || jsonState.isEmpty()) { return; } - for (Map.Entry<@Nullable String, @Nullable JsonElement> entry : state.entrySet()) { + for (Map.Entry<@Nullable String, @Nullable JsonElement> entry : jsonState.entrySet()) { String key = entry.getKey(); JsonElement value = entry.getValue(); if (null == key || null == value || !value.isJsonPrimitive()) { @@ -267,10 +220,22 @@ public void updateFromEvccState(JsonObject state) { Channel existingChannel = getThing().getChannel(channelUID.getId()); if (existingChannel == null) { ThingBuilder builder = editThing(); - createChannel(thingKey, builder, value); - updateThing(builder.build()); + List channels = new ArrayList<>(getThing().getChannels()); + builder.withoutChannels(channels); + @Nullable + Channel newChannel = createChannel(thingKey, value); + if (null != newChannel) { + channels.add(newChannel); + channels.sort(Comparator.comparing(channel -> channel.getUID().getId())); + updateThing(builder.withChannels(channels).build()); + } } - setItemValue(getItemType(new ChannelTypeUID(BINDING_ID, channelUID.getId())), channelUID, value); + Optional.ofNullable(stateResolver).ifPresent(resolver -> { + State state = resolver.resolveState(key, value); + if (null != state) { + updateState(channelUID, state); + } + }); } updateStatus(ThingStatus.ONLINE); } @@ -285,9 +250,19 @@ protected void sendCommand(String url) { if (response.getStatus() == 200) { logger.debug("Sending command was successful"); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); - logger.debug("Sending command was unsuccessful, got this error:\n {}", - response.getContentAsString()); + @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); @@ -339,7 +314,7 @@ protected void logUnknownChannelXml(String key, String itemType) { veto - """, key, itemType, Utils.capitalizeWords(key)); + """, key, itemType, capitalize(key)); Path filePath = Paths.get(System.getProperty("user.dir"), "evcc", "unknown-channels.xml"); @@ -366,23 +341,4 @@ protected void logUnknownChannelXml(String key, String itemType) { logger.error("Failed to write unknown channel definition to file", e); } } - - private static class ItemTypeUnit { - private final Unit unit; - private final String unitHint; - private final String itemType; - - public ItemTypeUnit(@Nullable ChannelType type, @Nullable Unit unit) { - if (null == type) { - unitHint = ""; - itemType = "Unknown"; - } else { - String tmp = type.getUnitHint(); - unitHint = null != tmp ? tmp : ""; - tmp = type.getItemType(); - itemType = null != tmp ? tmp : "Unknown"; - } - this.unit = Objects.requireNonNullElse(unit, Units.ONE); - } - } } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandler.java index 6db67ac62f66c..a78e1fa4c4a25 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandler.java @@ -14,7 +14,7 @@ import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; -import java.util.Map; +import java.math.BigDecimal; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -37,35 +37,41 @@ public class EvccBatteryHandler extends EvccBaseThingHandler { public EvccBatteryHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); - Map props = thing.getProperties(); - String indexString = props.getOrDefault(PROPERTY_INDEX, "0"); - index = Integer.parseInt(indexString); + 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); + type = PROPERTY_TYPE_BATTERY; } @Override public void initialize() { super.initialize(); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { - JsonObject stateOpt = handler.getCachedEvccState(); + JsonObject stateOpt = handler.getCachedEvccState().deepCopy(); if (stateOpt.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); return; } - JsonObject state = stateOpt.getAsJsonArray(JSON_MEMBER_BATTERY).get(index).getAsJsonObject(); + JsonObject state = stateOpt.getAsJsonArray(JSON_KEY_BATTERY).get(index).getAsJsonObject(); commonInitialize(state); }); } @Override - public void updateFromEvccState(JsonObject state) { - state = state.has(JSON_MEMBER_BATTERY) ? state.getAsJsonArray(JSON_MEMBER_BATTERY).get(index).getAsJsonObject() + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + state = state.has(JSON_KEY_BATTERY) ? state.getAsJsonArray(JSON_KEY_BATTERY).get(index).getAsJsonObject() : new JsonObject(); - super.updateFromEvccState(state); + updateStatesFromApiResponse(state); } @Override public JsonObject getStateFromCachedState(JsonObject state) { - return state.getAsJsonArray(JSON_MEMBER_BATTERY).get(index).getAsJsonObject(); + return state.getAsJsonArray(JSON_KEY_BATTERY).get(index).getAsJsonObject(); } } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBridgeHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBridgeHandler.java index 5597b49572728..2471a1a47fb3d 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBridgeHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccBridgeHandler.java @@ -24,7 +24,7 @@ import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.http.HttpHeader; -import org.openhab.binding.evcc.internal.EvccConfiguration; +import org.openhab.binding.evcc.internal.EvccBridgeConfiguration; import org.openhab.binding.evcc.internal.discovery.EvccDiscoveryService; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.i18n.TranslationProvider; @@ -78,7 +78,7 @@ public Collection> getServices() { @Override public void initialize() { - EvccConfiguration config = getConfigAs(EvccConfiguration.class); + EvccBridgeConfiguration config = getConfigAs(EvccBridgeConfiguration.class); endpoint = config.scheme + "://" + config.host + ":" + config.port + "/api/state"; startPolling(config.pollInterval); @@ -150,12 +150,12 @@ public Optional fetchEvccState() { private void notifyListeners(JsonObject state) { for (EvccThingLifecycleAware listener : listeners) { try { - listener.updateFromEvccState(state); + listener.prepareApiResponseForChannelStateUpdate(state); } catch (Exception e) { if (listener instanceof BaseThingHandler handler) { logger.warn("Listener {} couldn't parse evcc state", handler.getThing().getUID(), e); } else { - logger.debug("Listener {} is not instance of BaseThingHandlder", listener, e); + logger.debug("Listener {} is not instance of BaseThingHandler", listener, e); } } } @@ -167,7 +167,9 @@ public JsonObject getCachedEvccState() { public void register(EvccThingLifecycleAware handler) { listeners.addIfAbsent(handler); - Optional.of(lastState).ifPresent(handler::updateFromEvccState); + if (!lastState.isEmpty()) { + handler.prepareApiResponseForChannelStateUpdate(lastState); + } } public void unregister(EvccThingLifecycleAware handler) { diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandler.java index 77af6b03de344..4ead6aef4c568 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandler.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.evcc.internal.handler; -import static org.openhab.binding.evcc.internal.EvccBindingConstants.JSON_MEMBER_LOADPOINTS; +import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; import java.util.Map; @@ -38,9 +38,11 @@ public class EvccHeatingHandler extends EvccLoadpointHandler { private final Logger logger = LoggerFactory.getLogger(EvccHeatingHandler.class); private static final Map JSON_KEYS = Map.ofEntries( - Map.entry("effectiveLimitTemperature", "effectiveLimitSoc"), - Map.entry("effectivePlanTemperature", "effectivePlanSoc"), Map.entry("limitTemperature", "limitSoc"), - Map.entry("vehicleLimitTemperature", "vehicleLimitSoc"), Map.entry("vehicleTemperature", "vehicleSoc")); + Map.entry("effectiveLimitTemperature", JSON_KEY_EFFECTIVE_LIMIT_SOC), + Map.entry("effectivePlanTemperature", JSON_KEY_EFFECTIVE_PLAN_SOC), + Map.entry("limitTemperature", JSON_KEY_LIMIT_SOC), + Map.entry("vehicleLimitTemperature", JSON_KEY_VEHICLE_LIMIT_SOC), + Map.entry("vehicleTemperature", JSON_KEY_VEHICLE_SOC)); public EvccHeatingHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); @@ -69,15 +71,15 @@ public void handleCommand(ChannelUID channelUID, Command command) { } @Override - public void updateFromEvccState(JsonObject state) { + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { updateJSON(state); - super.updateFromEvccState(state); + updateStatesFromApiResponse(state); } protected void updateJSON(JsonObject state) { - JsonObject heatingState = state.getAsJsonArray(JSON_MEMBER_LOADPOINTS).get(index).getAsJsonObject(); + JsonObject heatingState = state.getAsJsonArray(JSON_KEY_LOADPOINTS).get(index).getAsJsonObject(); renameJsonKeys(heatingState); // rename the json keys - state.getAsJsonArray(JSON_MEMBER_LOADPOINTS).set(index, heatingState); // Update the keys in the original JSON + state.getAsJsonArray(JSON_KEY_LOADPOINTS).set(index, heatingState); // Update the keys in the original JSON } private static void renameJsonKeys(JsonObject json) { diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandler.java index c554bc5cf9adb..f609b377e4f09 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandler.java @@ -14,6 +14,7 @@ import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; +import java.math.BigDecimal; import java.util.Map; import java.util.Optional; @@ -29,6 +30,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; /** @@ -40,14 +43,26 @@ public class EvccLoadpointHandler extends EvccBaseThingHandler { private final Logger logger = LoggerFactory.getLogger(EvccLoadpointHandler.class); + + // JSON keys that need a special treatment, in example for backwards compatibility + private static final Map JSON_KEYS = Map.ofEntries( + Map.entry(JSON_KEY_CHARGE_CURRENT, JSON_KEY_OFFERED_CURRENT), + Map.entry(JSON_KEY_VEHICLE_PRESENT, JSON_KEY_CONNECTED), + Map.entry(JSON_KEY_PHASES, JSON_KEY_PHASES_CONFIGURED), Map.entry(JSON_KEY_CHARGE_CURRENTS, ""), + Map.entry(JSON_KEY_CHARGE_VOLTAGES, "")); protected final int index; - private int[] version = {}; public EvccLoadpointHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); - Map props = thing.getProperties(); - String indexString = props.getOrDefault(PROPERTY_INDEX, "0"); - index = Integer.parseInt(indexString); + 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); + type = PROPERTY_TYPE_LOADPOINT; } @Override @@ -55,7 +70,7 @@ public void initialize() { super.initialize(); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { endpoint = handler.getBaseURL() + API_PATH_LOADPOINTS + "/" + (index + 1); - JsonObject stateOpt = handler.getCachedEvccState(); + JsonObject stateOpt = handler.getCachedEvccState().deepCopy(); if (stateOpt.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); return; @@ -65,7 +80,7 @@ public void initialize() { heating.updateJSON(stateOpt.getAsJsonObject()); } - JsonObject state = stateOpt.getAsJsonArray(JSON_MEMBER_LOADPOINTS).get(index).getAsJsonObject(); + JsonObject state = stateOpt.getAsJsonArray(JSON_KEY_LOADPOINTS).get(index).getAsJsonObject(); modifyJSON(state); commonInitialize(state); @@ -76,9 +91,9 @@ public void initialize() { public void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof State) { String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase(); - // Backwardscompatibility for phasesConfigured - if ("configuredPhases".equals(datapoint) && version[0] == 0 && version[1] < 200) { - datapoint = "phases"; + // Correct the datapoint for the API call + if ("phasesconfigured".equals(datapoint)) { + datapoint = JSON_KEY_PHASES; } // Special Handling for enable and disable endpoints if (datapoint.contains("enable")) { @@ -104,34 +119,38 @@ public void handleCommand(ChannelUID channelUID, Command command) { } @Override - public void updateFromEvccState(JsonObject state) { - version = Utils.convertVersionStringToIntArray(state.get("version").getAsString().split(" ")[0]); - state = state.getAsJsonArray(JSON_MEMBER_LOADPOINTS).get(index).getAsJsonObject(); + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + state = state.getAsJsonArray(JSON_KEY_LOADPOINTS).get(index).getAsJsonObject(); modifyJSON(state); - super.updateFromEvccState(state); + updateStatesFromApiResponse(state); } private void modifyJSON(JsonObject state) { - // This is for backward compatibility with older evcc versions - if (state.has("chargeCurrent")) { - state.addProperty("offeredCurrent", state.get("chargeCurrent").getAsDouble()); - state.remove("chargeCurrent"); - } - if (state.has("vehiclePresent")) { - state.add("connected", state.get("vehiclePresent")); - } - if (state.has("enabled")) { - state.add("charging", state.get("enabled")); - } - if (state.has("phases")) { - state.add("phasesConfigured", state.get("phases")); + JSON_KEYS.forEach((oldKey, newKey) -> { + if (state.has(oldKey)) { + if (oldKey.equals(JSON_KEY_CHARGE_CURRENTS)) { + addMeasurementDatapointToState(state, state.getAsJsonArray(oldKey), "Current"); + } else if (oldKey.equals(JSON_KEY_CHARGE_VOLTAGES)) { + addMeasurementDatapointToState(state, state.getAsJsonArray(oldKey), "Voltage"); + } else { + state.add(newKey, state.get(oldKey)); + } + state.remove(oldKey); + } + }); + } + + protected void addMeasurementDatapointToState(JsonObject state, JsonArray values, String datapoint) { + int phase = 1; + for (JsonElement value : values) { + state.add("charge" + datapoint + "L" + phase, value); + phase++; } } @Override public JsonObject getStateFromCachedState(JsonObject state) { - return state.has(JSON_MEMBER_LOADPOINTS) - ? state.getAsJsonArray(JSON_MEMBER_LOADPOINTS).get(index).getAsJsonObject() + return state.has(JSON_KEY_LOADPOINTS) ? state.getAsJsonArray(JSON_KEY_LOADPOINTS).get(index).getAsJsonObject() : new JsonObject(); } } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccPvHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccPvHandler.java index ff551ec91c5ba..d0f7895cf4c4e 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccPvHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccPvHandler.java @@ -14,7 +14,7 @@ import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; -import java.util.Map; +import java.math.BigDecimal; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -37,35 +37,41 @@ public class EvccPvHandler extends EvccBaseThingHandler { public EvccPvHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); - Map props = thing.getProperties(); - String indexString = props.getOrDefault(PROPERTY_INDEX, "0"); - index = Integer.parseInt(indexString); + 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); + type = PROPERTY_TYPE_PV; } @Override public void initialize() { super.initialize(); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { - JsonObject stateOpt = handler.getCachedEvccState(); + JsonObject stateOpt = handler.getCachedEvccState().deepCopy(); if (stateOpt.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); return; } - JsonObject state = stateOpt.getAsJsonArray(JSON_MEMBER_PV).get(index).getAsJsonObject(); + JsonObject state = stateOpt.getAsJsonArray(JSON_KEY_PV).get(index).getAsJsonObject(); commonInitialize(state); }); } @Override - public void updateFromEvccState(JsonObject state) { - state = state.getAsJsonArray(JSON_MEMBER_PV).get(index).getAsJsonObject(); - super.updateFromEvccState(state); + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + state = state.getAsJsonArray(JSON_KEY_PV).get(index).getAsJsonObject(); + updateStatesFromApiResponse(state); } @Override public JsonObject getStateFromCachedState(JsonObject state) { - return state.has(JSON_MEMBER_PV) ? state.getAsJsonArray(JSON_MEMBER_PV).get(index).getAsJsonObject() + return state.has(JSON_KEY_PV) ? state.getAsJsonArray(JSON_KEY_PV).get(index).getAsJsonObject() : new JsonObject(); } } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandler.java index 0a6110d8daaef..74123165f9922 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandler.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.evcc.internal.handler; +import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; + +import java.util.Map; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -26,6 +29,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; /** @@ -40,13 +45,14 @@ public class EvccSiteHandler extends EvccBaseThingHandler { public EvccSiteHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); + type = PROPERTY_TYPE_SITE; } @Override public void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof State) { String datapoint = Utils.getKeyFromChannelUID(channelUID).toLowerCase(); - String value = ""; + String value; if (command instanceof OnOffType) { value = command == OnOffType.ON ? "true" : "false"; } else { @@ -64,11 +70,11 @@ public void handleCommand(ChannelUID channelUID, Command command) { } @Override - public void updateFromEvccState(JsonObject state) { - if (state.has("gridConfigured")) { + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + if (state.has(JSON_KEY_GRID_CONFIGURED)) { modifyJSON(state); } - super.updateFromEvccState(state); + updateStatesFromApiResponse(state); } @Override @@ -76,18 +82,18 @@ public void initialize() { super.initialize(); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { endpoint = handler.getBaseURL(); - JsonObject state = handler.getCachedEvccState(); + JsonObject state = handler.getCachedEvccState().deepCopy(); if (state.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); return; } // Set the smart cost type - if (state.has("smartCostType") && !state.get("smartCostType").isJsonNull()) { - smartCostType = state.get("smartCostType").getAsString(); + if (state.has(JSON_KEY_SMART_COST_TYPE) && !state.get(JSON_KEY_SMART_COST_TYPE).isJsonNull()) { + smartCostType = state.get(JSON_KEY_SMART_COST_TYPE).getAsString(); } - if (state.has("gridConfigured")) { + if (state.has(JSON_KEY_GRID_CONFIGURED)) { modifyJSON(state); } commonInitialize(state); @@ -95,8 +101,25 @@ public void initialize() { } private void modifyJSON(JsonObject state) { - state.add("gridPower", state.getAsJsonObject("grid").get("power")); - state.remove("gridConfigured"); + for (Map.Entry entry : state.getAsJsonObject(JSON_KEY_GRID).entrySet()) { + if ("currents".equals(entry.getKey())) { + addMeasurementDatapointsToState(state, entry.getValue().getAsJsonArray(), "Current"); + } else if ("voltages".equals(entry.getKey())) { + addMeasurementDatapointsToState(state, entry.getValue().getAsJsonArray(), "Voltage"); + } else { + state.add(JSON_KEY_GRID + Utils.capitalizeFirstLetter(entry.getKey()), entry.getValue()); + } + } + state.remove(JSON_KEY_GRID); + state.remove(JSON_KEY_GRID_CONFIGURED); + } + + protected void addMeasurementDatapointsToState(JsonObject state, JsonArray values, String datapoint) { + int phase = 1; + for (JsonElement value : values) { + state.add("grid" + datapoint + "L" + phase, value); + phase++; + } } @Override diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandler.java index cd946993364dc..9c6d12fcf96e5 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandler.java @@ -24,6 +24,7 @@ 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.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,6 +44,7 @@ public class EvccStatisticsHandler extends EvccBaseThingHandler { public EvccStatisticsHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); + type = PROPERTY_TYPE_STATISTICS; } @Override @@ -52,6 +54,12 @@ public void initialize() { handler.register(this); updateStatus(ThingStatus.ONLINE); isInitialized = true; + JsonObject stateOpt = handler.getCachedEvccState().deepCopy(); + if (stateOpt.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + return; + } + prepareApiResponseForChannelStateUpdate(stateOpt); }); } @@ -61,8 +69,13 @@ public JsonObject getStateFromCachedState(JsonObject state) { } @Override - public void updateFromEvccState(JsonObject state) { - state = state.has(JSON_MEMBER_STATISTICS) ? state.getAsJsonObject(JSON_MEMBER_STATISTICS) : new JsonObject(); + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + state = state.has(JSON_KEY_STATISTICS) ? state.getAsJsonObject(JSON_KEY_STATISTICS) : new JsonObject(); + if (!isInitialized || state.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + return; + } + updateStatus(ThingStatus.ONLINE); for (String statisticsKey : state.keySet()) { JsonObject statistic = state.getAsJsonObject(statisticsKey); logger.debug("Extracting statistics for {}", statisticsKey); diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccThingLifecycleAware.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccThingLifecycleAware.java index d8c6af4a6479a..88376fd0ac924 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccThingLifecycleAware.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccThingLifecycleAware.java @@ -28,7 +28,7 @@ public interface EvccThingLifecycleAware { * * @param state the responded JSON */ - void updateFromEvccState(JsonObject state); + void prepareApiResponseForChannelStateUpdate(JsonObject state); /** * This method shall return the to the thing corresponding JSON object diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandler.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandler.java index 20d3ee856f341..cece9bcc2d94f 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandler.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandler.java @@ -46,7 +46,13 @@ public class EvccVehicleHandler extends EvccBaseThingHandler { public EvccVehicleHandler(Thing thing, ChannelTypeRegistry channelTypeRegistry) { super(thing, channelTypeRegistry); - vehicleId = thing.getProperties().get(PROPERTY_ID); + Object id = thing.getConfiguration().get(PROPERTY_ID); + if (id instanceof String s) { + vehicleId = s; + } else { + vehicleId = thing.getProperties().getOrDefault(PROPERTY_ID, ""); + } + type = PROPERTY_TYPE_VEHICLE; } @Override @@ -66,9 +72,9 @@ public void handleCommand(ChannelUID channelUID, Command command) { } @Override - public void updateFromEvccState(JsonObject state) { - state = state.getAsJsonObject(JSON_MEMBER_VEHICLES).getAsJsonObject(vehicleId); - super.updateFromEvccState(state); + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + state = state.getAsJsonObject(JSON_KEY_VEHICLES).getAsJsonObject(vehicleId); + updateStatesFromApiResponse(state); } @Override @@ -76,20 +82,20 @@ public void initialize() { super.initialize(); Optional.ofNullable(bridgeHandler).ifPresent(handler -> { endpoint = handler.getBaseURL() + API_PATH_VEHICLES; - JsonObject stateOpt = handler.getCachedEvccState(); + JsonObject stateOpt = handler.getCachedEvccState().deepCopy(); if (stateOpt.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); return; } - JsonObject state = stateOpt.getAsJsonObject(JSON_MEMBER_VEHICLES).getAsJsonObject(vehicleId); + JsonObject state = stateOpt.getAsJsonObject(JSON_KEY_VEHICLES).getAsJsonObject(vehicleId); commonInitialize(state); }); } @Override public JsonObject getStateFromCachedState(JsonObject state) { - return state.has(JSON_MEMBER_VEHICLES) ? state.getAsJsonObject(JSON_MEMBER_VEHICLES).getAsJsonObject(vehicleId) + return state.has(JSON_KEY_VEHICLES) ? state.getAsJsonObject(JSON_KEY_VEHICLES).getAsJsonObject(vehicleId) : new JsonObject(); } } diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/StateResolver.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/StateResolver.java new file mode 100644 index 0000000000000..a29d025cc5dd7 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/StateResolver.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.MetricPrefix; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.State; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +/** + * The {@link StateResolver} provides a resolver to determine the appropriate State type based on the JSON value and key + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public final class StateResolver { + + private static final StateResolver INSTANCE = new StateResolver(); + + public static StateResolver getInstance() { + return INSTANCE; + } + + /** + * Resolves a {@link State} object from a given JSON element based on the provided key. + * This method interprets the JSON value heuristically, mapping numeric values to + * {@link QuantityType} or {@link DecimalType}, strings to {@link StringType} or {@link DateTimeType}, + * and booleans to {@link OnOffType}. The key is used to infer the appropriate unit of measurement + * or semantic meaning. + * + * @param key the semantic key associated with the value (e.g. "Odometer", "Timestamp", "Price") + * @param value the JSON element to be converted into an openHAB {@link State} + * @return the resolved {@link State}, or {@code null} if the value is not a supported primitive + */ + @Nullable + public State resolveState(String key, JsonElement value) { + if (value.isJsonNull() || !value.isJsonPrimitive()) { + return null; + } + JsonPrimitive prim = value.getAsJsonPrimitive(); + if (prim.isNumber()) { + String lowerKey = key.toLowerCase(); + Number raw = prim.getAsNumber(); + Unit base = determineBaseUnitFromKey(key); + if (lowerKey.contains("price") || key.contains("tariff")) { + return new DecimalType(raw); + } else if (raw.toString().contains(".")) { + if ("energy".equals(lowerKey) || "gridenergy".equals(lowerKey) || "chargedenergy".equals(lowerKey) + || "pvenergy".equals(lowerKey) || "limitenergy".equals(lowerKey) + || "chargedkwh".equals(lowerKey) || lowerKey.contains("import") + || lowerKey.contains("capacity")) { + return new QuantityType<>(raw, MetricPrefix.KILO(base)); + } + } else if (lowerKey.contains("capacity") || lowerKey.contains("odometer") || lowerKey.contains("range") + || lowerKey.contains("import") || "limitenergy".equals(lowerKey)) { + return new QuantityType<>(raw, MetricPrefix.KILO(base)); + } + return new QuantityType<>(raw, base); + } else if (prim.isString()) { + if (key.contains("Timestamp")) { + return new DateTimeType(value.getAsString()); + } else { + return new StringType(value.getAsString()); + } + } else if (prim.isBoolean()) { + return value.getAsBoolean() ? OnOffType.ON : OnOffType.OFF; + } else { + return null; + } + } + + /** + * Determines the most appropriate base {@link Units} for a given key using keyword heuristics. + * This method performs a case-insensitive analysis of the key to infer the expected unit + * (e.g. "temperature" → °C, "energy" → Wh). It is used to assign meaningful units to numeric values + * when constructing {@link QuantityType} instances. + * + * @param key the semantic key to analyze (e.g. "chargingPower", "batterySoc", "sessionEnergy") + * @return the inferred {@link Units}/{@link SIUnits}, or {@link Units#ONE} if no specific unit is matched + */ + private Unit determineBaseUnitFromKey(String key) { + String lower = key.toLowerCase(); + + if (lower.contains("soc") || lower.contains("percentage")) { + return Units.PERCENT; + } + if (lower.contains("power") || lower.contains("threshold") || lower.contains("tariffsolar")) { + return Units.WATT; + } + if (lower.contains("energy") || lower.contains("capacity") || lower.contains("import")) { + return Units.WATT_HOUR; + } + if (lower.contains("temp") || lower.contains("temperature") || lower.contains("heating")) { + return SIUnits.CELSIUS; + } + if (lower.contains("voltage")) { + return Units.VOLT; + } + if (lower.contains("current")) { + return Units.AMPERE; + } + if (lower.contains("duration") || lower.contains("time") || lower.contains("delay") + || lower.contains("interval") || lower.contains("remaining") || lower.contains("overrun") + || lower.contains("precondition")) { + return Units.SECOND; + } + if (lower.contains("odometer") || lower.contains("distance") || lower.contains("range")) { + return SIUnits.METRE; + } + if (lower.contains("co2")) { + return Units.GRAM_PER_KILOWATT_HOUR; + } + return Units.ONE; + } +} diff --git a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/Utils.java b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/Utils.java index aa1e68d10bfa2..16fa3392b97a9 100644 --- a/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/Utils.java +++ b/bundles/org.openhab.binding.evcc/src/main/java/org/openhab/binding/evcc/internal/handler/Utils.java @@ -23,7 +23,6 @@ import java.util.Arrays; import java.util.Map; import java.util.Objects; -import java.util.StringJoiner; import javax.measure.Unit; @@ -55,26 +54,6 @@ public static Unit getUnitFromChannelType(String itemType) { return Objects.requireNonNullElse(unit, Units.ONE); } - /** - * This method will capitalize the words of a given string - * - * @param input string containing hyphenized words - * @return A string with spaces instead of hyphens and the first letter of each word is capitalized - */ - public static String capitalizeWords(String input) { - String[] allParts = input.split("-"); - String[] parts = Arrays.stream(allParts, 1, allParts.length).toArray(String[]::new); - StringJoiner joiner = new StringJoiner(" "); - - for (String part : parts) { - if (!part.isEmpty()) { - joiner.add(Character.toUpperCase(part.charAt(0)) + part.substring(1)); - } - } - - return joiner.toString(); - } - /** * This method will sanitize a given string for a channel ID * @@ -122,4 +101,17 @@ public static int[] convertVersionStringToIntArray(String input) { } return Arrays.stream(input.split("\\.")).mapToInt(Integer::parseInt).toArray(); } + + /** + * Capitalizes the first character of a string. + * + * @param input The input string. + * @return The string with the first character in uppercase. + */ + public static String capitalizeFirstLetter(String input) { + if (input.isEmpty()) { + return input; + } + return input.substring(0, 1).toUpperCase() + input.substring(1); + } } diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/i18n/evcc.properties b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/i18n/evcc.properties index c311b301f9721..ab24389c163d8 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/i18n/evcc.properties +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/i18n/evcc.properties @@ -32,6 +32,14 @@ thing-type.evcc.vehicle.description = A vehicle configured in your evcc instance # thing types config +thing-type.config.evcc.battery.index.label = Index +thing-type.config.evcc.battery.index.description = The index of the battery in your evcc setup (starting with 0) +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.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 thing-type.config.evcc.server.host.description = Address of your evcc instance, for example evcc.local or 192.168.1.2 thing-type.config.evcc.server.pollInterval.label = Polling Interval @@ -42,6 +50,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) # channel group types @@ -49,7 +59,7 @@ channel-group-type.evcc.statistics-group.label = Statistics Group # channel types -channel-type.evcc.avg-co2-type.label = Average C02 +channel-type.evcc.avg-co2-type.label = Average CO2 channel-type.evcc.avg-price-type.label = Average Price channel-type.evcc.battery-capacity.label = Battery Capacity channel-type.evcc.battery-capacity.description = Capacity of this battery @@ -68,6 +78,12 @@ channel-type.evcc.loadpoint-battery-boost.label = Battery Boost channel-type.evcc.loadpoint-battery-boost.description = Controls and indicates whether battery boost is active or not channel-type.evcc.loadpoint-battery-boost.state.option.ON = Activated channel-type.evcc.loadpoint-battery-boost.state.option.OFF = Deactivated +channel-type.evcc.loadpoint-charge-current-l1.label = Charge Current 1 +channel-type.evcc.loadpoint-charge-current-l1.description = Actual charge current of charger phase L1 +channel-type.evcc.loadpoint-charge-current-l2.label = Charge Current 2 +channel-type.evcc.loadpoint-charge-current-l2.description = Actual charge current of charger phase L2 +channel-type.evcc.loadpoint-charge-current-l3.label = Charge Current 3 +channel-type.evcc.loadpoint-charge-current-l3.description = Actual charge current of charger phase L3 channel-type.evcc.loadpoint-charge-duration.label = Charging Duration channel-type.evcc.loadpoint-charge-duration.description = Time passed since start of the charging channel-type.evcc.loadpoint-charge-power.label = Charging Power @@ -78,6 +94,12 @@ channel-type.evcc.loadpoint-charge-remaining-energy.label = Charging Remaining E channel-type.evcc.loadpoint-charge-remaining-energy.description = Remaining energy until limit SoC is reached channel-type.evcc.loadpoint-charge-total-import.label = Charge Total Import channel-type.evcc.loadpoint-charge-total-import.description = Total imported energy +channel-type.evcc.loadpoint-charge-voltage-l1.label = Charge Voltage 1 +channel-type.evcc.loadpoint-charge-voltage-l1.description = Actual charge voltage of charger phase L1 +channel-type.evcc.loadpoint-charge-voltage-l2.label = Charge Voltage 2 +channel-type.evcc.loadpoint-charge-voltage-l2.description = Actual charge voltage of charger phase L2 +channel-type.evcc.loadpoint-charge-voltage-l3.label = Charge Voltage 3 +channel-type.evcc.loadpoint-charge-voltage-l3.description = Actual charge voltage of charger phase L3 channel-type.evcc.loadpoint-charged-energy.label = Charged Energy channel-type.evcc.loadpoint-charged-energy.description = Energy charged since plugged-in channel-type.evcc.loadpoint-charger-feature-heating.label = Heating @@ -139,10 +161,10 @@ channel-type.evcc.loadpoint-enable-delay.label = Enable Delay channel-type.evcc.loadpoint-enable-delay.description = Delay in PV mode before starting the charging when enough power is available channel-type.evcc.loadpoint-enable-threshold.label = Enable Threshold channel-type.evcc.loadpoint-enable-threshold.description = Threshold for the enable delay -channel-type.evcc.loadpoint-enabled.label = Loadpoint Enabled -channel-type.evcc.loadpoint-enabled.description = Indicates whether the charger is locked or released -channel-type.evcc.loadpoint-enabled.state.option.ON = Released -channel-type.evcc.loadpoint-enabled.state.option.OFF = Locked +channel-type.evcc.loadpoint-enabled.label = Enabled +channel-type.evcc.loadpoint-enabled.description = Indicating whether this loadpoint is enabled or not +channel-type.evcc.loadpoint-enabled.state.option.ON = Enabled +channel-type.evcc.loadpoint-enabled.state.option.OFF = Disabled channel-type.evcc.loadpoint-limit-energy.label = Energy Limit channel-type.evcc.loadpoint-limit-energy.description = Energy limit when charging the vehicle channel-type.evcc.loadpoint-limit-soc.label = SoC Limit @@ -240,7 +262,7 @@ channel-type.evcc.loadpoint-vehicle-range.label = Vehicle Range channel-type.evcc.loadpoint-vehicle-range.description = Current range of the vehicle channel-type.evcc.loadpoint-vehicle-soc.label = Vehicle API SoC channel-type.evcc.loadpoint-vehicle-soc.description = Current state of charge of the vehicle -channel-type.evcc.loadpoint-vehicle-temperature.label = Temperature +channel-type.evcc.loadpoint-vehicle-temperature.label = Device Temperature channel-type.evcc.loadpoint-vehicle-temperature.description = Current temperature of the heating device channel-type.evcc.loadpoint-vehicle-title.label = Vehicle Title channel-type.evcc.loadpoint-vehicle-title.description = The name of the vehicle as displayed in the UI @@ -304,8 +326,22 @@ channel-type.evcc.site-green-share-home.label = Green Share Home channel-type.evcc.site-green-share-home.description = Indicates whether green power is available for home channel-type.evcc.site-green-share-loadpoints.label = Green Share Loadpoints channel-type.evcc.site-green-share-loadpoints.description = Indicates whether green power is available for loadpoints +channel-type.evcc.site-grid-current-l1.label = Grid Current 1 +channel-type.evcc.site-grid-current-l1.description = Grid phase 1 current +channel-type.evcc.site-grid-current-l2.label = Grid Current 2 +channel-type.evcc.site-grid-current-l2.description = Grid phase 2 current +channel-type.evcc.site-grid-current-l3.label = Grid Current 3 +channel-type.evcc.site-grid-current-l3.description = Grid phase 3 current +channel-type.evcc.site-grid-energy.label = Grid Energy +channel-type.evcc.site-grid-energy.description = Consumed energy from the grid channel-type.evcc.site-grid-power.label = Grid Power channel-type.evcc.site-grid-power.description = Current power from grid (negative means feed-in) +channel-type.evcc.site-grid-voltage-l1.label = Charge Voltage 1 +channel-type.evcc.site-grid-voltage-l1.description = Actual charge voltage of charger phase L1 +channel-type.evcc.site-grid-voltage-l2.label = Charge Voltage 2 +channel-type.evcc.site-grid-voltage-l2.description = Actual charge voltage of charger phase L2 +channel-type.evcc.site-grid-voltage-l3.label = Charge Voltage 3 +channel-type.evcc.site-grid-voltage-l3.description = Actual charge voltage of charger phase L3 channel-type.evcc.site-home-power.label = Home Power channel-type.evcc.site-home-power.description = Current power consumption by home channel-type.evcc.site-interval.label = Interval @@ -387,8 +423,3 @@ 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 - -# channel types - -channel-type.evcc.site-battery-grid-charge-limit.label = Grid Charging Limit -channel-type.evcc.site-battery-grid-charge-limit.description = Below this smart cost limit the charging from grid will start (currency or co2) diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/battery.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/battery.xml index 2a82cb1b63d6e..dc32130833551 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/battery.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/battery.xml @@ -10,6 +10,12 @@ A battery configured in your evcc instance + + + + The index of the battery in your evcc setup (starting with 0) + + Number:Energy diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/heating.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/heating.xml index ba82a73040d0e..3d91e3bac9981 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/heating.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/heating.xml @@ -11,6 +11,12 @@ A heating loadpoint configured in your evcc instance index + + + + The index of the heating loadpoint in your evcc setup (starting with 0) + + Number:Temperature @@ -58,7 +64,7 @@ Number:Temperature - + Current temperature of the heating device Temperature diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/loadpoint.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/loadpoint.xml index a8bd1c4166018..0e4d84f30c49f 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/loadpoint.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/loadpoint.xml @@ -10,6 +10,12 @@ A loadpoint configured in your evcc instance index + + + + The index of the loadpoint in your evcc setup (starting with 0) + + Switch @@ -188,7 +194,7 @@ Status Mode - + @@ -284,6 +290,21 @@ + + Switch + + Indicating whether this loadpoint is enabled or not + + Status + Enabled + + + + + + + + DateTime @@ -429,6 +450,72 @@ + + Number:ElectricCurrent + + Actual charge current of charger phase L1 + Energy + + Measurement + Current + + + + + Number:ElectricCurrent + + Actual charge current of charger phase L2 + Energy + + Measurement + Current + + + + + Number:ElectricCurrent + + Actual charge current of charger phase L3 + Energy + + Measurement + Current + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L1 + Energy + + Measurement + Voltage + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L2 + Energy + + Measurement + Voltage + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L3 + Energy + + Measurement + Voltage + + + Number diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/pv.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/pv.xml index cd944831efede..8d842bb767123 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/pv.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/pv.xml @@ -10,6 +10,12 @@ A photovoltaic system configured in your evcc instance + + + + The index of the photovoltaic system in your evcc setup (starting with 0) + + diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/site.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/site.xml index bb3f40cb3c1da..936087b819fef 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/site.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/site.xml @@ -195,6 +195,83 @@ Indicates whether green power is available for loadpoints + + Number:ElectricCurrent + + Grid phase 1 current + Energy + + Measurement + Current + + + + + Number:ElectricCurrent + + Grid phase 2 current + Energy + + Measurement + Current + + + + + Number:ElectricCurrent + + Grid phase 3 current + Energy + + Measurement + Current + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L1 + Energy + + Measurement + Voltage + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L2 + Energy + + Measurement + Voltage + + + + + Number:ElectricPotential + + Actual charge voltage of charger phase L3 + Energy + + Measurement + Voltage + + + + + Number:Energy + + Consumed energy from the grid + Energy + + Measurement + Energy + + + Number:Power @@ -237,14 +314,14 @@ - Number + Number:Time Refresh interval configured for evcc Settings - + - Number:Energy + Number:Energy Accumulated energy provided through photovoltaic systems Energy diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/statistics.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/statistics.xml index 808abca29a182..44158112784f4 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/statistics.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/statistics.xml @@ -42,7 +42,7 @@ Number:EmissionIntensity - + Calculation Info diff --git a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/vehicle.xml b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/vehicle.xml index 8268a8f353fd8..c2f4828812c8d 100644 --- a/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/vehicle.xml +++ b/bundles/org.openhab.binding.evcc/src/main/resources/OH-INF/thing/vehicle.xml @@ -10,6 +10,12 @@ A vehicle configured in your evcc instance id + + + + The ID of the vehicle in your evcc setup (database ID) + + Number:Energy diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/AbstractThingHandlerTestClass.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/AbstractThingHandlerTestClass.java new file mode 100644 index 0000000000000..a328b368d265c --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/AbstractThingHandlerTestClass.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.BeforeAll; +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.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 com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Abstract base test for EvccBaseThingHandler implementations. + * Extend this class in your handler tests and override createHandler(). + * + * @author Marcel Goerentz - Initial contribution + */ +@SuppressWarnings("null") +@NonNullByDefault +public abstract class AbstractThingHandlerTestClass { + + protected Thing thing = mock(Thing.class); + protected ChannelTypeRegistry channelTypeRegistry = mock(ChannelTypeRegistry.class); + @Nullable + protected T handler = null; + + protected ThingStatus lastThingStatus = ThingStatus.UNKNOWN; + protected ThingStatusDetail lastThingStatusDetail = ThingStatusDetail.NONE; + + protected static JsonObject exampleResponse = new JsonObject(); + protected static JsonObject verifyObject = new JsonObject(); + + /** + * Implement this to provide a handler instance for testing. + */ + protected abstract T createHandler(); + + @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(); + verifyObject = exampleResponse.deepCopy(); + + } catch (IOException e) { + fail("Failed to read example response file", e); + } + } + + @Nested + class InitializeTests { + + @BeforeEach + public void setUp() { + handler = spy(createHandler()); + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "battery")); + 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(thing.getConfiguration()).thenReturn(configuration); + } + + @Test + public void initializeWithoutBridgeHandler() { + handler.initialize(); + assertEquals(ThingStatus.OFFLINE, lastThingStatus); + assertEquals(ThingStatusDetail.BRIDGE_UNINITIALIZED, lastThingStatusDetail); + } + + @Test + public void initializeWithBridgeHandlerWithoutCachedState() { + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(new JsonObject()); + + handler.initialize(); + assertEquals(ThingStatus.OFFLINE, lastThingStatus); + assertEquals(ThingStatusDetail.COMMUNICATION_ERROR, lastThingStatusDetail); + } + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/BaseThingHandlerTestClass.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/BaseThingHandlerTestClass.java new file mode 100644 index 0000000000000..ae69dcf525e31 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/BaseThingHandlerTestClass.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * The {@link BaseThingHandlerTestClass} is responsible for creating a subclass for testing the BaseThingHandler + * implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class BaseThingHandlerTestClass extends EvccBaseThingHandler { + public boolean createChannelCalled = false; + public boolean updateThingCalled = false; + public boolean updateStatusCalled = false; + public boolean prepareApiResponseForChannelStateUpdateCalled = true; + public boolean logUnknownChannelXmlCalled = false; + public ThingStatus lastUpdatedStatus = ThingStatus.UNKNOWN; + public boolean updateStateCalled = false; + public State lastState = UnDefType.UNDEF; + public ChannelUID lastChannelUID = new ChannelUID("dummy:dummy:dummy:dummy"); + + public BaseThingHandlerTestClass(Thing thing, ChannelTypeRegistry registry) { + super(thing, registry); + } + + @Override + protected void updateThing(Thing thing) { + updateThingCalled = true; + } + + @Override + protected void updateStatus(ThingStatus status) { + lastUpdatedStatus = status; + updateStatusCalled = true; + } + + @Override + @Nullable + protected Channel createChannel(String thingKey, JsonElement value) { + createChannelCalled = true; + return super.createChannel(thingKey, value); + } + + @Override + public void prepareApiResponseForChannelStateUpdate(JsonObject state) { + prepareApiResponseForChannelStateUpdateCalled = true; + super.updateStatesFromApiResponse(state); + } + + @Override + public JsonObject getStateFromCachedState(JsonObject state) { + return new JsonObject(); + } + + @Override + public void updateState(ChannelUID uid, State state) { + updateStateCalled = true; + lastState = state; + lastChannelUID = uid; + } + + // Make sure no files are getting created + @Override + protected void logUnknownChannelXml(String key, String itemType) { + } + + @Override + public void logUnknownChannelXmlAsync(String key, String itemType) { + logUnknownChannelXmlCalled = true; + super.logUnknownChannelXmlAsync(key, itemType); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandlerTest.java new file mode 100644 index 0000000000000..22530d425993a --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBaseThingHandlerTest.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; +import static org.openhab.binding.evcc.internal.EvccBindingConstants.NUMBER_ENERGY; + +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jdt.annotation.NonNullByDefault; +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.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +/** + * The {@link EvccBaseThingHandlerTest} is responsible for testing the EvccBaseThingHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccBaseThingHandlerTest { + + @SuppressWarnings("null") + private final Thing thing = mock(Thing.class); + + @SuppressWarnings("null") + private final ChannelTypeRegistry channelTypeRegistry = mock(ChannelTypeRegistry.class); + + private BaseThingHandlerTestClass handler = new BaseThingHandlerTestClass(thing, channelTypeRegistry); + + @SuppressWarnings("null") + @BeforeEach + public void setUp() { + handler = spy(new BaseThingHandlerTestClass(thing, channelTypeRegistry)); + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "battery")); + 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(thing.getConfiguration()).thenReturn(configuration); + } + + @Nested + class UpdateStatesFromApiResponseTests { + @Test + public void updateFromEvccStateNotInitializedDoesNothing() { + handler.isInitialized = false; + JsonObject state = new JsonObject(); + handler.updateStatesFromApiResponse(state); + assertTrue(handler.prepareApiResponseForChannelStateUpdateCalled); + assertFalse(handler.createChannelCalled); + assertFalse(handler.updateThingCalled); + assertFalse(handler.updateStatusCalled); + assertEquals(ThingStatus.UNKNOWN, handler.lastUpdatedStatus); + } + + @Test + public void updateFromEvccStateEmptyStateDoesNothing() { + handler.isInitialized = true; + JsonObject state = new JsonObject(); + handler.updateStatesFromApiResponse(state); + assertTrue(handler.prepareApiResponseForChannelStateUpdateCalled); + assertFalse(handler.createChannelCalled); + assertFalse(handler.updateThingCalled); + // Status should not be updated for empty state + assertFalse(handler.updateStatusCalled); + assertEquals(ThingStatus.UNKNOWN, handler.lastUpdatedStatus); + } + + @SuppressWarnings("null") + @Test + public void updateFromEvccStateWithPrimitiveValueCreatesChannelAndSetsItemValue() { + handler.isInitialized = true; + JsonObject state = new JsonObject(); + state.add("capacity", new JsonPrimitive(5.5)); + // Channel does not exist + when(thing.getChannel(anyString())).thenReturn(null); + ChannelType mockChannelType = mock(ChannelType.class); + when(mockChannelType.getItemType()).thenReturn(NUMBER_ENERGY); + when(channelTypeRegistry.getChannelType(any())).thenReturn(mockChannelType); + + handler.updateStatesFromApiResponse(state); + assertTrue(handler.prepareApiResponseForChannelStateUpdateCalled); + assertTrue(handler.createChannelCalled); + assertTrue(handler.updateThingCalled); + assertTrue(handler.updateStatusCalled); + assertEquals(ThingStatus.ONLINE, handler.lastUpdatedStatus); + } + + @Test + public void updateFromEvccStateWithExistingChannelDoesNotCreateChannel() { + handler.isInitialized = true; + JsonObject state = new JsonObject(); + state.add("capacity", new JsonPrimitive(5.5)); + @SuppressWarnings("null") + Channel mockChannel = mock(Channel.class); + when(thing.getChannel(anyString())).thenReturn(mockChannel); + + handler.updateStatesFromApiResponse(state); + + assertTrue(handler.prepareApiResponseForChannelStateUpdateCalled); + assertFalse(handler.createChannelCalled); + assertFalse(handler.updateThingCalled); // Should not update thing if channel exists + assertTrue(handler.updateStatusCalled); + assertEquals(ThingStatus.ONLINE, handler.lastUpdatedStatus); + } + + @Test + public void updateFromEvccStateSkipsNonPrimitiveValues() { + handler.isInitialized = true; + JsonObject state = new JsonObject(); + JsonObject nonPrimitive = new JsonObject(); + nonPrimitive.addProperty("foo", "bar"); + state.add("complexKey", nonPrimitive); + + handler.updateStatesFromApiResponse(state); + + assertTrue(handler.prepareApiResponseForChannelStateUpdateCalled); + assertFalse(handler.createChannelCalled); + assertFalse(handler.updateThingCalled); + assertTrue(handler.updateStatusCalled); // Status is updated even if nothing else happens + assertEquals(ThingStatus.ONLINE, handler.lastUpdatedStatus); + } + + @Test + void updateStatesFromApiResponseWithNullValueDoesNothing() { + handler.isInitialized = true; + JsonObject state = new JsonObject(); + state.add("capacity", null); // Null value + handler.updateStatesFromApiResponse(state); + assertFalse(handler.createChannelCalled); + assertFalse(handler.updateThingCalled); + assertTrue(handler.updateStatusCalled); + assertEquals(ThingStatus.ONLINE, handler.lastUpdatedStatus); + } + } + + @Nested + class HandleCommandTests { + @SuppressWarnings("null") + @Test + public void handleCommandWithNumberItemType() { + ChannelUID channelUID = new ChannelUID("test:thing:uid:battery-capacity"); + RefreshType command = RefreshType.REFRESH; + + JsonObject cachedState = new JsonObject(); + cachedState.add("capacity", new JsonPrimitive(42.0)); + + ChannelType mockChannelType = mock(ChannelType.class); + when(mockChannelType.getItemType()).thenReturn(NUMBER_ENERGY); + when(channelTypeRegistry.getChannelType(any())).thenReturn(mockChannelType); + + doReturn(cachedState).when(handler).getStateFromCachedState(any()); + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + handler.handleCommand(channelUID, command); + } + + @SuppressWarnings("null") + @Test + public void handleCommandWithRefreshTypeAndValidValue() { + ChannelUID channelUID = new ChannelUID("test:thing:uid:battery-capacity"); + RefreshType command = RefreshType.REFRESH; + + JsonObject cachedState = new JsonObject(); + cachedState.add("capacity", new JsonPrimitive(42.0)); + + ChannelType mockChannelType = mock(ChannelType.class); + when(mockChannelType.getItemType()).thenReturn(NUMBER_ENERGY); + when(channelTypeRegistry.getChannelType(any())).thenReturn(mockChannelType); + + doReturn(cachedState).when(handler).getStateFromCachedState(any()); + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + handler.handleCommand(channelUID, command); + } + + @SuppressWarnings("null") + @Test + public void handleCommandWithRefreshTypeAndMissingValue() { + ChannelUID channelUID = new ChannelUID("test:thing:uid:battery-capacity"); + RefreshType command = RefreshType.REFRESH; + + JsonObject cachedState = new JsonObject(); // no "capacity" key + doReturn(cachedState).when(handler).getStateFromCachedState(any()); + + handler.handleCommand(channelUID, command); + assertFalse(handler.logUnknownChannelXmlCalled); + } + + @SuppressWarnings("null") + @Test + void handleCommandWithNonRefreshTypeDoesNothing() { + ChannelUID channelUID = new ChannelUID("test:thing:uid:battery-capacity"); + Command command = mock(org.openhab.core.types.Command.class); + handler.handleCommand(channelUID, command); + } + } + + @Test + public void testCreateChannelWithUnknownItemType() { + JsonElement value = new JsonPrimitive(5.5); + @SuppressWarnings("null") + ThingBuilder builder = mock(ThingBuilder.class); + + @SuppressWarnings("null") + ChannelType mockChannelType = mock(ChannelType.class); + when(mockChannelType.getItemType()).thenReturn("Unknown"); + when(channelTypeRegistry.getChannelType(any())).thenReturn(mockChannelType); + + handler.createChannel("capacity", value); + + assertTrue(handler.createChannelCalled); + verify(builder, never()).withChannel(any()); + } + + @Nested + class GetThingKeyTests { + @Test + public void getThingKeyWithBatteryTypeAndSpecialKey() { + handler.type = "battery"; + + String key = "soc"; + String result = handler.getThingKey(key); + + assertEquals("battery-soc", result); + } + + @Test + public void getThingKeyWithHeatingType() { + handler.type = "loadpoint"; + + String key = "capacity"; + String result = handler.getThingKey(key); + + assertEquals("loadpoint-capacity", result); + } + + @Test + public void getThingKeyWithDefaultType() { + handler.type = "loadpoint"; + + String key = "someKey"; + String result = handler.getThingKey(key); + + assertEquals("loadpoint-some-key", result); + } + } + + @SuppressWarnings("null") + @Test + void logUnknownChannelXmlAsyncIsCalledAsynchronously() { + CompletableFuture future = new CompletableFuture<>(); + doAnswer(invocation -> { + future.complete(true); + return null; + }).when(handler).logUnknownChannelXml(anyString(), anyString()); + + handler.logUnknownChannelXmlAsync("testKey", "testType"); + + try { + future.get(2, SECONDS); + } catch (Exception e) { + fail("logUnknownChannelXml was not called asynchronously"); + } + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandlerTest.java new file mode 100644 index 0000000000000..384fcd152c0ed --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccBatteryHandlerTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +import com.google.gson.JsonObject; + +/** + * The {@link EvccBatteryHandlerTest} is responsible for testing the EvccBatteryHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccBatteryHandlerTest extends AbstractThingHandlerTestClass { + + private static JsonObject batteryState = new JsonObject(); + + @Override + protected EvccBatteryHandler createHandler() { + return new EvccBatteryHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @SuppressWarnings("null") + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "battery")); + 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(thing.getConfiguration()).thenReturn(configuration); + handler = spy(createHandler()); + + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + batteryState = exampleResponse.getAsJsonArray("battery").get(0).getAsJsonObject(); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsNotInitialized() { + handler.isInitialized = false; + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + verify(handler).updateStatesFromApiResponse(batteryState); + assertSame(ThingStatus.UNKNOWN, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsInitialized() { + handler.isInitialized = true; + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testGetStateFromCachedState() { + JsonObject result = handler.getStateFromCachedState(exampleResponse); + assertSame(batteryState, result); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandlerTest.java new file mode 100644 index 0000000000000..ef6718d4b347e --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccHeatingHandlerTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; +import static org.openhab.binding.evcc.internal.EvccBindingConstants.*; + +import java.util.ArrayList; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +import com.google.gson.JsonObject; + +/** + * The {@link EvccHeatingHandlerTest} is responsible for testing the EvccHeatingHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccHeatingHandlerTest extends AbstractThingHandlerTestClass { + + private JsonObject heatingObject = new JsonObject(); + + @Override + protected EvccHeatingHandler createHandler() { + return new EvccHeatingHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @SuppressWarnings("null") + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "heating")); + 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(thing.getConfiguration()).thenReturn(configuration); + handler = spy(createHandler()); + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + + heatingObject = verifyObject.getAsJsonArray(JSON_KEY_LOADPOINTS).get(0).getAsJsonObject(); + heatingObject.remove(JSON_KEY_EFFECTIVE_LIMIT_SOC); + heatingObject.remove(JSON_KEY_VEHICLE_SOC); + heatingObject.remove(JSON_KEY_LIMIT_SOC); + heatingObject.remove(JSON_KEY_EFFECTIVE_PLAN_SOC); + heatingObject.remove(JSON_KEY_VEHICLE_LIMIT_SOC); + heatingObject.addProperty("effectiveLimitTemperature", 80); + heatingObject.addProperty("vehicleTemperature", 48.3); + heatingObject.addProperty("limitTemperature", 80); + heatingObject.addProperty("effectivePlanTemperature", 20); + heatingObject.addProperty("vehicleLimitTemperature", 80); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsInitialized() { + handler.isInitialized = true; + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsNotInitialized() { + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.UNKNOWN, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testJsonGetsModifiedCorrectly() { + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertEquals(heatingObject, exampleResponse.getAsJsonArray("loadpoints").get(0)); + } + + @SuppressWarnings("null") + @Test + public void testGetStateFromCachedState() { + JsonObject result = handler.getStateFromCachedState(exampleResponse); + assertSame(exampleResponse.getAsJsonArray("loadpoints").get(0), result); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandlerTest.java new file mode 100644 index 0000000000000..4128865ed53d8 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccLoadpointHandlerTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +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.util.ArrayList; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * The {@link EvccLoadpointHandlerTest} is responsible for testing the EvccLoadpointHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccLoadpointHandlerTest extends AbstractThingHandlerTestClass { + + private final JsonObject modifiedTestState = exampleResponse.deepCopy(); + private final JsonObject testObject = exampleResponse.getAsJsonArray("loadpoints").get(0).getAsJsonObject(); + private final JsonObject modifiedVerifyObject = verifyObject.deepCopy().getAsJsonArray("loadpoints").get(0) + .getAsJsonObject(); + + @Override + protected EvccLoadpointHandler createHandler() { + return new EvccLoadpointHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "loadpoint")); + 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(thing.getConfiguration()).thenReturn(configuration); + handler = spy(createHandler()); + + modifiedVerifyObject.addProperty("chargedEnergy", 50); + modifiedVerifyObject.addProperty(JSON_KEY_OFFERED_CURRENT, 6); + modifiedVerifyObject.addProperty(JSON_KEY_CONNECTED, true); + modifiedVerifyObject.addProperty(JSON_KEY_PHASES_CONFIGURED, "3"); + modifiedVerifyObject.addProperty("chargeCurrentL1", 6); + modifiedVerifyObject.addProperty("chargeCurrentL2", 7); + modifiedVerifyObject.addProperty("chargeCurrentL3", 8); + modifiedVerifyObject.addProperty("chargeVoltageL1", 230.0); + modifiedVerifyObject.addProperty("chargeVoltageL2", 231.0); + modifiedVerifyObject.addProperty("chargeVoltageL3", 229.0); + modifiedVerifyObject.remove(JSON_KEY_CHARGE_CURRENT); + modifiedVerifyObject.remove(JSON_KEY_VEHICLE_PRESENT); + modifiedVerifyObject.remove(JSON_KEY_PHASES); + + testObject.addProperty("chargedEnergy", 50); + testObject.addProperty(JSON_KEY_CHARGE_CURRENT, 6); + testObject.addProperty(JSON_KEY_VEHICLE_PRESENT, true); + testObject.addProperty(JSON_KEY_PHASES, "3"); + JsonArray currents = new JsonArray(); + currents.add(6); + currents.add(7); + currents.add(8); + testObject.add(JSON_KEY_CHARGE_CURRENTS, currents); + JsonArray voltages = new JsonArray(); + voltages.add(230.0); + voltages.add(231.0); + voltages.add(229.0); + testObject.add(JSON_KEY_CHARGE_VOLTAGES, voltages); + JsonArray loadpointArray = exampleResponse.getAsJsonArray("loadpoints"); + loadpointArray.set(0, testObject); + modifiedTestState.add("loadpoints", loadpointArray); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + handler.isInitialized = true; + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsNotInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.UNKNOWN, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testJsonGetsModifiedCorrectly() { + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertEquals(modifiedVerifyObject, modifiedTestState.getAsJsonArray("loadpoints").get(0)); + } + + @SuppressWarnings("null") + @Test + public void testGetStateFromCachedState() { + JsonObject result = handler.getStateFromCachedState(exampleResponse); + assertSame(exampleResponse.getAsJsonArray("loadpoints").get(0), result); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccPvHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccPvHandlerTest.java new file mode 100644 index 0000000000000..47cc47855c970 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccPvHandlerTest.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * The {@link EvccSiteHandlerTest} is responsible for testing the EvccPvHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccPvHandlerTest extends AbstractThingHandlerTestClass { + + private final JsonObject testState = new JsonObject(); + private final JsonObject testObject = new JsonObject(); + private final JsonObject verifyObject = new JsonObject(); + + @Override + protected EvccPvHandler createHandler() { + return new EvccPvHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "pv")); + 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(thing.getConfiguration()).thenReturn(configuration); + handler = spy(createHandler()); + + verifyObject.addProperty("power", 2000); + + testObject.addProperty("power", 2000); + JsonArray loadpointArray = new JsonArray(); + loadpointArray.add(testObject); + testState.add("pv", loadpointArray); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(testState); + + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + handler.isInitialized = true; + + handler.prepareApiResponseForChannelStateUpdate(testState); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testPrepareApiResponseForChannelStateUpdateIsNotInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + handler.prepareApiResponseForChannelStateUpdate(testState); + assertSame(ThingStatus.UNKNOWN, lastThingStatus); + } + + @SuppressWarnings("null") + @Test + public void testGetStateFromCachedState() { + JsonObject result = handler.getStateFromCachedState(testState); + assertSame(testState.getAsJsonArray("pv").get(0), result); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandlerTest.java new file mode 100644 index 0000000000000..2e7ae422b3317 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccSiteHandlerTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +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.Nested; +import org.junit.jupiter.api.Test; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * The {@link EvccSiteHandlerTest} is responsible for testing the EvccSiteHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccSiteHandlerTest extends AbstractThingHandlerTestClass { + + private final JsonObject gridConfigured = new JsonObject(); + private final JsonObject modifiedVerifyObject = verifyObject.deepCopy(); + + @Override + protected EvccSiteHandler createHandler() { + return new EvccSiteHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "pv")); + when(thing.getChannels()).thenReturn(new ArrayList<>()); + handler = spy(createHandler()); + + modifiedVerifyObject.addProperty("gridPower", 2000); + modifiedVerifyObject.addProperty("gridEnergy", 10000); + modifiedVerifyObject.addProperty("gridCurrentL1", 6); + modifiedVerifyObject.addProperty("gridCurrentL2", 7); + modifiedVerifyObject.addProperty("gridCurrentL3", 8); + modifiedVerifyObject.addProperty("gridVoltageL1", 230.0); + modifiedVerifyObject.addProperty("gridVoltageL2", 231.0); + modifiedVerifyObject.addProperty("gridVoltageL3", 229.0); + modifiedVerifyObject.remove("gridConfigured"); + modifiedVerifyObject.remove("grid"); + + gridConfigured.addProperty("power", 2000); + gridConfigured.addProperty("energy", 10000); + JsonArray currents = new JsonArray(); + currents.add(6); + currents.add(7); + currents.add(8); + gridConfigured.add("currents", currents); + JsonArray voltages = new JsonArray(); + voltages.add(230.0); + voltages.add(231.0); + voltages.add(229.0); + gridConfigured.add("voltages", voltages); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @SuppressWarnings("null") + @Nested + public class TestPrepareApiResponseForChannelStateUpdate { + + @Test + public void handlerIsInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + handler.isInitialized = true; + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @Test + public void handlerIsNotInitialized() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.UNKNOWN, lastThingStatus); + } + + @Test + public void stateContainsGridConfigured() { + handler.bridgeHandler = mock(EvccBridgeHandler.class); + + exampleResponse.addProperty("gridConfigured", true); + exampleResponse.add("grid", gridConfigured); + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertEquals(modifiedVerifyObject, exampleResponse); + } + } + + @SuppressWarnings("null") + @Test + public void testGetStateFromCachedState() { + JsonObject result = handler.getStateFromCachedState(exampleResponse); + assertSame(exampleResponse, result); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandlerTest.java new file mode 100644 index 0000000000000..989a761faf576 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccStatisticsHandlerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +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.Nested; +import org.junit.jupiter.api.Test; +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.State; + +/** + * The {@link EvccStatisticsHandlerTest} is responsible for testing the EvccSiteHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccStatisticsHandlerTest extends AbstractThingHandlerTestClass { + + private boolean updateStateCalled = false; + private int updateStateCounter = 0; + + @Override + protected EvccStatisticsHandler createHandler() { + return new EvccStatisticsHandler(thing, channelTypeRegistry) { + + @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++; + } + }; + } + + @SuppressWarnings("null") + @BeforeEach + public void setup() { + when(thing.getUID()).thenReturn(new ThingUID("test:thing:uid")); + when(thing.getProperties()).thenReturn(Map.of("index", "0", "type", "statistics")); + when(thing.getChannels()).thenReturn(new ArrayList<>()); + handler = spy(createHandler()); + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + } + + @SuppressWarnings("null") + @Nested + public class TestPrepareApiResponseForChannelStateUpdate { + + @Test + public void handlerIsInitialized() { + handler.isInitialized = true; + + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertTrue(updateStateCalled); + assertEquals(16, updateStateCounter); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } + + @Test + public void handlerIsNotInitialized() { + handler.prepareApiResponseForChannelStateUpdate(exampleResponse); + assertSame(ThingStatus.OFFLINE, lastThingStatus); + } + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandlerTest.java b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandlerTest.java new file mode 100644 index 0000000000000..5c248f98510a4 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/java/org/openhab/binding/evcc/internal/handler/EvccVehicleHandlerTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.evcc.internal.handler; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.types.State; + +/** + * The {@link EvccVehicleHandlerTest} is responsible for testing the EvccSiteHandler implementation + * + * @author Marcel Goerentz - Initial contribution + */ +@NonNullByDefault +public class EvccVehicleHandlerTest extends AbstractThingHandlerTestClass { + + @Override + protected EvccVehicleHandler createHandler() { + return new EvccVehicleHandler(thing, channelTypeRegistry) { + + @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 updateThing(Thing thing) { + } + + @Override + protected void updateState(ChannelUID channelUID, State state) { + } + }; + } + + @SuppressWarnings("null") + @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.getChannels()).thenReturn(new ArrayList<>()); + Configuration configuration = mock(Configuration.class); + when(configuration.get("index")).thenReturn("0"); + when(configuration.get("id")).thenReturn("vehicle_1"); + when(thing.getConfiguration()).thenReturn(configuration); + handler = spy(createHandler()); + EvccBridgeHandler bridgeHandler = mock(EvccBridgeHandler.class); + handler.bridgeHandler = bridgeHandler; + when(bridgeHandler.getCachedEvccState()).thenReturn(exampleResponse); + } + + @SuppressWarnings("null") + @Test + public void testInitializeWithBridgeHandlerWithValidState() { + handler.initialize(); + assertSame(ThingStatus.ONLINE, lastThingStatus); + } +} diff --git a/bundles/org.openhab.binding.evcc/src/test/resources/responses/example_response.json b/bundles/org.openhab.binding.evcc/src/test/resources/responses/example_response.json new file mode 100644 index 0000000000000..ac8fbc60afa06 --- /dev/null +++ b/bundles/org.openhab.binding.evcc/src/test/resources/responses/example_response.json @@ -0,0 +1,1243 @@ +{ + "authProviders": { + }, + "aux": [ + ], + "battery": [ + { + "power": 188.78599363476042, + "capacity": 13.4, + "soc": 31, + "controllable": true + } + ], + "batteryCapacity": 13.4, + "batteryDischargeControl": true, + "batteryEnergy": 0, + "batteryGridChargeActive": true, + "batteryGridChargeLimit": 0.25, + "batteryMode": "charge", + "batteryModeExternal": "unknown", + "batteryPower": 188.786, + "batterySoc": 31, + "bufferSoc": 75, + "bufferStartSoc": 0, + "currency": "EUR", + "demoMode": true, + "eebus": false, + "ext": [ + ], + "forecast": { + "co2": [ + { + "start": "2025-09-08T00:00:00+02:00", + "end": "2025-09-08T01:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-08T01:00:00+02:00", + "end": "2025-09-08T02:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-08T02:00:00+02:00", + "end": "2025-09-08T03:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-08T03:00:00+02:00", + "end": "2025-09-08T04:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-08T04:00:00+02:00", + "end": "2025-09-08T05:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-08T05:00:00+02:00", + "end": "2025-09-08T06:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-08T06:00:00+02:00", + "end": "2025-09-08T07:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-08T07:00:00+02:00", + "end": "2025-09-08T08:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-08T08:00:00+02:00", + "end": "2025-09-08T09:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-08T09:00:00+02:00", + "end": "2025-09-08T10:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-08T10:00:00+02:00", + "end": "2025-09-08T11:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-08T11:00:00+02:00", + "end": "2025-09-08T12:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-08T12:00:00+02:00", + "end": "2025-09-08T13:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-08T13:00:00+02:00", + "end": "2025-09-08T14:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-08T14:00:00+02:00", + "end": "2025-09-08T15:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-08T15:00:00+02:00", + "end": "2025-09-08T16:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-08T16:00:00+02:00", + "end": "2025-09-08T17:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-08T17:00:00+02:00", + "end": "2025-09-08T18:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-08T18:00:00+02:00", + "end": "2025-09-08T19:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-08T19:00:00+02:00", + "end": "2025-09-08T20:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-08T20:00:00+02:00", + "end": "2025-09-08T21:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-08T21:00:00+02:00", + "end": "2025-09-08T22:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-08T22:00:00+02:00", + "end": "2025-09-08T23:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-08T23:00:00+02:00", + "end": "2025-09-09T00:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-09T00:00:00+02:00", + "end": "2025-09-09T01:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-09T01:00:00+02:00", + "end": "2025-09-09T02:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-09T02:00:00+02:00", + "end": "2025-09-09T03:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-09T03:00:00+02:00", + "end": "2025-09-09T04:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-09T04:00:00+02:00", + "end": "2025-09-09T05:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-09T05:00:00+02:00", + "end": "2025-09-09T06:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-09T06:00:00+02:00", + "end": "2025-09-09T07:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-09T07:00:00+02:00", + "end": "2025-09-09T08:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-09T08:00:00+02:00", + "end": "2025-09-09T09:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-09T09:00:00+02:00", + "end": "2025-09-09T10:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-09T10:00:00+02:00", + "end": "2025-09-09T11:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-09T11:00:00+02:00", + "end": "2025-09-09T12:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-09T12:00:00+02:00", + "end": "2025-09-09T13:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-09T13:00:00+02:00", + "end": "2025-09-09T14:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-09T14:00:00+02:00", + "end": "2025-09-09T15:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-09T15:00:00+02:00", + "end": "2025-09-09T16:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-09T16:00:00+02:00", + "end": "2025-09-09T17:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-09T17:00:00+02:00", + "end": "2025-09-09T18:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-09T18:00:00+02:00", + "end": "2025-09-09T19:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-09T19:00:00+02:00", + "end": "2025-09-09T20:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-09T20:00:00+02:00", + "end": "2025-09-09T21:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-09T21:00:00+02:00", + "end": "2025-09-09T22:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-09T22:00:00+02:00", + "end": "2025-09-09T23:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-09T23:00:00+02:00", + "end": "2025-09-10T00:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-10T00:00:00+02:00", + "end": "2025-09-10T01:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-10T01:00:00+02:00", + "end": "2025-09-10T02:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-10T02:00:00+02:00", + "end": "2025-09-10T03:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-10T03:00:00+02:00", + "end": "2025-09-10T04:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-10T04:00:00+02:00", + "end": "2025-09-10T05:00:00+02:00", + "value": 378 + }, + { + "start": "2025-09-10T05:00:00+02:00", + "end": "2025-09-10T06:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-10T06:00:00+02:00", + "end": "2025-09-10T07:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-10T07:00:00+02:00", + "end": "2025-09-10T08:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-10T08:00:00+02:00", + "end": "2025-09-10T09:00:00+02:00", + "value": 462 + }, + { + "start": "2025-09-10T09:00:00+02:00", + "end": "2025-09-10T10:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-10T10:00:00+02:00", + "end": "2025-09-10T11:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-10T11:00:00+02:00", + "end": "2025-09-10T12:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-10T12:00:00+02:00", + "end": "2025-09-10T13:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-10T13:00:00+02:00", + "end": "2025-09-10T14:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-10T14:00:00+02:00", + "end": "2025-09-10T15:00:00+02:00", + "value": 252 + }, + { + "start": "2025-09-10T15:00:00+02:00", + "end": "2025-09-10T16:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-10T16:00:00+02:00", + "end": "2025-09-10T17:00:00+02:00", + "value": 322 + }, + { + "start": "2025-09-10T17:00:00+02:00", + "end": "2025-09-10T18:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-10T18:00:00+02:00", + "end": "2025-09-10T19:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-10T19:00:00+02:00", + "end": "2025-09-10T20:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-10T20:00:00+02:00", + "end": "2025-09-10T21:00:00+02:00", + "value": 434 + }, + { + "start": "2025-09-10T21:00:00+02:00", + "end": "2025-09-10T22:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-10T22:00:00+02:00", + "end": "2025-09-10T23:00:00+02:00", + "value": 392 + }, + { + "start": "2025-09-10T23:00:00+02:00", + "end": "2025-09-11T00:00:00+02:00", + "value": 392 + } + ], + "grid": [ + { + "start": "2025-09-08T00:00:00+02:00", + "end": "2025-09-08T01:00:00+02:00", + "value": 0.22965999999999998 + }, + { + "start": "2025-09-08T01:00:00+02:00", + "end": "2025-09-08T02:00:00+02:00", + "value": 0.23574 + }, + { + "start": "2025-09-08T02:00:00+02:00", + "end": "2025-09-08T03:00:00+02:00", + "value": 0.2347 + }, + { + "start": "2025-09-08T03:00:00+02:00", + "end": "2025-09-08T04:00:00+02:00", + "value": 0.23226999999999998 + }, + { + "start": "2025-09-08T04:00:00+02:00", + "end": "2025-09-08T05:00:00+02:00", + "value": 0.23379 + }, + { + "start": "2025-09-08T05:00:00+02:00", + "end": "2025-09-08T06:00:00+02:00", + "value": 0.24559999999999998 + }, + { + "start": "2025-09-08T06:00:00+02:00", + "end": "2025-09-08T07:00:00+02:00", + "value": 0.2599 + }, + { + "start": "2025-09-08T07:00:00+02:00", + "end": "2025-09-08T08:00:00+02:00", + "value": 0.29813999999999996 + }, + { + "start": "2025-09-08T08:00:00+02:00", + "end": "2025-09-08T09:00:00+02:00", + "value": 0.27553 + }, + { + "start": "2025-09-08T09:00:00+02:00", + "end": "2025-09-08T10:00:00+02:00", + "value": 0.25205 + }, + { + "start": "2025-09-08T10:00:00+02:00", + "end": "2025-09-08T11:00:00+02:00", + "value": 0.23582999999999998 + }, + { + "start": "2025-09-08T11:00:00+02:00", + "end": "2025-09-08T12:00:00+02:00", + "value": 0.22614 + }, + { + "start": "2025-09-08T12:00:00+02:00", + "end": "2025-09-08T13:00:00+02:00", + "value": 0.21534999999999999 + }, + { + "start": "2025-09-08T13:00:00+02:00", + "end": "2025-09-08T14:00:00+02:00", + "value": 0.21084 + }, + { + "start": "2025-09-08T14:00:00+02:00", + "end": "2025-09-08T15:00:00+02:00", + "value": 0.21965 + }, + { + "start": "2025-09-08T15:00:00+02:00", + "end": "2025-09-08T16:00:00+02:00", + "value": 0.22846 + }, + { + "start": "2025-09-08T16:00:00+02:00", + "end": "2025-09-08T17:00:00+02:00", + "value": 0.24972 + }, + { + "start": "2025-09-08T17:00:00+02:00", + "end": "2025-09-08T18:00:00+02:00", + "value": 0.27427999999999997 + }, + { + "start": "2025-09-08T18:00:00+02:00", + "end": "2025-09-08T19:00:00+02:00", + "value": 0.34661 + }, + { + "start": "2025-09-08T19:00:00+02:00", + "end": "2025-09-08T20:00:00+02:00", + "value": 0.56366 + }, + { + "start": "2025-09-08T20:00:00+02:00", + "end": "2025-09-08T21:00:00+02:00", + "value": 0.45851999999999993 + }, + { + "start": "2025-09-08T21:00:00+02:00", + "end": "2025-09-08T22:00:00+02:00", + "value": 0.302 + }, + { + "start": "2025-09-08T22:00:00+02:00", + "end": "2025-09-08T23:00:00+02:00", + "value": 0.27393 + }, + { + "start": "2025-09-08T23:00:00+02:00", + "end": "2025-09-09T00:00:00+02:00", + "value": 0.26312 + } + ], + "planner": [ + { + "start": "2025-09-08T00:00:00+02:00", + "end": "2025-09-08T01:00:00+02:00", + "value": 0.22965999999999998 + }, + { + "start": "2025-09-08T01:00:00+02:00", + "end": "2025-09-08T02:00:00+02:00", + "value": 0.23574 + }, + { + "start": "2025-09-08T02:00:00+02:00", + "end": "2025-09-08T03:00:00+02:00", + "value": 0.2347 + }, + { + "start": "2025-09-08T03:00:00+02:00", + "end": "2025-09-08T04:00:00+02:00", + "value": 0.23226999999999998 + }, + { + "start": "2025-09-08T04:00:00+02:00", + "end": "2025-09-08T05:00:00+02:00", + "value": 0.23379 + }, + { + "start": "2025-09-08T05:00:00+02:00", + "end": "2025-09-08T06:00:00+02:00", + "value": 0.24559999999999998 + }, + { + "start": "2025-09-08T06:00:00+02:00", + "end": "2025-09-08T07:00:00+02:00", + "value": 0.2599 + }, + { + "start": "2025-09-08T07:00:00+02:00", + "end": "2025-09-08T08:00:00+02:00", + "value": 0.29813999999999996 + }, + { + "start": "2025-09-08T08:00:00+02:00", + "end": "2025-09-08T09:00:00+02:00", + "value": 0.27553 + }, + { + "start": "2025-09-08T09:00:00+02:00", + "end": "2025-09-08T10:00:00+02:00", + "value": 0.25205 + }, + { + "start": "2025-09-08T10:00:00+02:00", + "end": "2025-09-08T11:00:00+02:00", + "value": 0.23582999999999998 + }, + { + "start": "2025-09-08T11:00:00+02:00", + "end": "2025-09-08T12:00:00+02:00", + "value": 0.22614 + }, + { + "start": "2025-09-08T12:00:00+02:00", + "end": "2025-09-08T13:00:00+02:00", + "value": 0.21534999999999999 + }, + { + "start": "2025-09-08T13:00:00+02:00", + "end": "2025-09-08T14:00:00+02:00", + "value": 0.21084 + }, + { + "start": "2025-09-08T14:00:00+02:00", + "end": "2025-09-08T15:00:00+02:00", + "value": 0.21965 + }, + { + "start": "2025-09-08T15:00:00+02:00", + "end": "2025-09-08T16:00:00+02:00", + "value": 0.22846 + }, + { + "start": "2025-09-08T16:00:00+02:00", + "end": "2025-09-08T17:00:00+02:00", + "value": 0.24972 + }, + { + "start": "2025-09-08T17:00:00+02:00", + "end": "2025-09-08T18:00:00+02:00", + "value": 0.27427999999999997 + }, + { + "start": "2025-09-08T18:00:00+02:00", + "end": "2025-09-08T19:00:00+02:00", + "value": 0.34661 + }, + { + "start": "2025-09-08T19:00:00+02:00", + "end": "2025-09-08T20:00:00+02:00", + "value": 0.56366 + }, + { + "start": "2025-09-08T20:00:00+02:00", + "end": "2025-09-08T21:00:00+02:00", + "value": 0.45851999999999993 + }, + { + "start": "2025-09-08T21:00:00+02:00", + "end": "2025-09-08T22:00:00+02:00", + "value": 0.302 + }, + { + "start": "2025-09-08T22:00:00+02:00", + "end": "2025-09-08T23:00:00+02:00", + "value": 0.27393 + }, + { + "start": "2025-09-08T23:00:00+02:00", + "end": "2025-09-09T00:00:00+02:00", + "value": 0.26312 + } + ], + "solar": { + "scale": 1.7142189483634065, + "today": { + "energy": 46007.892876756465, + "complete": true + }, + "tomorrow": { + "energy": 112728, + "complete": true + }, + "dayAfterTomorrow": { + "energy": 112728, + "complete": false + }, + "timeseries": [ + { + "ts": "2025-09-08T00:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T01:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T02:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T03:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T04:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T05:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T06:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T07:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T08:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-08T09:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-08T10:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-08T11:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-08T12:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-08T13:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-08T14:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-08T15:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-08T16:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-08T17:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-08T18:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T19:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T20:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T21:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T22:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-08T23:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T00:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T01:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T02:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T03:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T04:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T05:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T06:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T07:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T08:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-09T09:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-09T10:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-09T11:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-09T12:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-09T13:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-09T14:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-09T15:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-09T16:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-09T17:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-09T18:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T19:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T20:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T21:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T22:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-09T23:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T00:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T01:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T02:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T03:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T04:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T05:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T06:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T07:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T08:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-10T09:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-10T10:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-10T11:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-10T12:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-10T13:00:00+02:00", + "val": 15372 + }, + { + "ts": "2025-09-10T14:00:00+02:00", + "val": 14347 + }, + { + "ts": "2025-09-10T15:00:00+02:00", + "val": 12298 + }, + { + "ts": "2025-09-10T16:00:00+02:00", + "val": 9223 + }, + { + "ts": "2025-09-10T17:00:00+02:00", + "val": 5124 + }, + { + "ts": "2025-09-10T18:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T19:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T20:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T21:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T22:00:00+02:00", + "val": 0 + }, + { + "ts": "2025-09-10T23:00:00+02:00", + "val": 0 + } + ] + } + }, + "greenShareHome": 1, + "greenShareLoadpoints": 0.739, + "grid": { + "power": 2875.972497154853 + }, + "gridConfigured": true, + "hems": { + }, + "homePower": 500, + "influx": { + "url": "", + "database": "", + "token": "", + "org": "", + "user": "", + "password": "", + "insecure": false + }, + "interval": 3, + "loadpoints": [ + { + "batteryBoost": false, + "chargeDuration": 743853, + "chargePower": 0, + "chargeRemainingDuration": 0, + "chargeRemainingEnergy": 2364.153, + "chargedEnergy": 1182321.351, + "chargerFeatureHeating": false, + "chargerFeatureIntegratedDevice": false, + "chargerIcon": "", + "chargerPhases1p3p": false, + "chargerSinglePhase": false, + "chargerStatusReason": "unknown", + "charging": false, + "connected": true, + "connectedDuration": 0, + "disableDelay": 180, + "disableThreshold": 0, + "effectiveLimitSoc": 80, + "effectiveMaxCurrent": 15, + "effectiveMinCurrent": 6, + "effectivePlanId": 0, + "effectivePlanSoc": 20, + "effectivePlanTime": null, + "effectivePriority": 0, + "enableDelay": 60, + "enableThreshold": 0, + "enabled": false, + "limitEnergy": 0, + "limitSoc": 80, + "maxCurrent": 15, + "minCurrent": 6, + "mode": "pv", + "offeredCurrent": 0, + "phaseAction": "inactive", + "phaseRemaining": 0, + "phasesActive": 1, + "phasesConfigured": 1, + "planActive": false, + "planEnergy": 0, + "planOverrun": 0, + "planPrecondition": 0, + "planProjectedEnd": null, + "planProjectedStart": null, + "planTime": null, + "priority": 0, + "pvAction": "inactive", + "pvRemaining": 0, + "sessionCo2PerKWh": 80.991, + "sessionEnergy": 1182321.351, + "sessionPrice": 134.88, + "sessionPricePerKWh": 0.114, + "sessionSolarPercentage": 77.772, + "smartCostActive": false, + "smartCostLimit": 0, + "smartCostNextStart": null, + "smartFeedInPriorityActive": false, + "smartFeedInPriorityLimit": null, + "smartFeedInPriorityNextStart": null, + "title": "Carport", + "vehicleClimaterActive": null, + "vehicleDetectionActive": false, + "vehicleLimitSoc": 80, + "vehicleName": "vehicle_1", + "vehicleOdometer": 0, + "vehicleRange": 141, + "vehicleSoc": 48.3, + "vehicleTitle": "blauer e-Golf", + "vehicleWelcomeActive": false + }, + { + "batteryBoost": false, + "chargeDuration": 712180, + "chargePower": 11040, + "chargeRemainingDuration": 0, + "chargeRemainingEnergy": 0, + "chargedEnergy": 1257162.744, + "chargerFeatureHeating": false, + "chargerFeatureIntegratedDevice": false, + "chargerIcon": "", + "chargerPhases1p3p": true, + "chargerSinglePhase": false, + "chargerStatusReason": "unknown", + "charging": true, + "connected": true, + "connectedDuration": 0, + "disableDelay": 180, + "disableThreshold": 0, + "effectiveLimitSoc": 85, + "effectiveMaxCurrent": 16, + "effectiveMinCurrent": 6, + "effectivePlanId": 0, + "effectivePlanSoc": 0, + "effectivePlanTime": null, + "effectivePriority": 0, + "enableDelay": 60, + "enableThreshold": 0, + "enabled": true, + "limitEnergy": 0, + "limitSoc": 85, + "maxCurrent": 16, + "minCurrent": 6, + "mode": "now", + "offeredCurrent": 16, + "phaseAction": "inactive", + "phaseRemaining": 0, + "phasesActive": 3, + "phasesConfigured": 0, + "planActive": false, + "planEnergy": 0, + "planOverrun": 0, + "planPrecondition": 0, + "planProjectedEnd": null, + "planProjectedStart": null, + "planTime": null, + "priority": 0, + "pvAction": "inactive", + "pvRemaining": 0, + "sessionCo2PerKWh": 71.313, + "sessionEnergy": 1257162.744, + "sessionPrice": 136.41, + "sessionPricePerKWh": 0.109, + "sessionSolarPercentage": 80.384, + "smartCostActive": false, + "smartCostLimit": 0.12, + "smartCostNextStart": null, + "smartFeedInPriorityActive": false, + "smartFeedInPriorityLimit": null, + "smartFeedInPriorityNextStart": null, + "title": "Garage", + "vehicleClimaterActive": null, + "vehicleDetectionActive": false, + "vehicleLimitSoc": 0, + "vehicleName": "vehicle_5", + "vehicleOdometer": 0, + "vehicleRange": 0, + "vehicleSoc": 0, + "vehicleTitle": "Wärmepumpe", + "vehicleWelcomeActive": false + } + ], + "messaging": false, + "modbusproxy": null, + "mqtt": { + "broker": "", + "user": "", + "password": "", + "clientID": "", + "insecure": false, + "caCert": "", + "clientCert": "", + "clientKey": "", + "topic": "evcc" + }, + "network": { + "schema": "http", + "host": "evcc.local", + "port": 7070 + }, + "prioritySoc": 50, + "pv": [ + { + "power": 8475.2415092103874 + } + ], + "pvEnergy": 0, + "pvPower": 8475.242, + "residualPower": 1, + "siteTitle": "Demo Mode", + "smartCostAvailable": true, + "smartCostType": "priceforecast", + "smartFeedInPriorityAvailable": false, + "sponsor": { + "name": "", + "expiresAt": "0001-01-01T00:00:00Z", + "fromYaml": true + }, + "startup": true, + "statistics": { + "30d": { + "avgCo2": 76.040614873472336, + "avgPrice": 0.11118445465324438, + "chargedKWh": 2428.032521160711, + "solarPercentage": 79.147220316136114 + }, + "365d": { + "avgCo2": 76.040614873472336, + "avgPrice": 0.11118445465324438, + "chargedKWh": 2428.032521160711, + "solarPercentage": 79.147220316136114 + }, + "thisYear": { + "avgCo2": 76.040614873472336, + "avgPrice": 0.11118445465324438, + "chargedKWh": 2428.032521160711, + "solarPercentage": 79.147220316136114 + }, + "total": { + "avgCo2": 76.040614873472336, + "avgPrice": 0.11118445465324438, + "chargedKWh": 2428.032521160711, + "solarPercentage": 79.147220316136114 + } + }, + "tariffCo2": 252, + "tariffCo2Home": 0, + "tariffCo2Loadpoints": 65.647, + "tariffFeedIn": 0.08, + "tariffGrid": 0.211, + "tariffPriceHome": 0.08, + "tariffPriceLoadpoints": 0.114, + "tariffSolar": 15372, + "telemetry": false, + "vehicles": { + "vehicle_1": { + "title": "blauer e-Golf", + "capacity": 44, + "minSoc": 15, + "limitSoc": 80, + "repeatingPlans": [ + { + "weekdays": [ + 1, + 2, + 3, + 4, + 5 + ], + "time": "07:00", + "tz": "Europe/Amsterdam", + "soc": 80, + "precondition": 0, + "active": false + } + ] + }, + "vehicle_2": { + "title": "weißes Model 3", + "capacity": 80, + "minSoc": 40, + "limitSoc": 80, + "plan": { + "soc": 80, + "precondition": 3600, + "time": "2025-09-08T06:00:00Z" + }, + "repeatingPlans": [ + ] + }, + "vehicle_3": { + "title": "grüner Honda e", + "icon": "car", + "capacity": 8, + "features": [ + "Offline" + ], + "repeatingPlans": [ + ] + }, + "vehicle_4": { + "title": "schwarzes VanMoof", + "icon": "bike", + "capacity": 0.46, + "features": [ + "Offline" + ], + "repeatingPlans": [ + ] + }, + "vehicle_5": { + "title": "Wärmepumpe", + "icon": "waterheater", + "features": [ + "Offline" + ], + "repeatingPlans": [ + ] + } + }, + "version": "0.207.5" +} \ No newline at end of file