]> git.basschouten.com Git - openhab-addons.git/blob
8146151150e110d72c00acc3934880a2a30b6c8c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.tacmi.internal.schema;
14
15 import java.net.URI;
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;
21 import java.util.Map;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26
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;
57
58 /**
59  * The {@link TACmiSchemaHandler} is responsible for handling commands, which are sent
60  * to one of the channels.
61  *
62  * @author Christian Niessner - Initial contribution
63  */
64 @NonNullByDefault
65 public class TACmiSchemaHandler extends BaseThingHandler {
66
67     private final Logger logger = LoggerFactory.getLogger(TACmiSchemaHandler.class);
68
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;
78
79     public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
80             final TACmiChannelTypeProvider channelTypeProvider) {
81         super(thing);
82         this.httpClient = httpClient;
83         this.channelTypeProvider = channelTypeProvider;
84
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);
93     }
94
95     @Override
96     public void initialize() {
97         final TACmiSchemaConfiguration config = getConfigAs(TACmiSchemaConfiguration.class);
98
99         if (config.host.trim().isEmpty()) {
100             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!");
101             return;
102         }
103         if (config.username.trim().isEmpty()) {
104             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No username configured!");
105             return;
106         }
107         if (config.password.trim().isEmpty()) {
108             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No password configured!");
109             return;
110         }
111         this.online = false;
112         updateStatus(ThingStatus.UNKNOWN);
113
114         this.authHeader = "Basic " + Base64.getEncoder()
115                 .encodeToString((config.username + ":" + config.password).getBytes(StandardCharsets.ISO_8859_1));
116
117         final String serverBase = "http://" + config.host + "/";
118         this.serverBase = serverBase;
119         this.schemaApiPage = buildUri("schematic_files/" + config.schemaId + ".cgi");
120
121         refreshData();
122         if (config.pollInterval <= 0) {
123             config.pollInterval = 10;
124         }
125         // we want to trigger the initial refresh 'at once'
126         this.scheduledFuture = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.pollInterval,
127                 TimeUnit.SECONDS);
128     }
129
130     protected URI buildUri(String path) {
131         return URI.create(serverBase + path);
132     }
133
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;
138         if (ah != null) {
139             req.header(HttpHeader.AUTHORIZATION, ah);
140         }
141         return req;
142     }
143
144     protected <PP extends AbstractSimpleMarkupHandler> PP parsePage(URI uri, PP pp)
145             throws ParseException, InterruptedException, TimeoutException, ExecutionException {
146         final ContentResponse response = prepareRequest(uri).send();
147
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);
153         } else {
154             responseString = response.getContentAsString();
155         }
156
157         if (logger.isTraceEnabled()) {
158             logger.trace("Response body was: {} ", responseString);
159         }
160
161         final ISimpleMarkupParser parser = new SimpleMarkupParser(this.noRestrictions);
162         parser.parse(responseString, pp);
163         return pp;
164     }
165
166     private void refreshData() {
167         URI schemaApiPage = this.schemaApiPage;
168         if (schemaApiPage == null) {
169             return;
170         }
171         try {
172             final ApiPageParser pp = parsePage(schemaApiPage,
173                     new ApiPageParser(this, entries, this.channelTypeProvider));
174
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());
181             }
182             if (!this.online) {
183                 updateStatus(ThingStatus.ONLINE);
184                 this.online = true;
185             }
186         } catch (final InterruptedException e) {
187             // binding shutdown is in progress
188             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
189             this.online = false;
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());
193             this.online = false;
194         } catch (final TimeoutException | ExecutionException e) {
195             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error: " + e.getMessage());
196             this.online = false;
197         }
198     }
199
200     @Override
201     public void handleCommand(final ChannelUID channelUID, final Command command) {
202         final ApiPageEntry e = this.entries.get(channelUID.getId());
203         if (command instanceof RefreshType) {
204             if (e == null) {
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
208                 // can do.
209                 return;
210             }
211             // we have our ApiPageEntry which also holds our last known state - just update it.
212             updateState(channelUID, e.getLastState());
213             return;
214         }
215         if (e == null) {
216             logger.debug("Got command for unknown channel {}: {}", channelUID, command);
217             return;
218         }
219         final Request reqUpdate;
220         switch (e.type) {
221             case SWITCH_BUTTON:
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...
225                 break;
226             case SWITCH_FORM:
227                 ChangerX2Entry cx2e = e.changerX2Entry;
228                 if (cx2e != null) {
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...
232                 } else {
233                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
234                     return;
235                 }
236                 break;
237             case STATE_FORM:
238                 ChangerX2Entry cx2sf = e.changerX2Entry;
239                 if (cx2sf != null) {
240                     String val = cx2sf.options.get(((StringType) command).toFullString());
241                     if (val != null) {
242                         reqUpdate = prepareRequest(
243                                 buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2sf.address + "&changetox2=" + val));
244                         reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
245                     } else {
246                         logger.warn("Got unknown form command {} for channel {}; Valid commands are: {}", command,
247                                 channelUID, cx2sf.options.keySet());
248                         return;
249                     }
250                 } else {
251                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
252                     return;
253                 }
254                 break;
255             case NUMERIC_FORM:
256                 ChangerX2Entry cx2en = e.changerX2Entry;
257                 if (cx2en != null) {
258                     String val;
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());
265                     } else {
266                         val = command.format("%.2f");
267                     }
268                     reqUpdate = prepareRequest(
269                             buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2en.address + "&changetox2=" + val));
270                     reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
271                 } else {
272                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
273                     return;
274                 }
275                 break;
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);
280                 return;
281             default:
282                 logger.debug("Got command for unhandled type {} channel {}: {}", e.type, channelUID, command);
283                 return;
284         }
285         try {
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);
292             } else {
293                 logger.warn("Error sending update for {} = {}: {} {}", channelUID, command, res.getStatus(),
294                         res.getReason());
295             }
296         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
297             logger.warn("Error sending update for {} = {}: {}", channelUID, command, ex.getMessage());
298         }
299     }
300
301     // make it accessible for ApiPageParser
302     @Override
303     protected void updateState(final ChannelUID channelUID, final State state) {
304         super.updateState(channelUID, state);
305     }
306
307     @Override
308     public void dispose() {
309         final ScheduledFuture<?> scheduledFuture = this.scheduledFuture;
310         if (scheduledFuture != null) {
311             scheduledFuture.cancel(true);
312             this.scheduledFuture = null;
313         }
314         super.dispose();
315     }
316 }