diff --git a/CODEOWNERS b/CODEOWNERS index b52bad008e565..0618bff24eefe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,7 @@ /bundles/org.openhab.binding.smaenergymeter/ @monnimeter /bundles/org.openhab.binding.smartmeter/ @msteigenberger /bundles/org.openhab.binding.smartthings/ @BobRak +/bundles/org.openhab.binding.smgw/ @J-N-K /bundles/org.openhab.binding.smhi/ @pacive /bundles/org.openhab.binding.smsmodem/ @dalgwen /bundles/org.openhab.binding.sncf/ @clinique diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 84e90d18c3f34..3f96dd1e789aa 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1566,6 +1566,11 @@ org.openhab.binding.smartthings ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.smgw + ${project.version} + org.openhab.addons.bundles org.openhab.binding.smhi diff --git a/bundles/org.openhab.binding.smgw/NOTICE b/bundles/org.openhab.binding.smgw/NOTICE new file mode 100644 index 0000000000000..0ca708bef198a --- /dev/null +++ b/bundles/org.openhab.binding.smgw/NOTICE @@ -0,0 +1,20 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons + +== Third-party Content + +jsoup +* License: MIT License +* Project: https://jsoup.org/ +* Source: https://github.com/jhy/jsoup diff --git a/bundles/org.openhab.binding.smgw/README.md b/bundles/org.openhab.binding.smgw/README.md new file mode 100644 index 0000000000000..1e0b3a10513a2 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/README.md @@ -0,0 +1,36 @@ +# PPC SMGW Binding + +The PPC SMGW binding adds support for PPC Smart Meter Gateways. +The gateway is commonly installed by the network operator to allow remote access to a smart meter. +It also provides a HAN (home area network) interface for local access. + +To use the HAN interface you need to connect it to your local network with an ethernet cable. + +## Supported Things + +- `smgw`: A smart meter gateway device. + +## Thing Configuration + +### `smgw` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|------------|------|--------------------------------------|----------------|----------|----------| +| `hostname` | text | Hostname or IP address of the device | `192.168.1.200 | no | no | +| `username` | text | Username to access the device | N/A | yes | no | +| `password` | text | Password to access the device | N/A | yes | no | + +The default value for the hostname matches the default value according to PPC's documentation. +Check with your network operator's documentation if DHCP has been enabled or a different fixed address has been set. + +Username and password are typically supplied by the network operator. +Login with certificate is not supported. + +## Channels + +| Channel | Type | Read/Write | Description | +|-------------|---------------|------------|------------------------------------------------| +| `meter` | Number:Energy | R | The meter reading of the smart meter. | +| `timestamp` | DateTime | R | The date and time for which the meter reading. | + +Channels are refreshed every 900s. diff --git a/bundles/org.openhab.binding.smgw/pom.xml b/bundles/org.openhab.binding.smgw/pom.xml new file mode 100644 index 0000000000000..0efdc5e524650 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/pom.xml @@ -0,0 +1,30 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.1.0-SNAPSHOT + + + org.openhab.binding.smgw + + openHAB Add-ons :: Bundles :: PPC SMGW Binding + + + 1.15.3 + + + + + org.jsoup + jsoup + ${jsoup.version} + provided + + + + diff --git a/bundles/org.openhab.binding.smgw/src/main/feature/feature.xml b/bundles/org.openhab.binding.smgw/src/main/feature/feature.xml new file mode 100644 index 0000000000000..80387106f3a52 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.jsoup/jsoup/1.15.3 + mvn:org.openhab.addons.bundles/org.openhab.binding.smgw/${project.version} + + diff --git a/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwBindingConstants.java b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwBindingConstants.java new file mode 100644 index 0000000000000..652ab3854a9ac --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwBindingConstants.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2023 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.smgw.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link SmgwBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class SmgwBindingConstants { + private static final String BINDING_ID = "smgw"; + + public static final ThingTypeUID THING_TYPE_SMGW = new ThingTypeUID(BINDING_ID, "smgw"); + + public static final String CHANNEL_METER = "meter"; + public static final String CHANNEL_TIMESTAMP = "timestamp"; +} diff --git a/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwConfiguration.java b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwConfiguration.java new file mode 100644 index 0000000000000..4869cf19da7d0 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwConfiguration.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2023 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.smgw.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link SmgwConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class SmgwConfiguration { + public String hostname = "192.168.1.200"; + public String username = ""; + public String password = ""; +} diff --git a/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandler.java b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandler.java new file mode 100644 index 0000000000000..20193c69eba96 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandler.java @@ -0,0 +1,232 @@ +/** + * Copyright (c) 2010-2023 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.smgw.internal; + +import static org.openhab.binding.smgw.internal.SmgwBindingConstants.CHANNEL_METER; +import static org.openhab.binding.smgw.internal.SmgwBindingConstants.CHANNEL_TIMESTAMP; + +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import javax.measure.quantity.Energy; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.Authentication; +import org.eclipse.jetty.client.api.AuthenticationStore; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.api.Result; +import org.eclipse.jetty.client.util.BufferingResponseListener; +import org.eclipse.jetty.client.util.DigestAuthentication; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.scheduler.CronScheduler; +import org.openhab.core.scheduler.ScheduledCompletableFuture; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link SmgwHandler} is responsible for refreshing the smart meter's data and handling REFRESH commands. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class SmgwHandler extends BaseThingHandler { + private static final URI URI_NOT_SET = URI.create(""); + private final Logger logger = LoggerFactory.getLogger(SmgwHandler.class); + private final HttpClient httpClient; + private final CronScheduler cronScheduler; + private SmgwConfiguration config = new SmgwConfiguration(); + private URI uri = URI_NOT_SET; + private @Nullable ScheduledCompletableFuture cronJob; + + public SmgwHandler(Thing thing, HttpClient httpClient, CronScheduler cronScheduler) { + super(thing); + this.httpClient = httpClient; + this.cronScheduler = cronScheduler; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType && !URI_NOT_SET.equals(uri)) { + cancelRefreshJob(); + getData(); + } + } + + @Override + public void initialize() { + config = getConfigAs(SmgwConfiguration.class); + try { + uri = new URI("https://" + config.hostname + "/cgi-bin/hanservice.cgi"); + } catch (URISyntaxException e) { + uri = URI_NOT_SET; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Could not create URI from given hostname"); + return; + } + + updateStatus(ThingStatus.UNKNOWN); + getData(); + } + + @Override + public void dispose() { + cancelRefreshJob(); + } + + private void cancelRefreshJob() { + ScheduledCompletableFuture cronJob = this.cronJob; + if (cronJob != null) { + cronJob.cancel(true); + this.cronJob = null; + } + } + + private void getData() { + if (URI_NOT_SET.equals(uri)) { + logger.warn("getData() called, but URI is not set. Please describe what happened and report a bug."); + return; + } + // clear cookies + CookieStore cookieStore = httpClient.getCookieStore(); + List cookies = cookieStore.get(uri); + cookies.forEach(cookie -> cookieStore.remove(uri, cookie)); + + // clear auth + AuthenticationStore authStore = httpClient.getAuthenticationStore(); + Authentication.Result authResult = authStore.findAuthenticationResult(uri); + if (authResult != null) { + authStore.removeAuthenticationResult(authResult); + } + Authentication authentication = authStore.findAuthentication("Digest", uri, Authentication.ANY_REALM); + if (authentication != null) { + authStore.removeAuthentication(authentication); + } + + // add new auth + authStore.addAuthentication( + new DigestAuthentication(uri, Authentication.ANY_REALM, config.username, config.password)); + + CompletableFuture future = new CompletableFuture<>(); + httpClient.newRequest(uri).send(new ResponseListener(future)); + future.thenCompose(this::onLoginSuccess).thenCompose(this::onMeterForm).handle(this::onShowMeterValue); + } + + private CompletableFuture onLoginSuccess(SmgwResponse response) { + Element tknElement = response.document().selectFirst("input[name='tkn']"); + if (tknElement == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Could not determine tkn")); + } + String tkn = tknElement.val(); + String showMeterValuesForm = "tkn=" + tkn + "&action=meterform"; + CompletableFuture future = new CompletableFuture<>(); + httpClient.POST(uri).content(new StringContentProvider(showMeterValuesForm)).send(new ResponseListener(future)); + return future; + } + + private CompletableFuture onMeterForm(SmgwResponse response) { + Element tknElement = response.document().selectFirst("input[name='tkn']"); + Element midElement = response.document().selectFirst("select[name='mid'] option"); + if (tknElement == null || midElement == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Could not determine mid or tkn")); + } + String tkn = tknElement.val(); + String mid = midElement.val(); + String localDate = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + + String showMeterValues = "tkn=" + tkn + "&mid=" + mid + "&action=showMeterValues&from=" + localDate + "&to=" + + localDate; + + CompletableFuture future = new CompletableFuture<>(); + httpClient.POST(uri).content(new StringContentProvider(showMeterValues)) + .header(HttpHeader.COOKIE, response.cookies()).send(new ResponseListener(future)); + + return future; + } + + private @Nullable Object onShowMeterValue(@Nullable SmgwResponse response, @Nullable Throwable t) { + if (t != null || response == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + } else { + Element valueElement = response.document().selectFirst("#table_metervalues_col_wert"); + Element unitElement = response.document().selectFirst("#table_metervalues_col_einheit"); + Element dateTimeElement = response.document().selectFirst("#table_metervalues_col_timestamp"); + if (valueElement == null || unitElement == null || dateTimeElement == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + } else { + QuantityType value = new QuantityType<>(valueElement.text() + " " + unitElement.text()); + DateTimeType dateTime = DateTimeType.valueOf(dateTimeElement.text().replace(" ", "T")); + + updateState(CHANNEL_METER, value); + updateState(CHANNEL_TIMESTAMP, dateTime); + updateStatus(ThingStatus.ONLINE); + } + } + ScheduledCompletableFuture cronJob = this.cronJob; + if (cronJob == null || cronJob.isDone()) { + this.cronJob = cronScheduler.schedule(this::getData, "5 0/15 * * * ? *"); + } + return null; + } + + private static class ResponseListener extends BufferingResponseListener { + private final Logger logger = LoggerFactory.getLogger(ResponseListener.class); + private final CompletableFuture resultFuture; + + public ResponseListener(CompletableFuture resultFuture) { + this.resultFuture = resultFuture; + } + + @Override + public void onComplete(@NonNullByDefault({}) Result result) { + if (result.isSucceeded()) { + Response response = result.getResponse(); + int status = response.getStatus(); + if (HttpStatus.isSuccess(status)) { + String setCookies = response.getHeaders().get(HttpHeader.SET_COOKIE); + String cookies = setCookies != null ? setCookies + : result.getRequest().getHeaders().get(HttpHeader.COOKIE); + Document doc = Jsoup.parse(getContentAsString()); + resultFuture.complete(new SmgwResponse(cookies, doc)); + return; + } + } + logger.warn("Failed to request {}", result.getRequest().getURI()); + resultFuture.completeExceptionally(new IllegalStateException()); + } + } + + private record SmgwResponse(String cookies, Document document) { + } +} diff --git a/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandlerFactory.java b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandlerFactory.java new file mode 100644 index 0000000000000..af54aeca4c512 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/java/org/openhab/binding/smgw/internal/SmgwHandlerFactory.java @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2010-2023 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.smgw.internal; + +import static org.openhab.binding.smgw.internal.SmgwBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.scheduler.CronScheduler; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link SmgwHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@Component(service = ThingHandlerFactory.class) +public class SmgwHandlerFactory extends BaseThingHandlerFactory { + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SMGW); + private final Logger logger = LoggerFactory.getLogger(SmgwHandlerFactory.class); + + private final HttpClient httpClient; + private final CronScheduler cronScheduler; + + @Activate + public SmgwHandlerFactory(@Reference HttpClientFactory clientFactory, @Reference CronScheduler cronScheduler) { + this.cronScheduler = cronScheduler; + this.httpClient = clientFactory.createHttpClient("smgw", new SslContextFactory.Client(true)); + try { + this.httpClient.start(); + } catch (Exception e) { + // catching exception is necessary due to the signature of HttpClient.start() + logger.warn("Failed to start http client: {}", e.getMessage()); + throw new IllegalStateException("Could not create HttpClient"); + } + } + + @Deactivate + public void deactivate() { + try { + httpClient.stop(); + } catch (Exception e) { + logger.warn("Failed to stop http client: {}", e.getMessage()); + } + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_SMGW.equals(thingTypeUID)) { + return new SmgwHandler(thing, httpClient, cronScheduler); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..559a16ecf95d3 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,12 @@ + + + + binding + PPC SMGW Binding + This integrates the PPC Smart Meter Gateways. + + local + + diff --git a/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/i18n/smgw.properties b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/i18n/smgw.properties new file mode 100644 index 0000000000000..5ceb32877312c --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/i18n/smgw.properties @@ -0,0 +1,22 @@ +# add-on + +addon.smgw.name = PPC SMGW Binding +addon.smgw.description = This integrates the PPC Smart Meter Gateways. + +# thing types + +thing-type.smgw.smgw.label = Smartmeter Gateway +thing-type.smgw.smgw.description = A Smartmeter Gateway +thing-type.smgw.smgw.channel.meter.label = Meter Reading + +# thing types config + +thing-type.config.smgw.smgw.hostname.label = Hostname +thing-type.config.smgw.smgw.hostname.description = Hostname or IP address of the device +thing-type.config.smgw.smgw.password.label = Password +thing-type.config.smgw.smgw.username.label = Username + +# channel types + +channel-type.smgw.timestamp.label = Timestamp +channel-type.smgw.timestamp.description = The timestamp of the meter reading. diff --git a/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..4692ec2ad2147 --- /dev/null +++ b/bundles/org.openhab.binding.smgw/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,45 @@ + + + + + + + A Smartmeter Gateway + + + + + + + + + + + + network-address + + Hostname or IP address of the device + 192.168.1.200 + + + + + + password + + + + + + + + DateTime + + The timestamp of the meter reading. + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 59e2e699d4a42..175ca4eb8f8e7 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -348,6 +348,7 @@ org.openhab.binding.smaenergymeter org.openhab.binding.smartmeter org.openhab.binding.smartthings + org.openhab.binding.smgw org.openhab.binding.smhi org.openhab.binding.smsmodem org.openhab.binding.sncf