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.mercedesme.internal.handler;
15 import java.util.ArrayList;
16 import java.util.HashMap;
17 import java.util.List;
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;
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;
59 import com.daimler.mbcarkit.proto.Client.ClientMessage;
60 import com.daimler.mbcarkit.proto.VehicleEvents.VEPUpdate;
61 import com.daimler.mbcarkit.proto.Vehicleapi.AppTwinCommandStatusUpdatesByPID;
64 * The {@link AccountHandler} acts as Bridge between MercedesMe Account and the associated vehicles
66 * @author Bernd Weymann - Initial contribution
69 public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
70 private static final String FEATURE_APPENDIX = "-features";
71 private static final String COMMAND_APPENDIX = "-commands";
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<>();
83 private Optional<AuthServer> server = Optional.empty();
84 private Optional<AuthService> authService = Optional.empty();
85 private Optional<ScheduledFuture<?>> scheduledFuture = Optional.empty();
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";
92 Optional<AccountConfiguration> config = Optional.empty();
94 ClientMessage message;
96 public AccountHandler(Bridge bridge, MercedesMeDiscoveryService mmds, HttpClient hc, LocaleProvider lp,
97 StorageService store, NetworkAddressService nas) {
99 discoveryService = mmds;
100 networkService = nas;
101 ws = new MBWebsocket(this);
104 storage = store.getStorage(Constants.BINDING_ID);
108 public void handleCommand(ChannelUID channelUID, Command command) {
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);
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") + "\"]");
131 scheduledFuture = Optional.of(scheduler.scheduleWithFixedDelay(this::update, 0,
132 config.get().refreshInterval, TimeUnit.MINUTES));
137 public void update() {
138 if (server.isPresent()) {
139 if (!Constants.NOT_SET.equals(authService.get().getToken())) {
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") + "\"]");
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);
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());
163 Utils.addPort(config.get().callbackPort);
165 if (!updateConfig.containsKey("callbackIP")) {
166 String ip = networkService.getPrimaryIpv4HostAddress();
168 updateConfig.put("callbackIP", ip);
170 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
171 "@text/mercedesme.account.status.ip-autodetect-failure");
174 super.updateConfiguration(updateConfig);
175 // get new config after update
176 config = Optional.of(getConfigAs(AccountConfiguration.class));
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;
193 return Constants.EMPTY;
198 public void dispose() {
199 if (server.isPresent()) {
200 AuthServer authServer = server.get();
202 authServer.dispose();
203 server = Optional.empty();
204 Utils.removePort(config.get().callbackPort);
207 scheduledFuture.ifPresent(schedule -> {
208 if (!schedule.isCancelled()) {
209 schedule.cancel(true);
215 * https://next.openhab.org/javadoc/latest/org/openhab/core/auth/client/oauth2/package-summary.html
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);
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") + "\"]");
236 public String toString() {
237 return Integer.toString(config.get().callbackPort);
240 public String getWSUri() {
241 return Utils.getWebsocketServer(config.get().region);
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));
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);
269 public void unregisterVin(String vin) {
270 activeVehicleHandlerMap.remove(vin);
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));
280 if (storage.containsKey(vin + COMMAND_APPENDIX)) {
281 if (activeVehicleHandlerMap.containsKey(vin)) {
282 activeVehicleHandlerMap.get(vin).setCommandCapabilities(storage.get(vin + COMMAND_APPENDIX));
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);
292 h.distributeContent(value);
294 if (value.getFullUpdate()) {
295 vepUpdateMap.put(key, value);
297 notFoundList.add(key);
300 notFoundList.forEach(vin -> {
301 logger.trace("No VehicleHandler available for VIN {}", vin);
303 return notFoundList.isEmpty();
306 public void commandStatusUpdate(Map<String, AppTwinCommandStatusUpdatesByPID> updatesByVinMap) {
307 updatesByVinMap.forEach((key, value) -> {
308 VehicleHandler h = activeVehicleHandlerMap.get(key);
310 h.distributeCommandStatus(value);
312 logger.trace("No VehicleHandler available for VIN {}", key);
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));
325 if (!capabilitiesMap.containsKey(vin)) {
326 // only report new discovery if capabilities aren't discovered yet
327 discoveryService.vehicleDiscovered(this, vin, getCapabilities(vin));
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());
341 private Map<String, Object> getCapabilities(String vin) {
342 // check cache before hammering API
343 Map<String, Object> m = capabilitiesMap.get(vin);
347 Map<String, Object> featureMap = new HashMap<>();
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());
358 ContentResponse capabilitiesResponse = capabilitiesRequest
359 .timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
361 String featureCapabilitiesJsonString = capabilitiesResponse.getContentAsString();
362 if (!storage.containsKey(vin + FEATURE_APPENDIX)) {
363 storage.put(vin + FEATURE_APPENDIX, featureCapabilitiesJsonString);
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);
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);
383 featureMap.put("vehicle", Constants.COMBUSTION);
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();
397 String commandCapabilitiesJsonString = commandCapabilitiesResponse.getContentAsString();
398 if (!storage.containsKey(vin + COMMAND_APPENDIX)) {
399 storage.put(vin + COMMAND_APPENDIX, commandCapabilitiesJsonString);
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);
414 String value = ((JSONObject) object).get("isAvailable").toString();
415 featureMap.put(builder.toString(), value);
418 capabilitiesMap.put(vin, featureMap);
420 } catch (InterruptedException | TimeoutException | ExecutionException e) {
421 logger.trace("Error retrieving capabilities: {}", e.getMessage());
427 public void sendCommand(@Nullable ClientMessage cm) {
431 scheduler.schedule(this::update, 2, TimeUnit.SECONDS);
434 public void keepAlive(boolean b) {
439 public void updateStatus(ThingStatus ts) {
440 super.updateStatus(ts);
444 public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String tsdt) {
445 super.updateStatus(ts, tsd, tsdt);
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"));
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());