]> git.basschouten.com Git - openhab-addons.git/blob
7b75b079ad6c2df1b4f5cf4b3ff17c7fa4d6c551
[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.mercedesme.internal.handler;
14
15 import java.util.ArrayList;
16 import java.util.HashMap;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.Optional;
20 import java.util.UUID;
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.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
34 import org.json.JSONArray;
35 import org.json.JSONObject;
36 import org.openhab.binding.mercedesme.internal.Constants;
37 import org.openhab.binding.mercedesme.internal.config.AccountConfiguration;
38 import org.openhab.binding.mercedesme.internal.discovery.MercedesMeDiscoveryService;
39 import org.openhab.binding.mercedesme.internal.server.AuthServer;
40 import org.openhab.binding.mercedesme.internal.server.AuthService;
41 import org.openhab.binding.mercedesme.internal.server.MBWebsocket;
42 import org.openhab.binding.mercedesme.internal.utils.Utils;
43 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
44 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
45 import org.openhab.core.config.core.Configuration;
46 import org.openhab.core.i18n.LocaleProvider;
47 import org.openhab.core.net.NetworkAddressService;
48 import org.openhab.core.storage.Storage;
49 import org.openhab.core.storage.StorageService;
50 import org.openhab.core.thing.Bridge;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.binding.BaseBridgeHandler;
55 import org.openhab.core.types.Command;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
58
59 import com.daimler.mbcarkit.proto.Client.ClientMessage;
60 import com.daimler.mbcarkit.proto.VehicleEvents.VEPUpdate;
61 import com.daimler.mbcarkit.proto.Vehicleapi.AppTwinCommandStatusUpdatesByPID;
62
63 /**
64  * The {@link AccountHandler} acts as Bridge between MercedesMe Account and the associated vehicles
65  *
66  * @author Bernd Weymann - Initial contribution
67  */
68 @NonNullByDefault
69 public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
70     private static final String FEATURE_APPENDIX = "-features";
71     private static final String COMMAND_APPENDIX = "-commands";
72
73     private final Logger logger = LoggerFactory.getLogger(AccountHandler.class);
74     private final NetworkAddressService networkService;
75     private final MercedesMeDiscoveryService discoveryService;
76     private final HttpClient httpClient;
77     private final LocaleProvider localeProvider;
78     private final Storage<String> storage;
79     private final Map<String, VehicleHandler> activeVehicleHandlerMap = new HashMap<>();
80     private final Map<String, VEPUpdate> vepUpdateMap = new HashMap<>();
81     private final Map<String, Map<String, Object>> capabilitiesMap = new HashMap<>();
82
83     private Optional<AuthServer> server = Optional.empty();
84     private Optional<AuthService> authService = Optional.empty();
85     private Optional<ScheduledFuture<?>> scheduledFuture = Optional.empty();
86
87     private String capabilitiesEndpoint = "/v1/vehicle/%s/capabilities";
88     private String commandCapabilitiesEndpoint = "/v1/vehicle/%s/capabilities/commands";
89     private String poiEndpoint = "/v1/vehicle/%s/route";
90
91     final MBWebsocket ws;
92     Optional<AccountConfiguration> config = Optional.empty();
93     @Nullable
94     ClientMessage message;
95
96     public AccountHandler(Bridge bridge, MercedesMeDiscoveryService mmds, HttpClient hc, LocaleProvider lp,
97             StorageService store, NetworkAddressService nas) {
98         super(bridge);
99         discoveryService = mmds;
100         networkService = nas;
101         ws = new MBWebsocket(this);
102         httpClient = hc;
103         localeProvider = lp;
104         storage = store.getStorage(Constants.BINDING_ID);
105     }
106
107     @Override
108     public void handleCommand(ChannelUID channelUID, Command command) {
109     }
110
111     @Override
112     public void initialize() {
113         updateStatus(ThingStatus.UNKNOWN);
114         config = Optional.of(getConfigAs(AccountConfiguration.class));
115         autodetectCallback();
116         String configValidReason = configValid();
117         if (!configValidReason.isEmpty()) {
118             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configValidReason);
119         } else {
120             String callbackUrl = Utils.getCallbackAddress(config.get().callbackIP, config.get().callbackPort);
121             thing.setProperty("callbackUrl", callbackUrl);
122             server = Optional.of(new AuthServer(httpClient, config.get(), callbackUrl));
123             authService = Optional
124                     .of(new AuthService(this, httpClient, config.get(), localeProvider.getLocale(), storage));
125             if (!server.get().start()) {
126                 String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
127                         + Constants.STATUS_SERVER_RESTART;
128                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
129                         textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
130             } else {
131                 scheduledFuture = Optional.of(scheduler.scheduleWithFixedDelay(this::update, 0,
132                         config.get().refreshInterval, TimeUnit.MINUTES));
133             }
134         }
135     }
136
137     public void update() {
138         if (server.isPresent()) {
139             if (!Constants.NOT_SET.equals(authService.get().getToken())) {
140                 ws.run();
141             } else {
142                 // all failed - start manual authorization
143                 String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
144                         + Constants.STATUS_AUTH_NEEDED;
145                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
146                         textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
147             }
148         } else {
149             // server not running - fix first
150             String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
151                     + Constants.STATUS_SERVER_RESTART;
152             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, textKey);
153         }
154     }
155
156     private void autodetectCallback() {
157         // if Callback IP and Callback Port are not set => autodetect these values
158         config = Optional.of(getConfigAs(AccountConfiguration.class));
159         Configuration updateConfig = super.editConfiguration();
160         if (!updateConfig.containsKey("callbackPort")) {
161             updateConfig.put("callbackPort", Utils.getFreePort());
162         } else {
163             Utils.addPort(config.get().callbackPort);
164         }
165         if (!updateConfig.containsKey("callbackIP")) {
166             String ip = networkService.getPrimaryIpv4HostAddress();
167             if (ip != null) {
168                 updateConfig.put("callbackIP", ip);
169             } else {
170                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
171                         "@text/mercedesme.account.status.ip-autodetect-failure");
172             }
173         }
174         super.updateConfiguration(updateConfig);
175         // get new config after update
176         config = Optional.of(getConfigAs(AccountConfiguration.class));
177     }
178
179     private String configValid() {
180         config = Optional.of(getConfigAs(AccountConfiguration.class));
181         String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId();
182         if (Constants.NOT_SET.equals(config.get().callbackIP)) {
183             return textKey + Constants.STATUS_IP_MISSING;
184         } else if (config.get().callbackPort == -1) {
185             return textKey + Constants.STATUS_PORT_MISSING;
186         } else if (Constants.NOT_SET.equals(config.get().email)) {
187             return textKey + Constants.STATUS_EMAIL_MISSING;
188         } else if (Constants.NOT_SET.equals(config.get().region)) {
189             return textKey + Constants.STATUS_REGION_MISSING;
190         } else if (config.get().refreshInterval <= 01) {
191             return textKey + Constants.STATUS_REFRESH_INVALID;
192         } else {
193             return Constants.EMPTY;
194         }
195     }
196
197     @Override
198     public void dispose() {
199         if (server.isPresent()) {
200             AuthServer authServer = server.get();
201             authServer.stop();
202             authServer.dispose();
203             server = Optional.empty();
204             Utils.removePort(config.get().callbackPort);
205         }
206         ws.interrupt();
207         scheduledFuture.ifPresent(schedule -> {
208             if (!schedule.isCancelled()) {
209                 schedule.cancel(true);
210             }
211         });
212     }
213
214     /**
215      * https://next.openhab.org/javadoc/latest/org/openhab/core/auth/client/oauth2/package-summary.html
216      */
217     @Override
218     public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
219         if (!Constants.NOT_SET.equals(tokenResponse.getAccessToken())) {
220             scheduler.schedule(this::update, 2, TimeUnit.SECONDS);
221         } else if (server.isEmpty()) {
222             // server not running - fix first
223             String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
224                     + Constants.STATUS_SERVER_RESTART;
225             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, textKey);
226         } else {
227             // all failed - start manual authorization
228             String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
229                     + Constants.STATUS_AUTH_NEEDED;
230             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
231                     textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
232         }
233     }
234
235     @Override
236     public String toString() {
237         return Integer.toString(config.get().callbackPort);
238     }
239
240     public String getWSUri() {
241         return Utils.getWebsocketServer(config.get().region);
242     }
243
244     public ClientUpgradeRequest getClientUpgradeRequest() {
245         ClientUpgradeRequest request = new ClientUpgradeRequest();
246         request.setHeader("Authorization", authService.get().getToken());
247         request.setHeader("X-SessionId", UUID.randomUUID().toString());
248         request.setHeader("X-TrackingId", UUID.randomUUID().toString());
249         request.setHeader("Ris-Os-Name", Constants.RIS_OS_NAME);
250         request.setHeader("Ris-Os-Version", Constants.RIS_OS_VERSION);
251         request.setHeader("Ris-Sdk-Version", Utils.getRisSDKVersion(config.get().region));
252         request.setHeader("X-Locale",
253                 localeProvider.getLocale().getLanguage() + "-" + localeProvider.getLocale().getCountry()); // de-DE
254         request.setHeader("User-Agent", Utils.getApplication(config.get().region));
255         request.setHeader("X-Applicationname", Utils.getUserAgent(config.get().region));
256         request.setHeader("Ris-Application-Version", Utils.getRisApplicationVersion(config.get().region));
257         return request;
258     }
259
260     public void registerVin(String vin, VehicleHandler handler) {
261         discoveryService.vehicleRemove(this, vin, handler.getThing().getThingTypeUID().getId());
262         activeVehicleHandlerMap.put(vin, handler);
263         VEPUpdate updateForVin = vepUpdateMap.get(vin);
264         if (updateForVin != null) {
265             handler.distributeContent(updateForVin);
266         }
267     }
268
269     public void unregisterVin(String vin) {
270         activeVehicleHandlerMap.remove(vin);
271     }
272
273     @SuppressWarnings("null")
274     public void getVehicleCapabilities(String vin) {
275         if (storage.containsKey(vin + FEATURE_APPENDIX)) {
276             if (activeVehicleHandlerMap.containsKey(vin)) {
277                 activeVehicleHandlerMap.get(vin).setFeatureCapabilities(storage.get(vin + FEATURE_APPENDIX));
278             }
279         }
280         if (storage.containsKey(vin + COMMAND_APPENDIX)) {
281             if (activeVehicleHandlerMap.containsKey(vin)) {
282                 activeVehicleHandlerMap.get(vin).setCommandCapabilities(storage.get(vin + COMMAND_APPENDIX));
283             }
284         }
285     }
286
287     public boolean distributeVepUpdates(Map<String, VEPUpdate> map) {
288         List<String> notFoundList = new ArrayList<>();
289         map.forEach((key, value) -> {
290             VehicleHandler h = activeVehicleHandlerMap.get(key);
291             if (h != null) {
292                 h.distributeContent(value);
293             } else {
294                 if (value.getFullUpdate()) {
295                     vepUpdateMap.put(key, value);
296                 }
297                 notFoundList.add(key);
298             }
299         });
300         notFoundList.forEach(vin -> {
301             logger.trace("No VehicleHandler available for VIN {}", vin);
302         });
303         return notFoundList.isEmpty();
304     }
305
306     public void commandStatusUpdate(Map<String, AppTwinCommandStatusUpdatesByPID> updatesByVinMap) {
307         updatesByVinMap.forEach((key, value) -> {
308             VehicleHandler h = activeVehicleHandlerMap.get(key);
309             if (h != null) {
310                 h.distributeCommandStatus(value);
311             } else {
312                 logger.trace("No VehicleHandler available for VIN {}", key);
313             }
314         });
315     }
316
317     @SuppressWarnings("null")
318     public void discovery(String vin) {
319         if (activeVehicleHandlerMap.containsKey(vin)) {
320             VehicleHandler vh = activeVehicleHandlerMap.get(vin);
321             if (vh.getThing().getProperties().isEmpty()) {
322                 vh.getThing().setProperties(getStringCapabilities(vin));
323             }
324         } else {
325             if (!capabilitiesMap.containsKey(vin)) {
326                 // only report new discovery if capabilities aren't discovered yet
327                 discoveryService.vehicleDiscovered(this, vin, getCapabilities(vin));
328             }
329         }
330     }
331
332     private Map<String, String> getStringCapabilities(String vin) {
333         Map<String, Object> props = getCapabilities(vin);
334         Map<String, String> stringProps = new HashMap<>();
335         props.forEach((key, value) -> {
336             stringProps.put(key, value.toString());
337         });
338         return stringProps;
339     }
340
341     private Map<String, Object> getCapabilities(String vin) {
342         // check cache before hammering API
343         Map<String, Object> m = capabilitiesMap.get(vin);
344         if (m != null) {
345             return m;
346         }
347         Map<String, Object> featureMap = new HashMap<>();
348         try {
349             // add vehicle capabilities
350             String capabilitiesUrl = Utils.getRestAPIServer(config.get().region)
351                     + String.format(capabilitiesEndpoint, vin);
352             Request capabilitiesRequest = httpClient.newRequest(capabilitiesUrl);
353             authService.get().addBasicHeaders(capabilitiesRequest);
354             capabilitiesRequest.header("X-SessionId", UUID.randomUUID().toString());
355             capabilitiesRequest.header("X-TrackingId", UUID.randomUUID().toString());
356             capabilitiesRequest.header("Authorization", authService.get().getToken());
357
358             ContentResponse capabilitiesResponse = capabilitiesRequest
359                     .timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
360
361             String featureCapabilitiesJsonString = capabilitiesResponse.getContentAsString();
362             if (!storage.containsKey(vin + FEATURE_APPENDIX)) {
363                 storage.put(vin + FEATURE_APPENDIX, featureCapabilitiesJsonString);
364             }
365
366             JSONObject jsonResponse = new JSONObject(featureCapabilitiesJsonString);
367             JSONObject features = jsonResponse.getJSONObject("features");
368             features.keySet().forEach(key -> {
369                 String value = features.get(key).toString();
370                 String newKey = Character.toUpperCase(key.charAt(0)) + key.substring(1);
371                 newKey = "feature" + newKey;
372                 featureMap.put(newKey, value);
373             });
374
375             // get vehicle type
376             JSONObject vehicle = jsonResponse.getJSONObject("vehicle");
377             JSONArray fuelTypes = vehicle.getJSONArray("fuelTypes");
378             if (fuelTypes.length() > 1) {
379                 featureMap.put("vehicle", Constants.HYBRID);
380             } else if ("ELECTRIC".equals(fuelTypes.get(0))) {
381                 featureMap.put("vehicle", Constants.BEV);
382             } else {
383                 featureMap.put("vehicle", Constants.COMBUSTION);
384             }
385
386             // add command capabilities
387             String commandCapabilitiesUrl = Utils.getRestAPIServer(config.get().region)
388                     + String.format(commandCapabilitiesEndpoint, vin);
389             Request commandCapabilitiesRequest = httpClient.newRequest(commandCapabilitiesUrl);
390             authService.get().addBasicHeaders(commandCapabilitiesRequest);
391             commandCapabilitiesRequest.header("X-SessionId", UUID.randomUUID().toString());
392             commandCapabilitiesRequest.header("X-TrackingId", UUID.randomUUID().toString());
393             commandCapabilitiesRequest.header("Authorization", authService.get().getToken());
394             ContentResponse commandCapabilitiesResponse = commandCapabilitiesRequest
395                     .timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
396
397             String commandCapabilitiesJsonString = commandCapabilitiesResponse.getContentAsString();
398             if (!storage.containsKey(vin + COMMAND_APPENDIX)) {
399                 storage.put(vin + COMMAND_APPENDIX, commandCapabilitiesJsonString);
400             }
401             JSONObject commands = new JSONObject(commandCapabilitiesJsonString);
402             JSONArray commandArray = commands.getJSONArray("commands");
403             commandArray.forEach(object -> {
404                 String commandName = ((JSONObject) object).get("commandName").toString();
405                 String[] words = commandName.split("[\\W_]+");
406                 StringBuilder builder = new StringBuilder();
407                 builder.append("command");
408                 for (int i = 0; i < words.length; i++) {
409                     String word = words[i];
410                     word = word.isEmpty() ? word
411                             : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase();
412                     builder.append(word);
413                 }
414                 String value = ((JSONObject) object).get("isAvailable").toString();
415                 featureMap.put(builder.toString(), value);
416             });
417             // store in cache
418             capabilitiesMap.put(vin, featureMap);
419             return featureMap;
420         } catch (InterruptedException | TimeoutException | ExecutionException e) {
421             logger.trace("Error retrieving capabilities: {}", e.getMessage());
422             featureMap.clear();
423         }
424         return featureMap;
425     }
426
427     public void sendCommand(@Nullable ClientMessage cm) {
428         if (cm != null) {
429             ws.setCommand(cm);
430         }
431         scheduler.schedule(this::update, 2, TimeUnit.SECONDS);
432     }
433
434     public void keepAlive(boolean b) {
435         ws.keepAlive(b);
436     }
437
438     @Override
439     public void updateStatus(ThingStatus ts) {
440         super.updateStatus(ts);
441     }
442
443     @Override
444     public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String tsdt) {
445         super.updateStatus(ts, tsd, tsdt);
446     }
447
448     /**
449      * Vehicle Actions
450      *
451      * @param poi
452      */
453
454     public void sendPoi(String vin, JSONObject poi) {
455         String poiUrl = Utils.getRestAPIServer(config.get().region) + String.format(poiEndpoint, vin);
456         Request poiRequest = httpClient.POST(poiUrl);
457         authService.get().addBasicHeaders(poiRequest);
458         poiRequest.header("X-SessionId", UUID.randomUUID().toString());
459         poiRequest.header("X-TrackingId", UUID.randomUUID().toString());
460         poiRequest.header("Authorization", authService.get().getToken());
461         poiRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
462         poiRequest.content(new StringContentProvider(poi.toString(), "utf-8"));
463
464         try {
465             ContentResponse cr = poiRequest.timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
466             logger.trace("Send POI Response {} : {}", cr.getStatus(), cr.getContentAsString());
467         } catch (InterruptedException | TimeoutException | ExecutionException e) {
468             logger.trace("Error Sending POI {}", e.getMessage());
469         }
470     }
471 }