2 * Copyright (c) 2010-2023 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.mybmw.internal.console;
15 import static org.openhab.binding.mybmw.internal.MyBMWConstants.BINDING_ID;
16 import static org.openhab.binding.mybmw.internal.MyBMWConstants.THING_TYPE_CONNECTED_DRIVE_ACCOUNT;
19 import java.io.FileOutputStream;
20 import java.io.IOException;
21 import java.nio.charset.StandardCharsets;
22 import java.nio.file.FileVisitResult;
23 import java.nio.file.Files;
24 import java.nio.file.Path;
25 import java.nio.file.Paths;
26 import java.nio.file.SimpleFileVisitor;
27 import java.nio.file.attribute.BasicFileAttributes;
28 import java.time.LocalDateTime;
29 import java.time.format.DateTimeFormatter;
30 import java.util.Arrays;
31 import java.util.Comparator;
32 import java.util.List;
33 import java.util.NoSuchElementException;
34 import java.util.Objects;
35 import java.util.Optional;
36 import java.util.stream.Collectors;
37 import java.util.zip.ZipEntry;
38 import java.util.zip.ZipOutputStream;
40 import org.eclipse.jdt.annotation.NonNull;
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.eclipse.jdt.annotation.Nullable;
43 import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleBase;
44 import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler;
45 import org.openhab.binding.mybmw.internal.handler.backend.NetworkException;
46 import org.openhab.binding.mybmw.internal.handler.backend.ResponseContentAnonymizer;
47 import org.openhab.binding.mybmw.internal.utils.BimmerConstants;
48 import org.openhab.core.io.console.Console;
49 import org.openhab.core.io.console.ConsoleCommandCompleter;
50 import org.openhab.core.io.console.StringsCompleter;
51 import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
52 import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
53 import org.openhab.core.thing.ThingRegistry;
54 import org.openhab.core.thing.ThingStatus;
55 import org.osgi.service.component.annotations.Activate;
56 import org.osgi.service.component.annotations.Component;
57 import org.osgi.service.component.annotations.Reference;
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonArray;
62 import com.google.gson.JsonElement;
63 import com.google.gson.JsonParser;
64 import com.google.gson.JsonSyntaxException;
67 * The {@link MyBMWCommandExtension} is responsible for handling console commands
69 * @author Mark Herwege - Initial contribution
70 * @author Martin Grassl - improved exception handling
74 @Component(service = ConsoleCommandExtension.class)
75 public class MyBMWCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter {
77 private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
79 private static final String FINGERPRINT_ROOT_PATH = System.getProperty("user.home") + File.separator + BINDING_ID;
81 private static final String FINGERPRINT = "fingerprint";
82 private static final StringsCompleter CMD_COMPLETER = new StringsCompleter(List.of(FINGERPRINT), false);
84 private final ThingRegistry thingRegistry;
87 public MyBMWCommandExtension(final @Reference ThingRegistry thingRegistry) {
88 super("mybmw", "Interact with the MyBMW binding");
89 this.thingRegistry = thingRegistry;
93 public void execute(String[] args, Console console) {
94 if ((args.length < 1) || (args.length > 3)) {
95 console.println("Invalid number of arguments");
100 List<MyBMWBridgeHandler> bridgeHandlers = thingRegistry.stream()
101 .filter(t -> THING_TYPE_CONNECTED_DRIVE_ACCOUNT.equals(t.getThingTypeUID()))
102 .map(b -> ((MyBMWBridgeHandler) b.getHandler())).filter(Objects::nonNull).collect(Collectors.toList());
103 if (bridgeHandlers.isEmpty()) {
104 console.println("No account bridges configured");
108 if (!FINGERPRINT.equalsIgnoreCase(args[0])) {
109 console.println("Unsupported command '" + args[0] + "'");
114 List<MyBMWBridgeHandler> handlers;
115 if (args.length > 1) {
116 handlers = bridgeHandlers.stream()
117 .filter(b -> args[1].equalsIgnoreCase(b.getThing().getConfiguration().get("userName").toString()))
118 .filter(Objects::nonNull).collect(Collectors.toList());
119 if (handlers.isEmpty()) {
120 console.println("No myBMW account bridge for user '" + args[1] + "'");
125 handlers = bridgeHandlers;
128 String basePath = FINGERPRINT_ROOT_PATH + File.separator
129 + LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE);
130 String path = nextPath(basePath, null);
132 console.println("# Start fingerprint");
134 for (MyBMWBridgeHandler handler : handlers) {
136 console.println("### Account " + String.valueOf(accountNdx));
137 if (!ThingStatus.ONLINE.equals(handler.getThing().getStatus())) {
138 console.println("MyBMW bridge for account not online, cannot create fingerprint");
140 String accountPath = path + File.separator + "Account-" + String.valueOf(accountNdx);
141 handler.getMyBmwProxy().ifPresentOrElse(prox -> {
142 // get list of vehicles
143 List<@NonNull VehicleBase> vehicles = null;
145 vehicles = prox.requestVehiclesBase();
147 for (String brand : BimmerConstants.REQUESTED_BRANDS) {
148 console.println("###### Vehicles base for brand " + brand);
149 printAndSave(console, accountPath, "VehicleBase_" + brand,
150 prox.requestVehiclesBaseJson(brand));
153 if (args.length == 3) {
154 Optional<VehicleBase> vehicleOptional = vehicles.stream()
155 .filter(v -> v.getVin().equalsIgnoreCase(args[2])).findAny();
156 if (vehicleOptional.isEmpty()) {
157 console.println("'" + args[2] + "' is not a valid vin on the account bridge with id '"
158 + handler.getThing().getUID().getId() + "'");
162 vehicles = List.of(vehicleOptional.get());
166 for (VehicleBase vehicleBase : vehicles) {
168 String vinPath = accountPath + File.separator + "Vin-" + String.valueOf(vinNdx);
169 console.println("###### Vehicle " + String.valueOf(vinNdx));
172 console.println("######## Vehicle state");
173 printAndSave(console, vinPath, "VehicleState", prox.requestVehicleStateJson(
174 vehicleBase.getVin(), vehicleBase.getAttributes().getBrand()));
176 // get charge statistics -> only successful for electric vehicles
177 console.println("######### Vehicle charging statistics");
178 printAndSave(console, vinPath, "VehicleChargingStatistics",
179 prox.requestChargeStatisticsJson(vehicleBase.getVin(),
180 vehicleBase.getAttributes().getBrand()));
182 // get charge sessions -> only successful for electric vehicles
183 console.println("######### Vehicle charging sessions");
184 printAndSave(console, vinPath, "VehicleChargingSessions", prox.requestChargeSessionsJson(
185 vehicleBase.getVin(), vehicleBase.getAttributes().getBrand()));
187 console.println("###### End vehicle " + String.valueOf(vinNdx));
189 } catch (NetworkException e) {
190 console.println("Fingerprint failed, network exception: " + e.getReason());
193 console.println("MyBMW bridge with id '" + handler.getThing().getUID().getId()
194 + "', communication not started, cannot retrieve fingerprint");
197 console.println("### End account " + String.valueOf(accountNdx));
201 String zipfile = nextPath(basePath, "zip");
202 zipDirectory(Paths.get(path), Paths.get(zipfile));
203 deleteDirectory(path);
204 console.println("### Fingerprint has been written to zipfile: " + zipfile);
205 } catch (IOException e) {
206 console.println("Exception zipping fingerprint: " + e.getMessage());
207 console.println("### Fingerprint has been written to files in directory: " + path);
210 console.println("# End fingerprint");
213 private void printAndSave(Console console, String path, String filename, String content) throws NetworkException {
214 String json = prettyJson(ResponseContentAnonymizer.anonymizeResponseContent(content));
215 console.println(json);
217 writeJsonToFile(path, filename, json);
218 } catch (IOException e) {
219 console.println("Exception writing to file: " + e.getMessage());
223 private String nextPath(String pathString, @Nullable String extension) {
224 String path = pathString + ((extension != null) ? ("." + extension) : "");
226 while (Files.exists(Paths.get(path))) {
227 path = pathString + "_" + String.valueOf(pathNdx) + ((extension != null) ? ("." + extension) : "");
233 private String prettyJson(String json) {
235 return GSON.toJson(JsonParser.parseString(json));
236 } catch (JsonSyntaxException e) {
237 // Keep the unformatted json if there is a syntax exception
242 private void writeJsonToFile(String pathString, String filename, String json) throws IOException {
244 JsonElement element = JsonParser.parseString(json);
245 if (element.isJsonNull() || (element.isJsonArray() && ((JsonArray) element).size() == 0)) {
246 // Don't write a file if empty
249 } catch (JsonSyntaxException e) {
250 // Just continue and write the file with non-valid json anyway
253 String path = nextPath(pathString + File.separator + filename, "json");
255 // ensure full path exists
256 File file = new File(path);
257 file.getParentFile().mkdirs();
259 final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
260 Files.write(file.toPath(), contents);
264 // https://stackoverflow.com/questions/57997257/how-can-i-zip-a-complete-directory-with-all-subfolders-in-java
265 private void zipDirectory(Path sourceDirectoryPath, Path zipPath) throws IOException {
266 try (FileOutputStream fos = new FileOutputStream(zipPath.toFile());
267 ZipOutputStream zos = new ZipOutputStream(fos)) {
268 Files.walkFileTree(sourceDirectoryPath, new SimpleFileVisitor<@Nullable Path>() {
270 public FileVisitResult visitFile(@Nullable Path file, @Nullable BasicFileAttributes attrs)
272 zos.putNextEntry(new ZipEntry(sourceDirectoryPath.relativize(file).toString()));
273 Files.copy(file, zos);
275 return FileVisitResult.CONTINUE;
278 } catch (IOException e) {
283 private void deleteDirectory(String path) throws IOException {
284 Files.walk(Paths.get(path)).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
288 public List<String> getUsages() {
289 return Arrays.asList(
290 new String[] { buildCommandUsage(FINGERPRINT, "generate fingerprint for all vehicles on all accounts"),
291 buildCommandUsage(FINGERPRINT + " <userName>", "generate fingerprint for vehicles on account"),
292 buildCommandUsage(FINGERPRINT + " <userName> <vin>",
293 "generate fingerprint for vehicle with vin on account") });
297 public @Nullable ConsoleCommandCompleter getCompleter() {
302 public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
304 if (cursorArgumentIndex <= 0) {
305 return CMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
306 } else if (cursorArgumentIndex == 1) {
307 return new StringsCompleter(
308 thingRegistry.stream()
309 .filter(t -> THING_TYPE_CONNECTED_DRIVE_ACCOUNT.equals(t.getThingTypeUID()))
310 .map(t -> t.getConfiguration().get("userName").toString()).collect(Collectors.toList()),
311 false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
312 } else if (cursorArgumentIndex == 2) {
313 MyBMWBridgeHandler handler = (MyBMWBridgeHandler) thingRegistry.stream()
314 .filter(t -> THING_TYPE_CONNECTED_DRIVE_ACCOUNT.equals(t.getThingTypeUID())
315 && args[1].equals(t.getConfiguration().get("userName")))
316 .map(t -> t.getHandler()).findAny().get();
317 List<VehicleBase> vehicles = handler.getMyBmwProxy().get().requestVehiclesBase();
318 return new StringsCompleter(
319 vehicles.stream().map(v -> v.getVin()).filter(Objects::nonNull).collect(Collectors.toList()),
320 false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
322 } catch (NoSuchElementException | NetworkException e) {