]> git.basschouten.com Git - openhab-addons.git/blob
1b62827ba403d02945e45691cb95b14c5619c1f9
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.linuxinput.internal;
14
15 import static org.openhab.binding.linuxinput.internal.LinuxInputBindingConstants.THING_TYPE_DEVICE;
16
17 import java.io.File;
18 import java.io.IOException;
19 import java.nio.file.FileSystems;
20 import java.nio.file.Path;
21 import java.nio.file.StandardWatchEventKinds;
22 import java.nio.file.WatchKey;
23 import java.nio.file.WatchService;
24 import java.time.Duration;
25 import java.util.Collections;
26 import java.util.concurrent.Future;
27 import java.util.concurrent.FutureTask;
28 import java.util.concurrent.TimeUnit;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.linuxinput.internal.evdev4j.EvdevDevice;
33 import org.openhab.binding.linuxinput.internal.evdev4j.LastErrorException;
34 import org.openhab.binding.linuxinput.internal.evdev4j.jnr.EvdevLibrary;
35 import org.openhab.core.config.discovery.AbstractDiscoveryService;
36 import org.openhab.core.config.discovery.DiscoveryResult;
37 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
38 import org.openhab.core.config.discovery.DiscoveryService;
39 import org.openhab.core.thing.ThingUID;
40 import org.osgi.service.component.annotations.Component;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
43
44 /**
45  * Discovery service for LinuxInputHandlers based on the /dev/input directory.
46  *
47  * @author Thomas Weißschuh - Initial contribution
48  */
49 @Component(service = DiscoveryService.class, configurationPid = "discovery.linuxinput")
50 @NonNullByDefault
51 public class LinuxInputDiscoveryService extends AbstractDiscoveryService {
52
53     private static final Duration REFRESH_INTERVAL = Duration.ofSeconds(50);
54     private static final Duration TIMEOUT = Duration.ofSeconds(30);
55     private static final Duration EVENT_TIMEOUT = Duration.ofSeconds(60);
56     private static final Path DEVICE_DIRECTORY = FileSystems.getDefault().getPath("/dev/input");
57
58     private final Logger logger = LoggerFactory.getLogger(LinuxInputDiscoveryService.class);
59     private @NonNullByDefault({}) Future<?> discoveryJob;
60
61     public LinuxInputDiscoveryService() {
62         super(Collections.singleton(THING_TYPE_DEVICE), (int) TIMEOUT.getSeconds(), true);
63     }
64
65     @Override
66     protected void startScan() {
67         performScan(false);
68     }
69
70     private void performScan(boolean applyTtl) {
71         logger.debug("Scanning directory {} for devices", DEVICE_DIRECTORY);
72         removeOlderResults(getTimestampOfLastScan());
73         File directory = DEVICE_DIRECTORY.toFile();
74         Duration ttl = null;
75         if (applyTtl) {
76             ttl = REFRESH_INTERVAL.multipliedBy(2);
77         }
78         if (directory == null) {
79             logger.warn("Could not open device directory {}", DEVICE_DIRECTORY);
80             return;
81         }
82         File[] devices = directory.listFiles();
83         if (devices == null) {
84             logger.warn("Could not enumerate {}, it is not a directory", directory);
85             return;
86         }
87         for (File file : devices) {
88             handleFile(file, ttl);
89         }
90     }
91
92     private void handleFile(File file, @Nullable Duration ttl) {
93         logger.trace("Discovering file {}", file);
94         if (file.isDirectory()) {
95             logger.trace("{} is not a file, ignoring", file);
96             return;
97         }
98         if (!file.canRead()) {
99             logger.debug("{} is not readable, ignoring", file);
100             return;
101         }
102         DiscoveryResultBuilder result = DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_DEVICE, file.getName()))
103                 .withProperty("path", file.getAbsolutePath()).withRepresentationProperty("path");
104         if (ttl != null) {
105             result = result.withTTL(ttl.getSeconds());
106         }
107
108         boolean shouldDiscover = enrichDevice(result, file);
109         if (shouldDiscover) {
110             DiscoveryResult thing = result.build();
111             logger.debug("Discovered: {}", thing);
112             thingDiscovered(thing);
113         } else {
114             logger.debug("{} is not a keyboard, ignoring", file);
115         }
116     }
117
118     private boolean enrichDevice(DiscoveryResultBuilder builder, File inputDevice) {
119         String label = inputDevice.getName();
120         try {
121             try (EvdevDevice device = new EvdevDevice(inputDevice.getAbsolutePath())) {
122                 String labelFromDevice = device.getName();
123                 boolean isKeyboard = device.has(EvdevLibrary.Type.KEY);
124                 if (labelFromDevice != null) {
125                     label = String.format("%s (%s)", labelFromDevice, inputDevice.getName());
126                 }
127                 return isKeyboard;
128             } finally {
129                 builder.withLabel(label);
130             }
131         } catch (IOException | LastErrorException e) {
132             logger.debug("Could not open device {}", inputDevice, e);
133             return false;
134         }
135     }
136
137     @Override
138     protected synchronized void stopScan() {
139         super.stopScan();
140         removeOlderResults(getTimestampOfLastScan());
141     }
142
143     @Override
144     protected void startBackgroundDiscovery() {
145         logger.debug("Starting background discovery");
146         if (discoveryJob == null || discoveryJob.isCancelled()) {
147             WatchService watchService = null;
148             try {
149                 watchService = makeWatcher();
150             } catch (IOException e) {
151                 logger.debug("Could not start event based watcher, falling back to polling", e);
152             }
153             if (watchService != null) {
154                 WatchService watcher = watchService;
155                 FutureTask<?> job = new FutureTask<>(() -> {
156                     waitForNewDevices(watcher);
157                     return null;
158                 });
159                 Thread t = Utils.backgroundThread(job, "discovery", null);
160                 t.start();
161                 discoveryJob = job;
162             } else {
163                 discoveryJob = scheduler.scheduleWithFixedDelay(() -> performScan(true), 0,
164                         REFRESH_INTERVAL.getSeconds(), TimeUnit.SECONDS);
165             }
166         }
167     }
168
169     private WatchService makeWatcher() throws IOException {
170         WatchService watchService = FileSystems.getDefault().newWatchService();
171         // FIXME also trigger on inotify "ATTRIB" events when WatchService supports this.
172         // Triggering on ENTRY_MODIFY will trigger multiple times on each keypress for *any* input device.
173         DEVICE_DIRECTORY.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
174                 StandardWatchEventKinds.ENTRY_DELETE);
175         return watchService;
176     }
177
178     private void waitForNewDevices(WatchService watchService) {
179         while (!Thread.currentThread().isInterrupted()) {
180             boolean gotEvent = waitAndDrainAll(watchService);
181             logger.debug("Input devices changed: {}. Triggering rescan: {}", gotEvent, gotEvent);
182
183             if (gotEvent) {
184                 performScan(false);
185             }
186         }
187         logger.debug("Discovery stopped");
188     }
189
190     private static boolean waitAndDrainAll(WatchService watchService) {
191         WatchKey event;
192         try {
193             event = watchService.poll(EVENT_TIMEOUT.getSeconds(), TimeUnit.SECONDS);
194         } catch (InterruptedException e) {
195             Thread.currentThread().interrupt();
196             return false;
197         }
198         if (event == null) {
199             return false;
200         }
201         do {
202             event.pollEvents();
203             event.reset();
204             event = watchService.poll();
205         } while (event != null);
206
207         return true;
208     }
209
210     @Override
211     protected void stopBackgroundDiscovery() {
212         logger.debug("Stopping background discovery");
213         if (discoveryJob != null && !discoveryJob.isCancelled()) {
214             discoveryJob.cancel(true);
215             discoveryJob = null;
216         }
217     }
218 }