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;
20 import java.util.Locale;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
27 import org.attoparser.ParseException;
28 import org.attoparser.config.ParseConfiguration;
29 import org.attoparser.config.ParseConfiguration.ElementBalancing;
30 import org.attoparser.config.ParseConfiguration.UniqueRootElementPresence;
31 import org.attoparser.simple.AbstractSimpleMarkupHandler;
32 import org.attoparser.simple.ISimpleMarkupParser;
33 import org.attoparser.simple.SimpleMarkupParser;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.client.api.ContentResponse;
38 import org.eclipse.jetty.client.api.Request;
39 import org.eclipse.jetty.http.HttpHeader;
40 import org.eclipse.jetty.http.HttpMethod;
41 import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.Channel;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.thing.binding.builder.ThingBuilder;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.openhab.core.types.State;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
59 * The {@link TACmiSchemaHandler} is responsible for handling commands, which are sent
60 * to one of the channels.
62 * @author Christian Niessner - Initial contribution
65 public class TACmiSchemaHandler extends BaseThingHandler {
67 private final Logger logger = LoggerFactory.getLogger(TACmiSchemaHandler.class);
69 private final HttpClient httpClient;
70 private final TACmiChannelTypeProvider channelTypeProvider;
71 private final Map<String, ApiPageEntry> entries = new HashMap<>();
72 private boolean online;
73 private @Nullable String serverBase;
74 private @Nullable URI schemaApiPage;
75 private @Nullable String authHeader;
76 private @Nullable ScheduledFuture<?> scheduledFuture;
77 private final ParseConfiguration noRestrictions;
79 public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
80 final TACmiChannelTypeProvider channelTypeProvider) {
82 this.httpClient = httpClient;
83 this.channelTypeProvider = channelTypeProvider;
85 // the default configuration for the parser
86 this.noRestrictions = ParseConfiguration.xmlConfiguration();
87 this.noRestrictions.setElementBalancing(ElementBalancing.NO_BALANCING);
88 this.noRestrictions.setNoUnmatchedCloseElementsRequired(false);
89 this.noRestrictions.setUniqueAttributesInElementRequired(false);
90 this.noRestrictions.setXmlWellFormedAttributeValuesRequired(false);
91 this.noRestrictions.setUniqueRootElementPresence(UniqueRootElementPresence.NOT_VALIDATED);
92 this.noRestrictions.getPrologParseConfiguration().setValidateProlog(false);
96 public void initialize() {
97 final TACmiSchemaConfiguration config = getConfigAs(TACmiSchemaConfiguration.class);
99 if (config.host.trim().isEmpty()) {
100 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!");
103 if (config.username.trim().isEmpty()) {
104 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No username configured!");
107 if (config.password.trim().isEmpty()) {
108 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No password configured!");
112 updateStatus(ThingStatus.UNKNOWN);
114 this.authHeader = "Basic " + Base64.getEncoder()
115 .encodeToString((config.username + ":" + config.password).getBytes(StandardCharsets.ISO_8859_1));
117 final String serverBase = "http://" + config.host + "/";
118 this.serverBase = serverBase;
119 this.schemaApiPage = buildUri("schematic_files/" + config.schemaId + ".cgi");
122 if (config.pollInterval <= 0) {
123 config.pollInterval = 10;
125 // we want to trigger the initial refresh 'at once'
126 this.scheduledFuture = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.pollInterval,
130 protected URI buildUri(String path) {
131 return URI.create(serverBase + path);
134 private Request prepareRequest(final URI uri) {
135 final Request req = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(10000, TimeUnit.MILLISECONDS);
136 req.header(HttpHeader.ACCEPT_LANGUAGE, "en"); // we want the on/off states in english
137 final String ah = this.authHeader;
139 req.header(HttpHeader.AUTHORIZATION, ah);
144 protected <PP extends AbstractSimpleMarkupHandler> PP parsePage(URI uri, PP pp)
145 throws ParseException, InterruptedException, TimeoutException, ExecutionException {
146 final ContentResponse response = prepareRequest(uri).send();
148 String responseString = null;
149 String encoding = response.getEncoding();
150 if (encoding == null || encoding.trim().isEmpty()) {
151 // the C.M.I. dosn't sometime return a valid encoding - but it defaults to UTF-8 instead of ISO...
152 responseString = new String(response.getContent(), StandardCharsets.UTF_8);
154 responseString = response.getContentAsString();
157 if (logger.isTraceEnabled()) {
158 logger.trace("Response body was: {} ", responseString);
161 final ISimpleMarkupParser parser = new SimpleMarkupParser(this.noRestrictions);
162 parser.parse(responseString, pp);
166 private void refreshData() {
167 URI schemaApiPage = this.schemaApiPage;
168 if (schemaApiPage == null) {
172 final ApiPageParser pp = parsePage(schemaApiPage,
173 new ApiPageParser(this, entries, this.channelTypeProvider));
175 final List<Channel> channels = pp.getChannels();
176 if (pp.isConfigChanged() || channels.size() != this.getThing().getChannels().size()) {
177 // we have to update our channels...
178 final ThingBuilder thingBuilder = editThing();
179 thingBuilder.withChannels(channels);
180 updateThing(thingBuilder.build());
183 updateStatus(ThingStatus.ONLINE);
186 } catch (final InterruptedException e) {
187 // binding shutdown is in progress
188 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
190 } catch (final ParseException | RuntimeException e) {
191 logger.debug("Error parsing API Scheme: {} ", e.getMessage(), e);
192 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "Error: " + e.getMessage());
194 } catch (final TimeoutException | ExecutionException e) {
195 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error: " + e.getMessage());
201 public void handleCommand(final ChannelUID channelUID, final Command command) {
202 final ApiPageEntry e = this.entries.get(channelUID.getId());
203 if (command instanceof RefreshType) {
205 // This might be a race condition between the 'initial' poll / fetch not finished yet or the channel
206 // might have been deleted in between. When the initial poll is still in progress, it will send an
207 // update for the channel as soon as we have the data. If the channel got deleted, there is nothing we
211 // we have our ApiPageEntry which also holds our last known state - just update it.
212 updateState(channelUID, e.getLastState());
216 logger.debug("Got command for unknown channel {}: {}", channelUID, command);
219 final Request reqUpdate;
222 reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + e.address + "&changetox2="
223 + (command == OnOffType.ON ? "1" : "0")));
224 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
227 ChangerX2Entry cx2e = e.changerX2Entry;
229 reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2e.address
230 + "&changetox2=" + (command == OnOffType.ON ? "1" : "0")));
231 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
233 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
238 ChangerX2Entry cx2sf = e.changerX2Entry;
240 String val = cx2sf.options.get(((StringType) command).toFullString());
242 reqUpdate = prepareRequest(
243 buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2sf.address + "&changetox2=" + val));
244 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
246 logger.warn("Got unknown form command {} for channel {}; Valid commands are: {}", command,
247 channelUID, cx2sf.options.keySet());
251 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
256 ChangerX2Entry cx2en = e.changerX2Entry;
259 if (command instanceof Number qt) {
260 val = String.format(Locale.US, "%.2f", qt.floatValue());
261 } else if (command instanceof DateTimeType dtt) {
262 // time is transferred as minutes since midnight...
263 var zdt = dtt.getZonedDateTime();
264 val = Integer.toString(zdt.getHour() * 60 + zdt.getMinute());
266 val = command.format("%.2f");
268 reqUpdate = prepareRequest(
269 buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2en.address + "&changetox2=" + val));
270 reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
272 logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
276 case READ_ONLY_NUMERIC:
277 case READ_ONLY_STATE:
278 case READ_ONLY_SWITCH:
279 logger.debug("Got command for ReadOnly channel {}: {}", channelUID, command);
282 logger.debug("Got command for unhandled type {} channel {}: {}", e.type, channelUID, command);
286 e.setLastCommandTS(System.currentTimeMillis());
287 ContentResponse res = reqUpdate.send();
288 if (res.getStatus() == 200) {
289 // update ok, we update the state
290 e.setLastState((State) command);
291 updateState(channelUID, (State) command);
293 logger.warn("Error sending update for {} = {}: {} {}", channelUID, command, res.getStatus(),
296 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
297 logger.warn("Error sending update for {} = {}: {}", channelUID, command, ex.getMessage());
301 // make it accessible for ApiPageParser
303 protected void updateState(final ChannelUID channelUID, final State state) {
304 super.updateState(channelUID, state);
308 public void dispose() {
309 final ScheduledFuture<?> scheduledFuture = this.scheduledFuture;
310 if (scheduledFuture != null) {
311 scheduledFuture.cancel(true);
312 this.scheduledFuture = null;