]> git.basschouten.com Git - openhab-addons.git/blob
933d77aad3d51875b395b5762f7d2d6b9bf78b18
[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.Map;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
25
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;
55
56 /**
57  * The {@link TACmiSchemaHandler} is responsible for handling commands, which are sent
58  * to one of the channels.
59  *
60  * @author Christian Niessner - Initial contribution
61  */
62 @NonNullByDefault
63 public class TACmiSchemaHandler extends BaseThingHandler {
64
65     private final Logger logger = LoggerFactory.getLogger(TACmiSchemaHandler.class);
66
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;
76
77     public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
78             final TACmiChannelTypeProvider channelTypeProvider) {
79         super(thing);
80         this.httpClient = httpClient;
81         this.channelTypeProvider = channelTypeProvider;
82
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);
91     }
92
93     @Override
94     public void initialize() {
95         final TACmiSchemaConfiguration config = getConfigAs(TACmiSchemaConfiguration.class);
96
97         if (config.host.trim().isEmpty()) {
98             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!");
99             return;
100         }
101         if (config.username.trim().isEmpty()) {
102             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No username configured!");
103             return;
104         }
105         if (config.password.trim().isEmpty()) {
106             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No password configured!");
107             return;
108         }
109         this.online = false;
110         updateStatus(ThingStatus.UNKNOWN);
111
112         this.authHeader = "Basic " + Base64.getEncoder()
113                 .encodeToString((config.username + ":" + config.password).getBytes(StandardCharsets.ISO_8859_1));
114
115         final String serverBase = "http://" + config.host + "/";
116         this.serverBase = serverBase;
117         this.schemaApiPage = buildUri("schematic_files/" + config.schemaId + ".cgi");
118
119         refreshData();
120         if (config.pollInterval <= 0) {
121             config.pollInterval = 10;
122         }
123         // we want to trigger the initial refresh 'at once'
124         this.scheduledFuture = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.pollInterval,
125                 TimeUnit.SECONDS);
126     }
127
128     protected URI buildUri(String path) {
129         return URI.create(serverBase + path);
130     }
131
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;
136         if (ah != null) {
137             req.header(HttpHeader.AUTHORIZATION, ah);
138         }
139         return req;
140     }
141
142     protected <PP extends AbstractSimpleMarkupHandler> PP parsePage(URI uri, PP pp)
143             throws ParseException, InterruptedException, TimeoutException, ExecutionException {
144         final ContentResponse response = prepareRequest(uri).send();
145
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);
151         } else {
152             responseString = response.getContentAsString();
153         }
154
155         if (logger.isTraceEnabled()) {
156             logger.trace("Response body was: {} ", responseString);
157         }
158
159         final ISimpleMarkupParser parser = new SimpleMarkupParser(this.noRestrictions);
160         parser.parse(responseString, pp);
161         return pp;
162     }
163
164     private void refreshData() {
165         URI schemaApiPage = this.schemaApiPage;
166         if (schemaApiPage == null) {
167             return;
168         }
169         try {
170             final ApiPageParser pp = parsePage(schemaApiPage,
171                     new ApiPageParser(this, entries, this.channelTypeProvider));
172
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());
179             }
180             if (!this.online) {
181                 updateStatus(ThingStatus.ONLINE);
182                 this.online = true;
183             }
184         } catch (final InterruptedException e) {
185             // binding shutdown is in progress
186             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
187             this.online = false;
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());
191             this.online = false;
192         } catch (final TimeoutException | ExecutionException e) {
193             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error: " + e.getMessage());
194             this.online = false;
195         }
196     }
197
198     @Override
199     public void handleCommand(final ChannelUID channelUID, final Command command) {
200         final ApiPageEntry e = this.entries.get(channelUID.getId());
201         if (command instanceof RefreshType) {
202             if (e == null) {
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
206                 // can do.
207                 return;
208             }
209             // we have our ApiPageEntry which also holds our last known state - just update it.
210             updateState(channelUID, e.getLastState());
211             return;
212         }
213         if (e == null) {
214             logger.debug("Got command for unknown channel {}: {}", channelUID, command);
215             return;
216         }
217         final Request reqUpdate;
218         switch (e.type) {
219             case SWITCH_BUTTON:
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...
223                 break;
224             case SWITCH_FORM:
225                 ChangerX2Entry cx2e = e.changerX2Entry;
226                 if (cx2e != null) {
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...
230                 } else {
231                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
232                     return;
233                 }
234                 break;
235             case STATE_FORM:
236                 ChangerX2Entry cx2sf = e.changerX2Entry;
237                 if (cx2sf != null) {
238                     String val = cx2sf.options.get(((StringType) command).toFullString());
239                     if (val != null) {
240                         reqUpdate = prepareRequest(
241                                 buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2sf.address + "&changetox2=" + val));
242                         reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
243                     } else {
244                         logger.warn("Got unknown form command {} for channel {}; Valid commands are: {}", command,
245                                 channelUID, cx2sf.options.keySet());
246                         return;
247                     }
248                 } else {
249                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
250                     return;
251                 }
252                 break;
253             case NUMERIC_FORM:
254                 ChangerX2Entry cx2en = e.changerX2Entry;
255                 if (cx2en != null) {
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...
259                 } else {
260                     logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
261                     return;
262                 }
263                 break;
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);
268                 return;
269             default:
270                 logger.debug("Got command for unhandled type {} channel {}: {}", e.type, channelUID, command);
271                 return;
272         }
273         try {
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);
280             } else {
281                 logger.warn("Error sending update for {} = {}: {} {}", channelUID, command, res.getStatus(),
282                         res.getReason());
283             }
284         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
285             logger.warn("Error sending update for {} = {}: {}", channelUID, command, ex.getMessage());
286         }
287     }
288
289     // make it accessible for ApiPageParser
290     @Override
291     protected void updateState(final ChannelUID channelUID, final State state) {
292         super.updateState(channelUID, state);
293     }
294
295     @Override
296     public void dispose() {
297         final ScheduledFuture<?> scheduledFuture = this.scheduledFuture;
298         if (scheduledFuture != null) {
299             scheduledFuture.cancel(true);
300             this.scheduledFuture = null;
301         }
302         super.dispose();
303     }
304 }