2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.tacmi.internal.schema;
16 import java.nio.charset.StandardCharsets;
17 import java.util.Base64;
18 import java.util.HashMap;
19 import java.util.List;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
26 import org.attoparser.ParseException;
27 import org.attoparser.config.ParseConfiguration;
28 import org.attoparser.config.ParseConfiguration.ElementBalancing;
29 import org.attoparser.config.ParseConfiguration.UniqueRootElementPresence;
30 import org.attoparser.simple.AbstractSimpleMarkupHandler;
31 import org.attoparser.simple.ISimpleMarkupParser;
32 import org.attoparser.simple.SimpleMarkupParser;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.http.HttpHeader;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
41 import org.openhab.core.library.types.OnOffType;
42 import org.openhab.core.library.types.StringType;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.thing.binding.builder.ThingBuilder;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
57 * The {@link TACmiSchemaHandler} is responsible for handling commands, which are sent
58 * to one of the channels.
60 * @author Christian Niessner - Initial contribution
63 public class TACmiSchemaHandler extends BaseThingHandler {
65 private final Logger logger = LoggerFactory.getLogger(TACmiSchemaHandler.class);
67 private final HttpClient httpClient;
68 private final TACmiChannelTypeProvider channelTypeProvider;
69 private final Map<String, ApiPageEntry> entries = new HashMap<>();
70 private boolean online;
71 private @Nullable String serverBase;
72 private @Nullable URI schemaApiPage;
73 private @Nullable String authHeader;
74 private @Nullable ScheduledFuture<?> scheduledFuture;
75 private final ParseConfiguration noRestrictions;
77 public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
78 final TACmiChannelTypeProvider channelTypeProvider) {
80 this.httpClient = httpClient;
81 this.channelTypeProvider = channelTypeProvider;
83 // the default configuration for the parser
84 this.noRestrictions = ParseConfiguration.xmlConfiguration();
85 this.noRestrictions.setElementBalancing(ElementBalancing.NO_BALANCING);
86 this.noRestrictions.setNoUnmatchedCloseElementsRequired(false);
87 this.noRestrictions.setUniqueAttributesInElementRequired(false);
88 this.noRestrictions.setXmlWellFormedAttributeValuesRequired(false);
89 this.noRestrictions.setUniqueRootElementPresence(UniqueRootElementPresence.NOT_VALIDATED);
90 this.noRestrictions.getPrologParseConfiguration().setValidateProlog(false);
94 public void initialize() {
95 final TACmiSchemaConfiguration config = getConfigAs(TACmiSchemaConfiguration.class);
97 if (config.host.trim().isEmpty()) {
98 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!");
101 if (config.username.trim().isEmpty()) {
102 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No username configured!");
105 if (config.password.trim().isEmpty()) {
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No password configured!");
110 updateStatus(ThingStatus.UNKNOWN);
112 this.authHeader = "Basic " + Base64.getEncoder()
113 .encodeToString((config.username + ":" + config.password).getBytes(StandardCharsets.ISO_8859_1));
115 final String serverBase = "http://" + config.host + "/";
116 this.serverBase = serverBase;
117 this.schemaApiPage = buildUri("schematic_files/" + config.schemaId + ".cgi");
120 if (config.pollInterval <= 0) {
121 config.pollInterval = 10;
123 // we want to trigger the initial refresh 'at once'
124 this.scheduledFuture = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.pollInterval,
128 protected URI buildUri(String path) {
129 return URI.create(serverBase + path);
132 private Request prepareRequest(final URI uri) {
133 final Request req = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(10000, TimeUnit.MILLISECONDS);
134 req.header(HttpHeader.ACCEPT_LANGUAGE, "en"); // we want the on/off states in english
135 final String ah = this.authHeader;
137 req.header(HttpHeader.AUTHORIZATION, ah);
142 protected <PP extends AbstractSimpleMarkupHandler> PP parsePage(URI uri, PP pp)
143 throws ParseException, InterruptedException, TimeoutException, ExecutionException {
144 final ContentResponse response = prepareRequest(uri).send();
146 String responseString = null;
147 String encoding = response.getEncoding();
148 if (encoding == null || encoding.trim().isEmpty()) {
149 // the C.M.I. dosn't sometime return a valid encoding - but it defaults to UTF-8 instead of ISO...
150 responseString = new String(response.getContent(), StandardCharsets.UTF_8);
152 responseString = response.getContentAsString();
155 if (logger.isTraceEnabled()) {
156 logger.trace("Response body was: {} ", responseString);
159 final ISimpleMarkupParser parser = new SimpleMarkupParser(this.noRestrictions);
160 parser.parse(responseString, pp);
164 private void refreshData() {
165 URI schemaApiPage = this.schemaApiPage;
166 if (schemaApiPage == null) {
170 final ApiPageParser pp = parsePage(schemaApiPage,
171 new ApiPageParser(this, entries, this.channelTypeProvider));
173 final List<Channel> channels = pp.getChannels();
174 if (pp.isConfigChanged() || channels.size() != this.getThing().getChannels().size()) {
175 // we have to update our channels...
176 final ThingBuilder thingBuilder = editThing();
177 thingBuilder.withChannels(channels);
178 updateThing(thingBuilder.build());
181 updateStatus(ThingStatus.ONLINE);
184 } catch (final InterruptedException e) {
185 // binding shutdown is in progress
186 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
188 } catch (final ParseException | RuntimeException e) {
189 logger.debug("Error parsing API Scheme: {} ", e.getMessage(), e);
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "Error: " + e.getMessage());
192 } catch (final TimeoutException | ExecutionException e) {
193 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error: " + e.getMessage());
199 public void handleCommand(final ChannelUID channelUID, final Command command) {
200 final ApiPageEntry e = this.entries.get(channelUID.getId());
201 if (command instanceof RefreshType) {
203 // This might be a race condition between the 'initial' poll / fetch not finished yet or the channel
204 // might have been deleted in between. When the initial poll is still in progress, it will send an
205 // update for the channel as soon as we have the data. If the channel got deleted, there is nothing we
209 // we have our ApiPageEntry which also holds our last known state - just update it.
210 updateState(channelUID, e.getLastState());
214 logger.debug("Got command for unknown channel {}: {}", channelUID, command);
217 final Request reqUpdate;
220 reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + e.address + "&changetox2="
221 + (command == OnOffType.ON ? "1" : "0")));
222 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
225 ChangerX2Entry cx2e = e.changerX2Entry;
227 reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2e.address
228 + "&changetox2=" + (command == OnOffType.ON ? "1" : "0")));
229 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
231 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
236 ChangerX2Entry cx2sf = e.changerX2Entry;
238 String val = cx2sf.options.get(((StringType) command).toFullString());
240 reqUpdate = prepareRequest(
241 buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2sf.address + "&changetox2=" + val));
242 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
244 logger.warn("Got unknown form command {} for channel {}; Valid commands are: {}", command,
245 channelUID, cx2sf.options.keySet());
249 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
254 ChangerX2Entry cx2en = e.changerX2Entry;
256 reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2en.address
257 + "&changetox2=" + command.format("%.2f")));
258 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
260 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
264 case READ_ONLY_NUMERIC:
265 case READ_ONLY_STATE:
266 case READ_ONLY_SWITCH:
267 logger.debug("Got command for ReadOnly channel {}: {}", channelUID, command);
270 logger.debug("Got command for unhandled type {} channel {}: {}", e.type, channelUID, command);
274 e.setLastCommandTS(System.currentTimeMillis());
275 ContentResponse res = reqUpdate.send();
276 if (res.getStatus() == 200) {
277 // update ok, we update the state
278 e.setLastState((State) command);
279 updateState(channelUID, (State) command);
281 logger.warn("Error sending update for {} = {}: {} {}", channelUID, command, res.getStatus(),
284 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
285 logger.warn("Error sending update for {} = {}: {}", channelUID, command, ex.getMessage());
289 // make it accessible for ApiPageParser
291 protected void updateState(final ChannelUID channelUID, final State state) {
292 super.updateState(channelUID, state);
296 public void dispose() {
297 final ScheduledFuture<?> scheduledFuture = this.scheduledFuture;
298 if (scheduledFuture != null) {
299 scheduledFuture.cancel(true);
300 this.scheduledFuture = null;