dataStreams = client.getDataStreams();
- boolean showError = (client.getCurrentError() != null);
- boolean showMsg = (dataStreams.isEmpty()) && (client.getStatusMessage() != null);
-
- if (showError || showMsg)
- {
- mainInfoText.append("" + client.getName() + ":
");
- if (showMsg)
- mainInfoText.append(client.getStatusMessage() + "
");
- if (showError)
- {
- Throwable errorObj = client.getCurrentError();
- String errorMsg = errorObj.getMessage().trim();
- if (!errorMsg.endsWith("."))
- errorMsg += ". ";
- if (errorObj.getCause() != null && errorObj.getCause().getMessage() != null)
- errorMsg += errorObj.getCause().getMessage();
- mainInfoText.append("" + errorMsg + "");
- }
- mainInfoText.append("
");
- }
-
- log.debug("[CONSYS CLIENT CONNECTION]", client.isConnected());
- }
-
- // then display streams status
- mainInfoText.append("");
- for (SOSTClient client: sostClients)
- {
- mainInfoText.append("SOS-T Client");
- mainInfoText.append("
");
-
- Map dataStreams = client.getDataStreams();
- long now = System.currentTimeMillis();
-
- for (Entry stream : dataStreams.entrySet())
- {
- mainInfoText.append("" + stream.getKey() + " : ");
-
- long lastEventTime = stream.getValue().lastEventTime;
- long dt = now - lastEventTime;
- if (lastEventTime == Long.MIN_VALUE)
- mainInfoText.append("NO OBS");
- else if (dt > stream.getValue().measPeriodMs)
- mainInfoText.append("NOK (" + dt + "ms ago)");
- else
- mainInfoText.append("OK (" + dt + "ms ago)");
-
- if (stream.getValue().errorCount > 0)
- {
- mainInfoText.append(" (");
- mainInfoText.append(stream.getValue().errorCount);
- mainInfoText.append(")");
- }
-
- mainInfoText.append("
");
- }
-
- }
-
- for (ConSysApiClientModule client: conSysClients)
- {
- mainInfoText.append("ConSysApi Client");
- mainInfoText.append("");
-
- Map dataStreams = client.getDataStreams();
- long now = System.currentTimeMillis();
-
- for (Entry stream : dataStreams.entrySet())
- {
- mainInfoText.append("" + stream.getKey() + " : ");
-
- long lastEventTime = stream.getValue().lastEventTime;
- long dt = now - lastEventTime;
- if (lastEventTime == Long.MIN_VALUE)
- mainInfoText.append("NO OBS");
- else if (dt > stream.getValue().measPeriodMs)
- mainInfoText.append("NOK (" + dt + "ms ago)");
- else
- mainInfoText.append("OK (" + dt + "ms ago)");
-
- if (stream.getValue().errorCount > 0)
- {
- mainInfoText.append(" (");
- mainInfoText.append(stream.getValue().errorCount);
- mainInfoText.append(")");
- }
-
- mainInfoText.append("
");
- }
- }
- mainInfoText.append("");
-
- if (mainInfoText.length() > 5)
- mainInfoText.setLength(mainInfoText.length()-5); // remove last
- mainInfoText.append("
");
-
- // Notify we are running when no data is being pushed
- boolean serveOrStore = shouldServe(PreferenceManager.getDefaultSharedPreferences(MainActivity.this)) || shouldStore(PreferenceManager.getDefaultSharedPreferences(MainActivity.this));
- if(sostClients.isEmpty() && serveOrStore){
- mainInfoText.append("No Sensors Set to Push Remotely");
- }
-
- if(conSysClients.isEmpty() && serveOrStore){
- mainInfoText.append("No Sensors Set to Push Remotely");
+ if (broadcastReceiver != null) {
+ unregisterReceiver(broadcastReceiver);
+ broadcastReceiver = null;
}
- // show video info
- if (androidSensors != null && boundService.hasVideo())
- {
-// TODO: Fix crash resulting from this (620)
- try {
- VideoEncoderConfig config = androidSensors.getConfiguration().videoConfig;
- VideoPreset preset = config.presets[config.selectedPreset];
- videoInfoText.setLength(0);
- videoInfoText.append("")
- .append(config.codec).append(", ")
- .append(preset.width).append("x").append(preset.height).append(", ")
- .append(config.frameRate).append(" fps, ")
- .append(preset.selectedBitrate).append(" kbits/s")
- .append("");
- }catch (Exception e){
- log.error("Exception thrown trying to disaply video", e.getMessage());
- }
+ if (boundService != null) {
+ unbindService(sConn);
+ boundService = null;
}
-
- }
-
- protected synchronized void newStatusMessage(String msg)
- {
- mainInfoText.setLength(0);
- appendStatusMessage(msg);
- }
-
-
- protected synchronized void appendStatusMessage(String msg)
- {
- mainInfoText.append(msg);
-
- displayHandler.post(new Runnable()
- {
- public void run()
- {
- mainInfoArea.setText(mainInfoText.toString());
- }
- });
+ super.onDestroy();
}
-
- protected void startListeningForEvents() {
- if (boundService == null || boundService.getSensorHub() == null){
-
+ private ControllerDriver getControllerDriver() {
+ if (boundService == null || boundService.sensorhub == null)
+ return null;
+ try {
+ return boundService.sensorhub.getModuleRegistry().getModuleByType(ControllerDriver.class);
+ } catch (Exception e) {
+ return null;
}
-
- // TODO: Implement a listener that can sub to the status of the hub
-// boundService.getSensorHub().getModuleRegistry().registerListener(this);
-
}
-
- protected void stopListeningForEvents()
- {
- if (boundService == null || boundService.getSensorHub() == null){
-
- }
-
- // TODO: Unsub the listener here
-// boundService.getSensorHub().getModuleRegistry().unregisterListener(this);
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ ControllerDriver controller = getControllerDriver();
+ if (controller != null && controller.onKeyEvent(event))
+ return true;
+ return super.dispatchKeyEvent(event);
}
-
-
- protected void showVideo()
- {
- if (boundService.getVideoTexture() != null)
- {
- TextureView textureView = (TextureView) findViewById(R.id.video);
- if (textureView.getSurfaceTexture() != boundService.getVideoTexture())
- textureView.setSurfaceTexture(boundService.getVideoTexture());
- }
+ @Override
+ public boolean dispatchGenericMotionEvent(MotionEvent event) {
+ ControllerDriver controller = getControllerDriver();
+ if (controller != null && controller.onMotionEvent(event))
+ return true;
+ return super.dispatchGenericMotionEvent(event);
}
- protected void hideVideo()
- {
- }
- private boolean isPushingSensor(Sensors sensor) {
- SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(MainActivity.this);
+ boolean isPushingSensor(Sensors sensor) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
if (Sensors.Android.equals(sensor)) {
- if (prefs.getBoolean("accel_enabled", false)
- && prefs.getStringSet("accel_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("accel_enabled", false))
return true;
- if (prefs.getBoolean("gyro_enabled", false)
- && prefs.getStringSet("gyro_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("gyro_enabled", false))
return true;
- if (prefs.getBoolean("mag_enabled", false)
- && prefs.getStringSet("mag_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("mag_enabled", false))
return true;
- if (prefs.getBoolean("orient_quat_enabled", false)
- && prefs.getStringSet("orient_quat_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("orient_quat_enabled", false))
return true;
- if (prefs.getBoolean("orient_euler_enabled", false)
- && prefs.getStringSet("orient_euler_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("orient_euler_enabled", false))
return true;
- if (prefs.getBoolean("gps_enabled", false)
- && prefs.getStringSet("gps_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("gps_enabled", false))
return true;
- if (prefs.getBoolean("netloc_enabled", false)
- && prefs.getStringSet("netloc_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("netloc_enabled", false))
return true;
- if (prefs.getBoolean("cam_enabled", false)
- && prefs.getStringSet("cam_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("cam_enabled", false))
return true;
- if(prefs.getBoolean("audio_enabled", false)
- && prefs.getStringSet("audio_options", Collections.emptySet()).contains("PUSH_REMOTE"))
+ if (prefs.getBoolean("audio_enabled", false))
return true;
} else if (Sensors.TruPulse.equals(sensor) || Sensors.TruPulseSim.equals(sensor)) {
- return prefs.getBoolean("trupulse_enabled", false)
- && prefs.getStringSet("trupulse_options", Collections.emptySet()).contains("PUSH_REMOTE");
- } else if(Sensors.BLELocation.equals(sensor)){
- return prefs.getBoolean("ble_enable", false) && prefs.getStringSet("ble_options", Collections.emptySet()).contains("PUSH_REMOTE");
- }
- else if (Sensors.Meshtastic.equals(sensor)) {
- return prefs.getBoolean("meshtastic_enabled", false)
- && prefs.getStringSet("meshtastic_options", Collections.emptySet()).contains("PUSH_REMOTE");
- }
- else if (Sensors.PolarHRMonitor.equals(sensor)) {
- return prefs.getBoolean("polar_enabled", false)
- && prefs.getStringSet("polar_options", Collections.emptySet()).contains("PUSH_REMOTE");
- }
- else if (Sensors.Kestrel.equals(sensor)) {
- return prefs.getBoolean("kestrel_enabled", false)
- && prefs.getStringSet("kestrel_options", Collections.emptySet()).contains("PUSH_REMOTE");
+ return prefs.getBoolean("trupulse_enabled", false);
+ } else if (Sensors.BLELocation.equals(sensor)) {
+ return prefs.getBoolean("ble_enable", false);
+ } else if (Sensors.Meshtastic.equals(sensor)) {
+ return prefs.getBoolean("meshtastic_enabled", false);
+ } else if (Sensors.PolarHRMonitor.equals(sensor)) {
+ return prefs.getBoolean("polar_enabled", false);
+ } else if (Sensors.Kestrel.equals(sensor)) {
+ return prefs.getBoolean("kestrel_enabled", false);
+ } else if (Sensors.Wardriving.equals(sensor)) {
+ return prefs.getBoolean("wardriving_enabled", false);
+ } else if (Sensors.Controller.equals(sensor)) {
+ return prefs.getBoolean("controller_enabled", false);
+ } else if (Sensors.Template.equals(sensor)) {
+ return prefs.getBoolean("template_enabled", false);
}
-
return false;
}
-
- private void setupBroadcastReceivers() {
- broadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- String origin = intent.getStringExtra("src");
- if (!context.getPackageName().equalsIgnoreCase(origin)) {
- String sosEndpointUrl = intent.getStringExtra("sosEndpointUrl");
- String name = intent.getStringExtra("name");
- String sensorId = intent.getStringExtra("sensorId");
- ArrayList properties = intent.getStringArrayListExtra("properties");
-
- if (sosEndpointUrl == null || name == null || sensorId == null || properties.size() == 0) {
- return;
- }
-
- // register and "start" new sensor, data stream doesn't begin until someone requests data;
- try {
- boundService.stopSensorHub();
- Thread.sleep(2000);
- Log.d("OSHApp", "Starting SensorHub Again");
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
- updateConfig(PreferenceManager.getDefaultSharedPreferences(MainActivity.this), runName);
- sostClients.clear();
- boundService.startSensorHub(sensorhubConfig, showVideo);
- if (boundService.hasVideo())
- mainInfoArea.setBackgroundColor(0x80FFFFFF);
-
- EventBus shEventBus = (EventBus) boundService.getSensorHub().getEventBus();
-// shEventBus.newSubscription()
-// .withTopicID(ModuleRegistry.EVENT_GROUP_ID)
-// .subscribe();
- } catch (InterruptedException e) {
- Log.e("OSHApp", "Error Loading Proxy Sensor", e);
- }
-
+ boolean shouldServe(SharedPreferences prefs) {
+ Map prefMap = prefs.getAll();
+ for (Map.Entry pref : prefMap.entrySet()) {
+ if (pref.getValue() instanceof HashSet) {
+ if (((HashSet) pref.getValue()).contains("FETCH_LOCAL")) {
+ return true;
}
}
- };
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_BROADCAST_RECEIVER);
-
- registerReceiver(broadcastReceiver, filter);
- }
-
- @Override
- protected void onStart()
- {
- super.onStart();
- }
-
-
- @Override
- protected void onResume()
- {
- super.onResume();
-
- TextureView textureView = (TextureView) findViewById(R.id.video);
- textureView.setSurfaceTextureListener(this);
-
- if (oshStarted)
- {
- startListeningForEvents();
- startRefreshingStatus();
-
- if (boundService.hasVideo())
- mainInfoArea.setBackgroundColor(0x80FFFFFF);
- }
- }
-
-
- @Override
- protected void onPause()
- {
- stopListeningForEvents();
- stopRefreshingStatus();
- hideVideo();
- super.onPause();
- }
-
-
- @Override
- protected void onStop()
- {
- stopListeningForEvents();
- stopRefreshingStatus();
- super.onStop();
- }
-
-
- @Override
- protected void onDestroy()
- {
-// stopService(new Intent(this, SensorHubService.class));
-
- if (broadcastReceiver != null) {
- unregisterReceiver(broadcastReceiver);
- broadcastReceiver = null;
- }
-
- // this should stop it from stopping sensorhub and allow it to stay connected when the app closes/ phone shuts off
- if (boundService != null) {
- unbindService(sConn);
- boundService = null;
}
- super.onDestroy();
- }
-
-
- @Override
- public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int i, int i1)
- {
- showVideo();
- }
-
-
- @Override
- public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int i, int i1)
- {
- }
-
-
- @Override
- public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture)
- {
return false;
}
-
- @Override
- public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture)
- {
- }
-
- private boolean shouldServe(SharedPreferences prefs){
+ boolean shouldStore(SharedPreferences prefs) {
Map prefMap = prefs.getAll();
- for(Map.Entry pref : prefMap.entrySet()){
- if(pref.getValue() instanceof HashSet) {
- if(((HashSet) pref.getValue()).contains("FETCH_LOCAL")) {
- Log.d(TAG, "shouldServe: TRUE");
+ for (Map.Entry pref : prefMap.entrySet()) {
+ if (pref.getValue() instanceof HashSet) {
+ if (((HashSet) pref.getValue()).contains("STORE_LOCAL")) {
return true;
}
}
@@ -1425,142 +802,110 @@ private boolean shouldServe(SharedPreferences prefs){
return false;
}
- private boolean shouldStore(SharedPreferences prefs){
- Map prefMap = prefs.getAll();
- for(Map.Entry pref : prefMap.entrySet()){
- if(pref.getValue() instanceof HashSet) {
- if(((HashSet) pref.getValue()).contains("STORE_LOCAL")) {
- Log.d(TAG, "shouldStore: TRUE");
- return true;}
+ private void requestBatteryOptimizationExemption() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ if (!pm.isIgnoringBatteryOptimizations(getPackageName())) {
+ Intent intent = new Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivity(intent);
}
}
- return false;
}
- private void checkForPermissions(){
+ private boolean hasBluetoothPermissions() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
+ && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
+ && checkSelfPermission(Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED;
+ }
+ return true;
+ }
+
+ private void checkForPermissions() {
List permissions = new ArrayList<>();
- // Check for necessary permissions
- if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION);
- }
- if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.CAMERA);
- }
- if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.RECORD_AUDIO);
- }
- if (checkSelfPermission(Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.BLUETOOTH);
- }
- if (checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.BLUETOOTH_ADMIN);
- }
- if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.BLUETOOTH_CONNECT);
- }
- if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.BLUETOOTH_SCAN);
- }
- if (checkSelfPermission(Manifest.permission.START_FOREGROUND_SERVICES_FROM_BACKGROUND) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.START_FOREGROUND_SERVICES_FROM_BACKGROUND) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.START_FOREGROUND_SERVICES_FROM_BACKGROUND);
- }
- if (checkSelfPermission(Manifest.permission.CHANGE_WIFI_STATE) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.CHANGE_WIFI_STATE) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.CHANGE_WIFI_STATE);
- }
- if (checkSelfPermission(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.ACCESS_WIFI_STATE) == PackageManager.PERMISSION_DENIED)
+ permissions.add(Manifest.permission.ACCESS_WIFI_STATE);
+ if (checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_DENIED)
+ permissions.add(Manifest.permission.ACCESS_COARSE_LOCATION);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_DENIED)
+ permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES);
+ }
+ if (checkSelfPermission(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
- }
- if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.POST_NOTIFICATIONS);
- }
- if (checkSelfPermission(Manifest.permission.FOREGROUND_SERVICE) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.FOREGROUND_SERVICE) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.FOREGROUND_SERVICE);
- }
- if (checkSelfPermission(Manifest.permission.WAKE_LOCK) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.WAKE_LOCK) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.WAKE_LOCK);
- }
- if (checkSelfPermission(Manifest.permission.INTERNET) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.INTERNET) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.INTERNET);
- }
- if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- }
- if (checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.READ_PHONE_STATE);
- }
- if (checkSelfPermission(Manifest.permission.ACCESS_NETWORK_STATE) == PackageManager.PERMISSION_DENIED) {
+ if (checkSelfPermission(Manifest.permission.ACCESS_NETWORK_STATE) == PackageManager.PERMISSION_DENIED)
permissions.add(Manifest.permission.ACCESS_NETWORK_STATE);
- }
- // Does app actually need storage permissions now?
String[] permARR = new String[permissions.size()];
permARR = permissions.toArray(permARR);
- if(permARR.length >0) {
+ if (permARR.length > 0) {
requestPermissions(permARR, 100);
}
}
- @Override
- public void onSubscribe(Flow.Subscription subscription) {
- this.subscription = subscription;
- System.out.println("MainActivity Subscribed...");
- subscription.request(10);
- }
-
- @Override
- public void onNext(Event e) {
- System.out.println("Event of : " + e);
-
- System.out.println(e.getSource());
- if (e instanceof ModuleEvent)
- {
-
- // start refreshing status on first module loaded
- if (!oshStarted && ((ModuleEvent) e).getType() == ModuleEvent.Type.LOADED)
- {
- oshStarted = true;
- startRefreshingStatus();
- return;
- }
+ private void setupBroadcastReceivers() {
+ broadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String origin = intent.getStringExtra("src");
+ if (!context.getPackageName().equalsIgnoreCase(origin)) {
+ String sosEndpointUrl = intent.getStringExtra("sosEndpointUrl");
+ String name = intent.getStringExtra("name");
+ String sensorId = intent.getStringExtra("sensorId");
+ ArrayList properties = intent.getStringArrayListExtra("properties");
- // detect when Android sensor driver is started
- else if (e.getSource() instanceof AndroidSensorsDriver)
- {
- this.androidSensors = (AndroidSensorsDriver)e.getSource();
- }
+ if (sosEndpointUrl == null || name == null || sensorId == null || properties.size() == 0) {
+ return;
+ }
- // detect when SOS-T modules are connected
- else if (e.getSource() instanceof SOSTClient && ((ModuleEvent)e).getType() == ModuleEvent.Type.STATE_CHANGED)
- {
- switch (((ModuleEvent)e).getNewState())
- {
- case INITIALIZING:
- sostClients.add((SOSTClient)e.getSource());
- break;
- }
- }
- else if (e.getSource() instanceof ConSysApiClientModule && ((ModuleEvent)e).getType() == ModuleEvent.Type.STATE_CHANGED)
- {
- switch (((ModuleEvent)e).getNewState())
- {
- case INITIALIZING:
- conSysClients.add((ConSysApiClientModule)e.getSource());
- break;
+ try {
+ boundService.stopSensorHub();
+ Thread.sleep(2000);
+ Log.d("OSHApp", "Starting SensorHub Again");
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ updateConfig(PreferenceManager.getDefaultSharedPreferences(MainActivity.this), runName);
+ sostClients.clear();
+ boundService.startSensorHub(sensorhubConfig, showVideo);
+ } catch (InterruptedException e) {
+ Log.e("OSHApp", "Error Loading Proxy Sensor", e);
+ }
}
}
- }
-
- subscription.request(10);
- }
-
- @Override
- public void onError(Throwable throwable) {
-
- }
-
- @Override
- public void onComplete() {
-
+ };
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_BROADCAST_RECEIVER);
+ registerReceiver(broadcastReceiver, filter);
}
-}
\ No newline at end of file
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/SOSServiceWithIPC.java b/sensorhub-android-app/src/org/sensorhub/android/SOSServiceWithIPC.java
index b8086c95..2cd5d5e3 100644
--- a/sensorhub-android-app/src/org/sensorhub/android/SOSServiceWithIPC.java
+++ b/sensorhub-android-app/src/org/sensorhub/android/SOSServiceWithIPC.java
@@ -14,12 +14,16 @@
import org.vast.xml.DOMHelperException;
import org.w3c.dom.Element;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class SOSServiceWithIPC extends SOSService
{
+ private static final Logger log = LoggerFactory.getLogger(SOSServiceWithIPC.class);
public static final String SQAN_TEST = "SA";
private static final String SQAN_EXTRA = "channel";
public static final String ACTION_SOS = "org.sofwerx.ogc.ACTION_SOS";
@@ -91,15 +95,15 @@ private void handleIPCRequest(String body)
}
catch (DOMHelperException e)
{
- e.printStackTrace();
+ log.error("Error parsing IPC request DOM", e);
}
catch (IOException e)
{
- e.printStackTrace();
+ log.error("IO error handling IPC request", e);
}
catch (OWSException e)
{
- e.printStackTrace();
+ log.error("OWS error handling IPC request", e);
}
// OGCException e
/**
diff --git a/sensorhub-android-app/src/org/sensorhub/android/SecurePrefs.java b/sensorhub-android-app/src/org/sensorhub/android/SecurePrefs.java
new file mode 100644
index 00000000..2b5e98d7
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/SecurePrefs.java
@@ -0,0 +1,123 @@
+package org.sensorhub.android;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.util.Base64;
+
+import androidx.preference.PreferenceManager;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+public class SecurePrefs {
+ private static final String KEY_ALIAS = "osh_android_secure_key";
+ private static final String SECURE_PREFS_NAME = "osh_secure_prefs";
+
+ private static final Set SENSITIVE_KEYS = new HashSet<>(Arrays.asList(
+ "password", "client_secret", "token_endpoint", "client_id"
+ ));
+
+ private static SecretKey getKey() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
+ keyStore.load(null);
+
+ if (!keyStore.containsAlias(KEY_ALIAS)) {
+ KeyGenerator keyGenerator = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
+ keyGenerator.init(new KeyGenParameterSpec.Builder(KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build()
+ );
+ keyGenerator.generateKey();
+ }
+ return (SecretKey) keyStore.getKey(KEY_ALIAS, null);
+ }
+
+ private static String encrypt(String plainText) {
+ try {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, getKey());
+
+ byte[] iv = cipher.getIV();
+ byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
+
+ return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
+ Base64.encodeToString(encrypted, Base64.NO_WRAP);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String decrypt(String encryptedText) {
+ try {
+ String[] parts = encryptedText.split(":");
+ if (parts.length != 2) return null;
+
+ byte[] iv = Base64.decode(parts[0], Base64.NO_WRAP);
+ byte[] data = Base64.decode(parts[1], Base64.NO_WRAP);
+
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, getKey(), new GCMParameterSpec(128, iv));
+
+ byte[] decryptedBytes = cipher.doFinal(data);
+ return new String(decryptedBytes, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static SharedPreferences getSecureStore(Context context) {
+ return context.getSharedPreferences(SECURE_PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ public static void put(Context context, String key, String value) {
+ if (value == null || value.isEmpty()) {
+ getSecureStore(context).edit().remove(key).apply();
+ return;
+ }
+ String encrypted = encrypt(value);
+ if (encrypted != null) {
+ getSecureStore(context).edit().putString(key, encrypted).apply();
+ }
+ }
+
+ public static String get(Context context, String key, String defaultValue) {
+ String encrypted = getSecureStore(context).getString(key, null);
+ if (encrypted == null) return defaultValue;
+
+ String decrypted = decrypt(encrypted);
+ return decrypted != null ? decrypted : defaultValue;
+ }
+
+ public static void remove(Context context, String key) {
+ getSecureStore(context).edit().remove(key).apply();
+ }
+
+ public static boolean isSensitiveKey(String key) {
+ return SENSITIVE_KEYS.contains(key) || key.startsWith("profile_");
+ }
+
+ public static void removeByPrefix(Context context, String prefix) {
+ SharedPreferences secureStore = getSecureStore(context);
+ SharedPreferences.Editor editor = secureStore.edit();
+ for (String key : secureStore.getAll().keySet()) {
+ if (key.startsWith(prefix)) {
+ editor.remove(key);
+ }
+ }
+ editor.apply();
+ }
+
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/SensorHubServiceProvider.java b/sensorhub-android-app/src/org/sensorhub/android/SensorHubServiceProvider.java
new file mode 100644
index 00000000..cafaec36
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/SensorHubServiceProvider.java
@@ -0,0 +1,23 @@
+package org.sensorhub.android;
+
+import org.sensorhub.api.module.IModuleConfigRepository;
+import org.sensorhub.impl.client.sost.SOSTClient;
+import org.sensorhub.impl.sensor.android.AndroidSensorsDriver;
+import org.sensorhub.impl.service.consys.client.ConSysApiClientModule;
+
+import java.util.ArrayList;
+
+public interface SensorHubServiceProvider {
+ SensorHubService getBoundService();
+ boolean isOshStarted();
+ void setOshStarted(boolean started);
+ IModuleConfigRepository getSensorhubConfig();
+ ArrayList getSostClients();
+ ArrayList getConSysClients();
+ AndroidSensorsDriver getAndroidSensors();
+ void setAndroidSensors(AndroidSensorsDriver driver);
+ boolean getShowVideo();
+ void updateConfig(android.content.SharedPreferences prefs, String runName);
+ void startSensorHub();
+ void stopSensorHub();
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/SensorsFragment.java b/sensorhub-android-app/src/org/sensorhub/android/SensorsFragment.java
new file mode 100644
index 00000000..2f6ca79e
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/SensorsFragment.java
@@ -0,0 +1,310 @@
+package org.sensorhub.android;
+
+import android.Manifest;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.hardware.Camera;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.InputType;
+import android.util.Log;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.app.ActivityCompat;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceManager;
+import androidx.preference.SwitchPreferenceCompat;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+
+/*
+ * Fragment for sensor preferences
+ */
+public class SensorsFragment extends PreferenceFragmentCompat {
+
+ private static final String[][] SWITCH_DEPENDENTS = {
+ {"accel_enabled"},
+ {"gyro_enabled"},
+ {"mag_enabled"},
+ {"orient_quat_enabled"},
+ {"orient_euler_enabled"},
+ {"gps_enabled"},
+ {"netloc_enabled"},
+ {"cam_enabled", "video_codec", "video_framerate", "video_resolution", "camera_select"},
+ {"video_roll_enabled"},
+ {"audio_enabled", "audio_codec", "audio_samplerate", "audio_bitrate"},
+ {"meshtastic_enabled", "meshtastic_device_address"},
+ {"polar_enabled", "polar_device_address"},
+ {"kestrel_enabled", "kestrel_device_address"},
+ {"trupulse_enabled", "trupulse_datasource", "trupulse_device_address", "trupulse_simu"},
+ {"angel_enabled", "angel_address"},
+ {"flirone_enabled"},
+ {"ste_radpager_enabled"},
+ {"wardriving_enabled"},
+ {"controller_enabled"},
+ {"template_enabled", "template_device_address"},
+ };
+
+ /** Keys of Preferences that use the Bluetooth device picker dialog */
+ private static final String[] BT_DEVICE_PREF_KEYS = {
+ "meshtastic_device_address",
+ "angel_address",
+ "polar_device_address",
+ "kestrel_device_address",
+ "trupulse_device_address",
+ "template_device_address",
+ };
+
+ private ArrayList frameRateList = new ArrayList<>();
+ private ArrayList resList = new ArrayList<>();
+
+ @Override
+ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
+ setPreferencesFromResource(R.xml.pref_sensors, rootKey);
+
+ for (String[] group : SWITCH_DEPENDENTS) {
+ String switchKey = group[0];
+ SwitchPreferenceCompat switchPref = findPreference(switchKey);
+ if (switchPref == null) continue;
+
+ boolean isChecked = switchPref.isChecked();
+ for (int i = 1; i < group.length; i++) {
+ Preference dep = findPreference(group[i]);
+ if (dep != null) dep.setVisible(isChecked);
+ }
+
+ switchPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ boolean enabled = (boolean) newValue;
+ for (int i = 1; i < group.length; i++) {
+ Preference dep = findPreference(group[i]);
+ if (dep != null) dep.setVisible(enabled);
+ }
+ return true;
+ });
+ }
+
+ setupVideoPreferences();
+ setupAudioPreferences();
+
+ setupBluetoothDevicePickers();
+ }
+
+ private void setupBluetoothDevicePickers() {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+
+ for (String key : BT_DEVICE_PREF_KEYS) {
+ Preference pref = findPreference(key);
+ if (pref == null) continue;
+
+ String saved = prefs.getString(key, "");
+ if (!saved.isEmpty()) {
+ pref.setSummary(saved);
+ }
+
+ pref.setOnPreferenceClickListener(p -> {
+ showDevicePickerDialog(key);
+ return true;
+ });
+ }
+ }
+
+ private void showDevicePickerDialog(String prefKey) {
+ List names = new ArrayList<>();
+ List addresses = new ArrayList<>();
+
+ // Gather all bonded Bluetooth devices (classic + BLE)
+ BluetoothAdapter btAdapter = getBluetoothAdapter();
+ if (btAdapter != null && btAdapter.isEnabled() && hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) {
+ Set bondedDevices = btAdapter.getBondedDevices();
+ for (BluetoothDevice device : bondedDevices) {
+ String name = device.getName();
+ String mac = device.getAddress();
+ names.add(name != null ? name + " (" + mac + ")" : mac);
+ addresses.add(mac);
+ }
+ }
+
+ names.add(getString(R.string.manual_entry_option));
+ addresses.add(null);
+
+ String[] displayNames = names.toArray(new String[0]);
+
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.title_select_device)
+ .setItems(displayNames, (dialog, which) -> {
+ if (addresses.get(which) == null) {
+ showManualAddressDialog(prefKey);
+ } else {
+ saveDeviceAddress(prefKey, addresses.get(which), displayNames[which]);
+ }
+ })
+ .setNegativeButton(R.string.btn_cancel, null)
+ .show();
+ }
+
+ private void showManualAddressDialog(String prefKey) {
+ EditText input = new EditText(requireContext());
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ input.setHint(R.string.hint_manual_device);
+
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+ String current = prefs.getString(prefKey, "");
+ if (!current.isEmpty()) {
+ input.setText(current);
+ input.selectAll();
+ }
+
+ int padding = (int) (24 * getResources().getDisplayMetrics().density);
+ FrameLayout container = new FrameLayout(requireContext());
+ container.setPadding(padding, padding, padding, 0);
+ container.addView(input);
+
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.title_enter_device)
+ .setMessage(R.string.msg_enter_device)
+ .setView(container)
+ .setPositiveButton(R.string.btn_ok, (dialog, which) -> {
+ String address = input.getText().toString().trim();
+ if (!address.isEmpty()) {
+ saveDeviceAddress(prefKey, address, address);
+ }
+ })
+ .setNegativeButton(R.string.btn_cancel, null)
+ .show();
+ }
+
+ private void saveDeviceAddress(String prefKey, String address, String displayText) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
+ prefs.edit().putString(prefKey, address).apply();
+
+ Preference pref = findPreference(prefKey);
+ if (pref != null) {
+ pref.setSummary(displayText);
+ }
+ }
+
+
+ private void setupVideoPreferences() {
+ // Camera selection
+ ArrayList cameras = new ArrayList<>();
+ try {
+ for (int i = 0; i < Camera.getNumberOfCameras(); i++) {
+ cameras.add(Integer.toString(i));
+ }
+ } catch (Exception e) {
+ cameras.add("0");
+ }
+
+ ListPreference cameraSelectList = findPreference("camera_select");
+ if (cameraSelectList != null) {
+ cameraSelectList.setEntries(cameras.toArray(new String[0]));
+ cameraSelectList.setEntryValues(cameras.toArray(new String[0]));
+ cameraSelectList.setOnPreferenceChangeListener((preference, newValue) -> {
+ Log.d("CAMERA_SELECT", "New Camera Selected: " + newValue);
+ updateCameraSettings(Integer.parseInt((String) newValue));
+ return true;
+ });
+ }
+
+ // Frame rates and resolutions from camera
+ Camera camera = null;
+ try {
+ camera = Camera.open(0);
+ Camera.Parameters camParams = camera.getParameters();
+ for (int frameRate : camParams.getSupportedPreviewFrameRates())
+ frameRateList.add(Integer.toString(frameRate));
+ for (Camera.Size imgSize : camParams.getSupportedPreviewSizes())
+ resList.add(imgSize.width + "x" + imgSize.height);
+ } catch (Exception e) {
+ frameRateList.add("30");
+ resList.add("640x480");
+ } finally {
+ if (camera != null) camera.release();
+ }
+
+ ListPreference frameRatePrefList = findPreference("video_framerate");
+ if (frameRatePrefList != null) {
+ frameRatePrefList.setEntries(frameRateList.toArray(new String[0]));
+ frameRatePrefList.setEntryValues(frameRateList.toArray(new String[0]));
+ }
+
+ // Resolution list
+ ListPreference resolutionPrefList = findPreference("video_resolution");
+ if (resolutionPrefList != null) {
+ resolutionPrefList.setEntries(resList.toArray(new String[0]));
+ resolutionPrefList.setEntryValues(resList.toArray(new String[0]));
+ if (!resList.isEmpty() && resolutionPrefList.getValue() == null)
+ resolutionPrefList.setValue(resList.get(0));
+ }
+ }
+
+ private void updateCameraSettings(int cameraId) {
+ Camera camera = null;
+ try {
+ frameRateList.clear();
+ resList.clear();
+ camera = Camera.open(cameraId);
+ Camera.Parameters camParams = camera.getParameters();
+ for (int frameRate : camParams.getSupportedPreviewFrameRates())
+ frameRateList.add(Integer.toString(frameRate));
+ for (Camera.Size imgSize : camParams.getSupportedPreviewSizes())
+ resList.add(imgSize.width + "x" + imgSize.height);
+
+ ListPreference frameRatePrefList = findPreference("video_framerate");
+ if (frameRatePrefList != null) {
+ frameRatePrefList.setEntries(frameRateList.toArray(new String[0]));
+ frameRatePrefList.setEntryValues(frameRateList.toArray(new String[0]));
+ }
+ ListPreference resolutionPrefList = findPreference("video_resolution");
+ if (resolutionPrefList != null) {
+ resolutionPrefList.setEntries(resList.toArray(new String[0]));
+ resolutionPrefList.setEntryValues(resList.toArray(new String[0]));
+ }
+ } catch (Exception e) {
+ Log.e("SensorsFragment", "Error updating camera settings", e);
+ } finally {
+ if (camera != null) camera.release();
+ }
+ }
+
+
+ private void setupAudioPreferences() {
+ List sampleRateList = Arrays.asList("8000", "11025", "22050", "44100", "48000");
+ List bitRateList = Arrays.asList("32", "64", "96", "128", "160", "192");
+
+ ListPreference sampleRatePrefList = findPreference("audio_samplerate");
+ if (sampleRatePrefList != null) {
+ sampleRatePrefList.setEntries(sampleRateList.toArray(new String[0]));
+ sampleRatePrefList.setEntryValues(sampleRateList.toArray(new String[0]));
+ }
+
+ ListPreference bitRatePrefList = findPreference("audio_bitrate");
+ if (bitRatePrefList != null) {
+ bitRatePrefList.setEntries(bitRateList.toArray(new String[0]));
+ bitRatePrefList.setEntryValues(bitRateList.toArray(new String[0]));
+ }
+ }
+
+ private BluetoothAdapter getBluetoothAdapter() {
+ BluetoothManager btManager = (BluetoothManager) requireContext().getSystemService(Context.BLUETOOTH_SERVICE);
+ return btManager != null ? btManager.getAdapter() : null;
+ }
+
+ private boolean hasPermission(String permission) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true;
+ return ActivityCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED;
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/SettingsFragment.java b/sensorhub-android-app/src/org/sensorhub/android/SettingsFragment.java
new file mode 100644
index 00000000..ab1d1573
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/SettingsFragment.java
@@ -0,0 +1,80 @@
+package org.sensorhub.android;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreferenceCompat;
+
+import org.sensorhub.android.server.ServerProfileRepository;
+import org.sensorhub.android.server.ServerProfilesActivity;
+
+
+/*
+ * Fragment for settings preferences
+ */
+public class SettingsFragment extends PreferenceFragmentCompat {
+
+ @Override
+ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+ setPreferencesFromResource(R.xml.pref_settings, rootKey);
+
+ manageServerProfiles();
+ setupDiscoveryToggle();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Preference serverPref = findPreference("manage_servers");
+ if (serverPref != null) updateServerProfilesSummary(serverPref);
+ }
+
+ private void manageServerProfiles() {
+ Preference serverPref = findPreference("manage_servers");
+
+ if (serverPref != null) {
+ updateServerProfilesSummary(serverPref);
+ serverPref.setOnPreferenceClickListener(p -> {
+ startActivity(new Intent(requireContext(), ServerProfilesActivity.class));
+ return true;
+ });
+ }
+ }
+
+
+ private void updateServerProfilesSummary(Preference pref) {
+ ServerProfileRepository repo = new ServerProfileRepository(requireContext());
+ int total = repo.getAll().size();
+ int enabled = repo.getEnabled().size();
+ if (total == 0) {
+ pref.setSummary("No server profiles configured");
+ } else {
+ pref.setSummary(enabled + " of " + total + " server(s) enabled");
+ }
+ }
+
+ private void setupDiscoveryToggle() {
+ SwitchPreferenceCompat enableDiscovery = findPreference("discovery_service");
+
+ Preference rules = findPreference("rules_link");
+
+ if (enableDiscovery != null) {
+ boolean isDiscovery = enableDiscovery.isChecked();
+ setVisibility(isDiscovery, rules);
+
+ enableDiscovery.setOnPreferenceChangeListener((pref, value) -> {
+ boolean isEnabled = (Boolean) value;
+ setVisibility(isEnabled, rules);
+ return true;
+ });
+ }
+ }
+
+ private void setVisibility(boolean visible, Preference... prefs) {
+ for (Preference p : prefs) {
+ if (p != null) p.setVisible(visible);
+ }
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/UserSettingsActivity.java b/sensorhub-android-app/src/org/sensorhub/android/UserSettingsActivity.java
deleted file mode 100644
index d9a621d6..00000000
--- a/sensorhub-android-app/src/org/sensorhub/android/UserSettingsActivity.java
+++ /dev/null
@@ -1,848 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
-
- The contents of this file are subject to the Mozilla Public License, v. 2.0.
- If a copy of the MPL was not distributed with this file, You can obtain one
- at http://mozilla.org/MPL/2.0/.
-
- Software distributed under the License is distributed on an "AS IS" basis,
- WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- for the specific language governing rights and limitations under the License.
-
- Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
- ******************************* END LICENSE BLOCK ***************************/
-
-package org.sensorhub.android;
-
-import android.Manifest;
-import android.annotation.TargetApi;
-import android.app.AlertDialog;
-import android.bluetooth.BluetoothAdapter;
-import android.bluetooth.BluetoothDevice;
-import android.bluetooth.le.BluetoothLeScanner;
-import android.bluetooth.le.ScanCallback;
-import android.bluetooth.le.ScanResult;
-import android.content.DialogInterface;
-import android.content.SharedPreferences;
-import android.content.pm.PackageManager;
-import android.hardware.Camera;
-import android.net.wifi.WifiManager;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.preference.EditTextPreference;
-import android.preference.ListPreference;
-import android.preference.Preference;
-import android.preference.PreferenceActivity;
-import android.preference.PreferenceFragment;
-import android.preference.PreferenceManager;
-import android.preference.PreferenceScreen;
-import android.text.InputType;
-import android.util.Log;
-import android.widget.BaseAdapter;
-
-import androidx.annotation.RequiresPermission;
-import androidx.core.app.ActivityCompat;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.math.BigInteger;
-import java.net.InetAddress;
-import java.net.URL;
-import java.net.UnknownHostException;
-import java.nio.ByteOrder;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-
-public class UserSettingsActivity extends PreferenceActivity {
-
- private static final Logger log = LoggerFactory.getLogger(UserSettingsActivity.class);
-
- @Override
- public void onBuildHeaders(List target) {
- loadHeadersFromResource(R.xml.pref_headers, target);
- }
-
-
- /*
- * A preference value change listener that updates the preference's summary to reflect its new value.
- */
- private static final Preference.OnPreferenceChangeListener sBindPreferenceSummaryToValueListener = new Preference.OnPreferenceChangeListener() {
- @Override
- public boolean onPreferenceChange(Preference preference, Object value) {
- String stringValue = value.toString();
-
- if (preference instanceof ListPreference listPreference) {
- int index = listPreference.findIndexOfValue(stringValue);
- preference.setSummary(index >= 0 ? listPreference.getEntries()[index] : null);
- } else if (preference.getKey().startsWith("video_res")) {
- PreferenceScreen presetSettings = (PreferenceScreen) preference;
- String frameSize = ((ListPreference) presetSettings.getPreference(0)).getValue();
- String minBitrate = ((EditTextPreference) presetSettings.getPreference(1)).getText();
- String maxBitrate = ((EditTextPreference) presetSettings.getPreference(2)).getText();
- presetSettings.setSummary(frameSize + " @ " + minBitrate + "-" + maxBitrate + " kbits/s");
- ((BaseAdapter) presetSettings.getRootAdapter()).notifyDataSetChanged();
- } else {
- preference.setSummary(stringValue);
- }
-
- // detect errors
- if (preference.getKey().equals("sos_uri")) {
- try {
- URL url = new URL(value.toString());
- if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https"))
- throw new Exception("SOS URL must be HTTP or HTTPS");
- } catch (Exception e) {
- AlertDialog.Builder dlgAlert = new AlertDialog.Builder(preference.getContext());
- dlgAlert.setMessage("Invalid SOS URL");
- dlgAlert.setTitle(e.getMessage());
- dlgAlert.setPositiveButton("OK", null);
- dlgAlert.setCancelable(true);
- dlgAlert.create().show();
- }
- }
-
- return true;
- }
- };
-
-
- /*
- * Binds a preference's summary to its value. More specifically, when the
- * preference's value is changed, its summary (line of text below the
- * preference title) is updated to reflect the value. The summary is also
- * immediately updated upon calling this method. The exact display format is
- * dependent on the type of preference.
- *
- * @see #sBindPreferenceSummaryToValueListener
- */
- private static void bindPreferenceSummaryToValue(Preference preference) {
- // Set the listener to watch for value changes.
- preference.setOnPreferenceChangeListener(sBindPreferenceSummaryToValueListener);
-
- // for preference screens, call listener when screen is closed
- if (preference instanceof PreferenceScreen) {
- preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
- @Override
- public boolean onPreferenceClick(Preference preference) {
- ((PreferenceScreen) preference).getDialog().setOnCancelListener(new DialogInterface.OnCancelListener() {
- @Override
- public void onCancel(DialogInterface dialog) {
- sBindPreferenceSummaryToValueListener.onPreferenceChange(preference, "");
- }
- });
- return true;
- }
- });
- }
-
- // Trigger the listener immediately with the preference's current value.
- sBindPreferenceSummaryToValueListener.onPreferenceChange(preference, PreferenceManager.getDefaultSharedPreferences(preference.getContext()).getString(preference.getKey(), ""));
- }
-
-
- /*
- * Fragment for general preferences
- */
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public static class GeneralPreferenceFragment extends PreferenceFragment {
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- addPreferencesFromResource(R.xml.pref_general);
- bindPreferenceSummaryToValue(findPreference("device_name"));
- bindPreferenceSummaryToValue(findPreference("ip_address"));
- bindPreferenceSummaryToValue(findPreference("port"));
- bindPreferenceSummaryToValue(findPreference("endpoint_path"));
- bindPreferenceSummaryToValue(findPreference("username"));
- bindPreferenceSummaryToValue(findPreference("password"));
-
-
- WifiManager wifiManager = (WifiManager) getActivity().getApplicationContext().getSystemService(WIFI_SERVICE);
- int ipAddress = wifiManager.getConnectionInfo().getIpAddress();
-
- // Convert little-endian to big-endianif needed
- if (ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN)) {
- ipAddress = Integer.reverseBytes(ipAddress);
- }
-
- byte[] ipByteArray = BigInteger.valueOf(ipAddress).toByteArray();
-
- String ipAddressString;
- try {
- ipAddressString = InetAddress.getByAddress(ipByteArray).getHostAddress();
- } catch (UnknownHostException ex) {
- ipAddressString = "Unable to get IP Address";
- }
-
- Preference ipAddressLabel = getPreferenceScreen().findPreference("nop_ipAddress");
- ipAddressLabel.setSummary(ipAddressString);
-
-
- SharedPreferences prefs = getPreferenceManager().getSharedPreferences();
-
- Preference oAuthEnabled = getPreferenceScreen().findPreference("o_auth_enabled");
- Preference tokenEndpoint = getPreferenceScreen().findPreference("token_endpoint");
- Preference clientID = getPreferenceScreen().findPreference("client_id");
- Preference clientSecret = getPreferenceScreen().findPreference("client_secret");
-
- tokenEndpoint.setEnabled(prefs.getBoolean(oAuthEnabled.getKey(), false));
- clientID.setEnabled(prefs.getBoolean(oAuthEnabled.getKey(), false));
- clientSecret.setEnabled(prefs.getBoolean(oAuthEnabled.getKey(), false));
-
- oAuthEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- tokenEndpoint.setEnabled((boolean) newValue);
- clientID.setEnabled((boolean) newValue);
- clientSecret.setEnabled((boolean) newValue);
- return true;
- });
- }
- }
-
-
- /*
- * Fragment for sensor preferences
- */
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public static class SensorPreferenceFragment extends PreferenceFragment {
-
- List scannedEntries = new ArrayList<>();
- List scannedEntryValues = new ArrayList<>();
- Set scannedDevices = new HashSet<>();
-
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- addPreferencesFromResource(R.xml.pref_sensors);
- bindPreferenceSummaryToValue(findPreference("uid_extension"));
- bindPreferenceSummaryToValue(findPreference("angel_address"));
-
- SharedPreferences prefs = getPreferenceManager().getSharedPreferences();
-
- Preference accelerometerEnable = getPreferenceScreen().findPreference("accel_enabled");
- Preference accelerometerOptions = getPreferenceScreen().findPreference("accel_options");
- accelerometerOptions.setEnabled(prefs.getBoolean(accelerometerEnable.getKey(), false));
- accelerometerEnable.setOnPreferenceChangeListener((preference, newValue) -> {
- accelerometerOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference gyroEnabled = getPreferenceScreen().findPreference("gyro_enabled");
- Preference gyroOptions = getPreferenceScreen().findPreference("gyro_options");
- gyroOptions.setEnabled(prefs.getBoolean(gyroEnabled.getKey(), false));
- gyroEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- gyroOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference magEnabled = getPreferenceScreen().findPreference("mag_enabled");
- Preference magOptions = getPreferenceScreen().findPreference("mag_options");
- magOptions.setEnabled(prefs.getBoolean(magEnabled.getKey(), false));
- magEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- magOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference orientQuatEnabled = getPreferenceScreen().findPreference("orient_quat_enabled");
- Preference orientQuatOptions = getPreferenceScreen().findPreference("orient_quat_options");
- orientQuatOptions.setEnabled(prefs.getBoolean(orientQuatEnabled.getKey(), false));
- orientQuatEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- orientQuatOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference orientEulerEnabled = getPreferenceScreen().findPreference("orient_euler_enabled");
- Preference orientEulerOptions = getPreferenceScreen().findPreference("orient_euler_options");
- orientEulerOptions.setEnabled(prefs.getBoolean(orientEulerEnabled.getKey(), false));
- orientEulerEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- orientEulerOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference gpsEnabled = getPreferenceScreen().findPreference("gps_enabled");
- Preference gpsOptions = getPreferenceScreen().findPreference("gps_options");
- gpsOptions.setEnabled(prefs.getBoolean(gpsEnabled.getKey(), false));
- gpsEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- gpsOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference netlocEnabled = getPreferenceScreen().findPreference("netloc_enabled");
- Preference netlocOptions = getPreferenceScreen().findPreference("netloc_options");
- netlocOptions.setEnabled(prefs.getBoolean(netlocEnabled.getKey(), false));
- netlocEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- netlocOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference camEnabled = getPreferenceScreen().findPreference("cam_enabled");
- Preference camOptions = getPreferenceScreen().findPreference("cam_options");
- camOptions.setEnabled(prefs.getBoolean(camEnabled.getKey(), false));
- camEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- camOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference videoRollEnabled = getPreferenceScreen().findPreference("video_roll_enabled");
- Preference videoRollOptions = getPreferenceScreen().findPreference("video_roll_options");
- videoRollOptions.setEnabled(prefs.getBoolean(videoRollEnabled.getKey(), false));
- videoRollEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- videoRollOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference audioEnabled = getPreferenceScreen().findPreference("audio_enabled");
- Preference audioOptions = getPreferenceScreen().findPreference("audio_options");
- camOptions.setEnabled(prefs.getBoolean(camEnabled.getKey(), false));
- audioEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- audioOptions.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference trupulseEnabled = getPreferenceScreen().findPreference("trupulse_enabled");
- Preference trupulseOptions = getPreferenceScreen().findPreference("trupulse_options");
- Preference trupulseDatasource = getPreferenceScreen().findPreference("trupulse_datasource");
- ListPreference trupulseListPref = (ListPreference) getPreferenceScreen().findPreference("trupulse_device_address");
-
- trupulseOptions.setEnabled(prefs.getBoolean(trupulseEnabled.getKey(), false));
- trupulseDatasource.setEnabled(prefs.getBoolean(trupulseEnabled.getKey(), false));
- trupulseEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- trupulseOptions.setEnabled((boolean) newValue);
- trupulseDatasource.setEnabled((boolean) newValue);
- trupulseListPref.setEnabled((boolean) newValue);
- return true;
- });
-
- Preference meshtasticEnabled = getPreferenceScreen().findPreference("meshtastic_enabled");
- Preference meshtasticOptions = getPreferenceScreen().findPreference("meshtastic_options");
-
- ListPreference meshDeviceListPref = (ListPreference) getPreferenceScreen().findPreference("meshtastic_device_address");
-
-
- Preference polarEnabled = getPreferenceScreen().findPreference("polar_enabled");
- Preference polarOptions = getPreferenceScreen().findPreference("polar_options");
- ListPreference polarDeviceListPref = (ListPreference) getPreferenceScreen().findPreference("polar_device_address");
- polarEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- polarOptions.setEnabled((boolean) newValue);
- polarDeviceListPref.setEnabled((boolean) newValue);
- return true;
- });
-
-
- Preference kestrelEnabled = getPreferenceScreen().findPreference("kestrel_enabled");
- Preference kestrelOptions = getPreferenceScreen().findPreference("kestrel_options");
-// bindPreferenceSummaryToValue(findPreference("kestrel_device_name"));
-// bindPreferenceSummaryToValue(findPreference("kestrel_serial"));
-
- ListPreference kestrelDeviceListPref = (ListPreference) getPreferenceScreen().findPreference("kestrel_device_address");
-
- kestrelEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- kestrelOptions.setEnabled((boolean) newValue);
- kestrelDeviceListPref.setEnabled((boolean) newValue);
- return true;
- });
-
-
- Preference scanPref = findPreference("scan_ble_devices");
-
- scanPref.setOnPreferenceClickListener(preference -> {
- startBleScan();
- return true;
- });
-
-
- BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
- if (btAdapter != null && btAdapter.isEnabled()) {
-
-// if (!scannedEntries.isEmpty()) {
-// kestrelDeviceListPref.setEntries(scannedEntries.toArray(new CharSequence[0]));
-// kestrelDeviceListPref.setEntryValues(scannedEntryValues.toArray(new CharSequence[0]));
-// } else {
-// kestrelDeviceListPref.setEnabled(false);
-// kestrelDeviceListPref.setSummary("No BLE devices found");
-// }
-
-
- if (ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
- return;
- }
- Set bondedDevices = btAdapter.getBondedDevices();
-
- List entries = new ArrayList<>();
- List entryValues = new ArrayList<>();
-
- for (BluetoothDevice device : bondedDevices) {
- String name = device.getName();
- String mac = device.getAddress();
- entries.add(name != null ? name + " (" + mac + ")" : mac);
- entryValues.add(mac);
- }
-
- if (!entries.isEmpty()) {
- meshDeviceListPref.setEntries(entries.toArray(new CharSequence[0]));
- meshDeviceListPref.setEntryValues(entryValues.toArray(new CharSequence[0]));
-
- trupulseListPref.setEntries(entries.toArray(new CharSequence[0]));
- trupulseListPref.setEntryValues(entryValues.toArray(new CharSequence[0]));
-
- polarDeviceListPref.setEntries(entries.toArray(new CharSequence[0]));
- polarDeviceListPref.setEntryValues(entryValues.toArray(new CharSequence[0]));
- } else {
- meshDeviceListPref.setEnabled(false);
- meshDeviceListPref.setSummary("No paired Bluetooth devices found");
-
- trupulseListPref.setEnabled(false);
- trupulseListPref.setSummary("No paired Bluetooth devices found");
-
- polarDeviceListPref.setEnabled(false);
- polarDeviceListPref.setSummary("No paired Bluetooth devices found");
- }
- }
-
- meshtasticOptions.setEnabled(prefs.getBoolean(meshtasticEnabled.getKey(), false));
- meshtasticEnabled.setOnPreferenceChangeListener((preference, newValue) -> {
- meshtasticOptions.setEnabled((boolean) newValue);
- return true;
- });
-
-// Preference bleEnable = getPreferenceScreen().findPreference("ble_enabled");
-// Preference bleLocationMethod = getPreferenceScreen().findPreference("ble_loc_method");
-// Preference bleOptions = getPreferenceScreen().findPreference("ble_options");
-// Preference bleConfigURL = getPreferenceScreen().findPreference("ble_config_url");
-// bleLocationMethod.setEnabled(prefs.getBoolean(bleEnable.getKey(), false));
-// bleOptions.setEnabled((prefs.getBoolean(bleEnable.getKey(), false)));
-// bleConfigURL.setEnabled((prefs.getBoolean(bleEnable.getKey(), false)));
-// bleEnable.setOnPreferenceChangeListener(((preference, newValue) -> {
-// bleLocationMethod.setEnabled((boolean) newValue);
-// bleOptions.setEnabled((boolean) newValue);
-// bleConfigURL.setEnabled((boolean) newValue);
-// return true;
-// }));
-
- // TODO: introduce FLIR and ANGEL sensors
- }
-
- public void startBleScan() {
- BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
- if (btAdapter == null || !btAdapter.isEnabled())
- return;
-
- scannedEntries.clear();
- scannedEntryValues.clear();
- scannedDevices.clear();
-
-
- Preference scanBlePref = findPreference("scan_ble_devices");
-
- BluetoothLeScanner scanner = btAdapter.getBluetoothLeScanner();
-
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- if (ActivityCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
- return;
- }
- }
-
- ScanCallback scanCallback = new ScanCallback() {
- @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
- @Override
- public void onScanResult(int callbackType, ScanResult result) {
- BluetoothDevice device = result.getDevice();
- String name = device.getName();
- String address = device.getAddress();
-
- if (name == null && !scannedEntryValues.contains(address)) {
- name = "Unnamed Device";
- }
- if (!scannedEntryValues.contains(address)) {
- scannedEntries.add(name != null ? name + " (" + address + ")" : address);
- scannedEntryValues.add(address);
-
- updateKestrelListPreference();
- }
- }
- };
-
- scanner.startScan(scanCallback);
-
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- if (ActivityCompat.checkSelfPermission(getContext(),
- Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
- return;
- }
- }
- scanner.stopScan(scanCallback);
-
- if (scanBlePref != null) scanBlePref.setEnabled(true);
-
- updateKestrelListPreference();
- }, 8000);
-
- }
-
- private void updateKestrelListPreference() {
- ListPreference kestrelPref = (ListPreference) findPreference("kestrel_device_address");
-
- if (kestrelPref == null) return;
-
- kestrelPref.setEntries(scannedEntries.toArray(new CharSequence[0]));
- kestrelPref.setEntryValues(scannedEntryValues.toArray(new CharSequence[0]));
- kestrelPref.setEnabled(!scannedEntries.isEmpty());
-
- if (scannedEntries.isEmpty()) {
- kestrelPref.setSummary("No BLE devices found");
- } else {
- kestrelPref.setSummary("Select a device");
- }
- }
- }
-
-
- /*
- * Fragment for video settings
- */
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public static class VideoPreferenceFragment extends PreferenceFragment {
- ArrayList frameRateList = new ArrayList<>();
- ArrayList resList = new ArrayList<>();
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- addPreferencesFromResource(R.xml.pref_video);
-
- PreferenceScreen videoOptsScreen = getPreferenceScreen();
-
- // Create camera selection preference
- ArrayList cameras = new ArrayList<>();
- for (int i = 0; i < Camera.getNumberOfCameras(); i++) {
- Camera.CameraInfo info = new Camera.CameraInfo();
- Camera.getCameraInfo(i, info);
- cameras.add(Integer.toString(i));
- }
- ListPreference cameraSelectList = (ListPreference) videoOptsScreen.findPreference("camera_select");
- cameraSelectList.setEntries(cameras.toArray(new String[0]));
- cameraSelectList.setEntryValues(cameras.toArray(new String[0]));
- bindPreferenceSummaryToValue(cameraSelectList);
- videoOptsScreen.addPreference(cameraSelectList);
-
- bindPreferenceSummaryToValue(findPreference("video_codec"));
- // get possible video capture frame rates and sizes
- Camera camera = Camera.open(0);
- Camera.Parameters camParams = camera.getParameters();
- for (int frameRate : camParams.getSupportedPreviewFrameRates())
- frameRateList.add(Integer.toString(frameRate));
- for (Camera.Size imgSize : camParams.getSupportedPreviewSizes())
- resList.add(imgSize.width + "x" + imgSize.height);
- camera.release();
-
- // add list of supported frame rates
- ListPreference frameRatePrefList = (ListPreference) videoOptsScreen.findPreference("video_framerate");
- frameRatePrefList.setEntries(frameRateList.toArray(new String[0]));
- frameRatePrefList.setEntryValues(frameRateList.toArray(new String[0]));
- bindPreferenceSummaryToValue(findPreference("video_framerate"));
-
- // add list of configurable presets
- ArrayList presetNames = new ArrayList<>();
- ArrayList presetIndexes = new ArrayList<>();
- for (int i = 1; i <= 5; i++) {
- PreferenceScreen prefScreen = getPreferenceManager().createPreferenceScreen(videoOptsScreen.getContext());
- prefScreen.setKey("video_res" + i);
- String presetName = "Video Preset #" + i;
- prefScreen.setTitle(presetName);
- presetNames.add(presetName);
- presetIndexes.add(String.valueOf(i - 1));
-
- ListPreference sizeList = new ListPreference(prefScreen.getContext());
- sizeList.setKey("video_size" + i);
- sizeList.setTitle("Frame Size");
- sizeList.setEntries(resList.toArray(new String[0]));
- sizeList.setEntryValues(resList.toArray(new String[0]));
- bindPreferenceSummaryToValue(sizeList);
- prefScreen.addPreference(sizeList);
-
- EditTextPreference minBitrate = new EditTextPreference(prefScreen.getContext());
- minBitrate.setKey("video_min_bitrate" + i);
- minBitrate.setTitle("Min Bitrate (kbits/s)");
- minBitrate.getEditText().setSingleLine();
- minBitrate.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- minBitrate.setDefaultValue("3000");
- bindPreferenceSummaryToValue(minBitrate);
- prefScreen.addPreference(minBitrate);
-
- EditTextPreference maxBitrate = new EditTextPreference(prefScreen.getContext());
- maxBitrate.setKey("video_max_bitrate" + i);
- maxBitrate.setTitle("Max Bitrate (kbits/s)");
- maxBitrate.getEditText().setSingleLine();
- maxBitrate.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- maxBitrate.setDefaultValue("3000");
- bindPreferenceSummaryToValue(maxBitrate);
- prefScreen.addPreference(maxBitrate);
-
- bindPreferenceSummaryToValue(prefScreen);
- videoOptsScreen.addPreference(prefScreen);
- }
-
- // add list of selectable presets
- ListPreference selectedPresetList = (ListPreference) videoOptsScreen.findPreference("video_preset");
- presetNames.add("Auto select");
- presetIndexes.add("AUTO");
- selectedPresetList.setEntries(presetNames.toArray(new String[0]));
- selectedPresetList.setEntryValues(presetIndexes.toArray(new String[0]));
-
- // Setup Camera Listener
- cameraSelectList.setOnPreferenceChangeListener((preference, newValue) -> {
- Log.d("CAMERA_SELECT", "New Camera Selected: " + newValue);
- updateCameraSettings(Integer.parseInt((String) newValue));
- cameraSelectList.setSummary(newValue.toString());
- return true;
- });
- }
-
- protected void updateCameraSettings(Integer cameraId) {
- Camera camera = Camera.open(cameraId);
- Camera.Parameters camParams = camera.getParameters();
- for (int frameRate : camParams.getSupportedPreviewFrameRates())
- frameRateList.add(Integer.toString(frameRate));
- for (Camera.Size imgSize : camParams.getSupportedPreviewSizes())
- resList.add(imgSize.width + "x" + imgSize.height);
- camera.release();
- }
- }
-
-
- /*
- * Fragment for audio settings
- */
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public static class AudioPreferenceFragment extends PreferenceFragment {
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- addPreferencesFromResource(R.xml.pref_audio);
-
- PreferenceScreen audioOptsScreen = getPreferenceScreen();
- bindPreferenceSummaryToValue(findPreference("audio_codec"));
-
- // get possible video capture frame rates and sizes
- List sampleRateList = Arrays.asList("8000", "11025", "22050", "44100", "48000");
- List bitRateList = Arrays.asList("32", "64", "96", "128", "160", "192");
-
- // add list of supported sample rates
- ListPreference sampleRatePrefList = (ListPreference) audioOptsScreen.findPreference("audio_samplerate");
- sampleRatePrefList.setEntries(sampleRateList.toArray(new String[0]));
- sampleRatePrefList.setEntryValues(sampleRateList.toArray(new String[0]));
- bindPreferenceSummaryToValue(findPreference("audio_samplerate"));
-
- // add list of supported bitrates
- ListPreference bitRatePrefList = (ListPreference) audioOptsScreen.findPreference("audio_bitrate");
- bitRatePrefList.setEntries(bitRateList.toArray(new String[0]));
- bitRatePrefList.setEntryValues(bitRateList.toArray(new String[0]));
- bindPreferenceSummaryToValue(findPreference("audio_samplerate"));
- }
- }
-
-
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public static class KestrelPreferenceFragment extends PreferenceFragment {
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- addPreferencesFromResource(R.xml.pref_kestrel);
-
- PreferenceScreen kestrelOptsScreen = getPreferenceScreen();
-
- ArrayList presetNames = new ArrayList<>();
- ArrayList presetIndexes = new ArrayList<>();
-
- for (int i = 1; i <= 5; i++) {
- PreferenceScreen prefScreen = getPreferenceManager().createPreferenceScreen(kestrelOptsScreen.getContext());
- prefScreen.setKey("kestrel_preset" + i);
- String presetName = "Gun Profile Preset #" + i;
- prefScreen.setTitle(presetName);
- presetNames.add(presetName);
- presetIndexes.add(String.valueOf(i - 1));
-
- addBulletDataFields(prefScreen, i);
- addGunFields(prefScreen, i);
- addScopeDataFields(prefScreen, i);
-
- bindPreferenceSummaryToValue(prefScreen);
- kestrelOptsScreen.addPreference(prefScreen);
- }
-
-
- // add list of selectable presets
- ListPreference selectedPresetList = (ListPreference) kestrelOptsScreen.findPreference("kestrel_profile_preset");
- presetNames.add("Auto select");
- presetIndexes.add("AUTO");
- selectedPresetList.setEntries(presetNames.toArray(new String[0]));
- selectedPresetList.setEntryValues(presetIndexes.toArray(new String[0]));
- }
-
- private void addProfileFields(PreferenceScreen preferenceScreen, int index) {
-//
-//
-//
-//
-//
- }
-
- private void addScopeDataFields(PreferenceScreen prefScreen, int index) {
- List unitList = Arrays.asList("inches", "mil", "tmoa", "smoa", "clicks", "cm");
-
- ListPreference eUnitList = new ListPreference(prefScreen.getContext());
- eUnitList.setKey("e_unit_" + index);
- eUnitList.setTitle("E Units");
- eUnitList.setEntries(unitList.toArray(new String[0]));
- eUnitList.setEntryValues(unitList.toArray(new String[0]));
- bindPreferenceSummaryToValue(eUnitList);
- prefScreen.addPreference(eUnitList);
-
- ListPreference wUnitList = new ListPreference(prefScreen.getContext());
- wUnitList.setKey("w_unit_" + index);
- wUnitList.setTitle("W Units");
- wUnitList.setEntries(unitList.toArray(new String[0]));
- wUnitList.setEntryValues(unitList.toArray(new String[0]));
- bindPreferenceSummaryToValue(wUnitList);
- prefScreen.addPreference(wUnitList);
- }
-
- private void addGunFields(PreferenceScreen prefScreen, int index) {
- EditTextPreference muzzleVel = new EditTextPreference(prefScreen.getContext());
- muzzleVel.setKey("muzzle_velocity_" + index);
- muzzleVel.setTitle("Muzzle Velocity (fps)");
- muzzleVel.setDialogTitle("Enter the muzzle velocity (fps)");
- muzzleVel.getEditText().setSingleLine();
- muzzleVel.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- muzzleVel.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(muzzleVel);
- prefScreen.addPreference(muzzleVel);
-
- EditTextPreference zeroRange = new EditTextPreference(prefScreen.getContext());
- zeroRange.setKey("zero_range_" + index);
- zeroRange.setTitle("Zero Range (m)");
- zeroRange.setDialogTitle("Enter the zero range (m)");
- zeroRange.getEditText().setSingleLine();
- zeroRange.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- zeroRange.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(zeroRange);
- prefScreen.addPreference(zeroRange);
-
- EditTextPreference boreHeight = new EditTextPreference(prefScreen.getContext());
- boreHeight.setKey("bore_height_" + index);
- boreHeight.setTitle("Bore Height (in)");
- boreHeight.setDialogTitle("Enter the bore height (in)");
- boreHeight.getEditText().setSingleLine();
- boreHeight.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- boreHeight.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(boreHeight);
- prefScreen.addPreference(boreHeight);
-
- EditTextPreference zeroHeight = new EditTextPreference(prefScreen.getContext());
- zeroHeight.setKey("zero_height_" + index);
- zeroHeight.setTitle("Zero Height (in)");
- zeroHeight.setDialogTitle("Enter the zero height (in)");
- zeroHeight.getEditText().setSingleLine();
- zeroHeight.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- zeroHeight.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(zeroHeight);
- prefScreen.addPreference(zeroHeight);
-
- EditTextPreference zeroOffset = new EditTextPreference(prefScreen.getContext());
- zeroOffset.setKey("zero_offset_" + index);
- zeroOffset.setTitle("Zero Offset (in)");
- zeroOffset.setDialogTitle("Enter the zero offset (in)");
- zeroOffset.getEditText().setSingleLine();
- zeroOffset.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- zeroOffset.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(zeroOffset);
- prefScreen.addPreference(zeroOffset);
-
- EditTextPreference twistRate = new EditTextPreference(prefScreen.getContext());
- twistRate.setKey("twist_rate_" + index);
- twistRate.setTitle("Twist Rate (in)");
- twistRate.setDialogTitle("Enter the twist rate (in)");
- twistRate.getEditText().setSingleLine();
- twistRate.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- twistRate.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(twistRate);
- prefScreen.addPreference(twistRate);
-
- List directionlist = Arrays.asList("L", "R");
-
- ListPreference twistRateList = new ListPreference(prefScreen.getContext());
- twistRateList.setKey("twist_rate_direction_" + index);
- twistRateList.setTitle("Twist Rate Direction");
- twistRateList.setEntries(directionlist.toArray(new String[0]));
- twistRateList.setEntryValues(directionlist.toArray(new String[0]));
- bindPreferenceSummaryToValue(twistRateList);
- prefScreen.addPreference(twistRateList);
- }
-
-
- private void addBulletDataFields(PreferenceScreen prefScreen, int index) {
- EditTextPreference diameter = new EditTextPreference(prefScreen.getContext());
- diameter.setKey("diameter_" + index);
- diameter.setTitle("Diameter (in)");
- diameter.setDialogTitle("Enter the diameter (inches)");
- diameter.getEditText().setSingleLine();
- diameter.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- diameter.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(diameter);
- prefScreen.addPreference(diameter);
-
- EditTextPreference weight = new EditTextPreference(prefScreen.getContext());
- weight.setKey("weight_" + index);
- weight.setTitle("Weight (gr)");
- weight.setDialogTitle("Enter the weight (gr)");
- weight.getEditText().setSingleLine();
- weight.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- weight.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(weight);
- prefScreen.addPreference(weight);
-
- EditTextPreference ballistic = new EditTextPreference(prefScreen.getContext());
- ballistic.setKey("ballistic_" + index);
- ballistic.setTitle("Ballistic Coefficient (G7)");
- ballistic.setDialogTitle("Enter the Ballistic Coefficient (G7)");
- ballistic.getEditText().setSingleLine();
- ballistic.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- ballistic.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(ballistic);
- prefScreen.addPreference(ballistic);
-
- EditTextPreference length = new EditTextPreference(prefScreen.getContext());
- length.setKey("length_" + index);
- length.setTitle("Length (in)");
- length.setDialogTitle("Enter the length (in)");
- length.getEditText().setSingleLine();
- length.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
- length.setDefaultValue("0.0");
- bindPreferenceSummaryToValue(length);
- prefScreen.addPreference(length);
- }
-
- }
-
- @Override
- protected boolean isValidFragment(String fragmentName) {
- return true;
- }
-}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/server/EditServerProfileActivity.java b/sensorhub-android-app/src/org/sensorhub/android/server/EditServerProfileActivity.java
new file mode 100644
index 00000000..73588ba9
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/server/EditServerProfileActivity.java
@@ -0,0 +1,194 @@
+package org.sensorhub.android.server;
+
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.button.MaterialButtonToggleGroup;
+import com.google.android.material.materialswitch.MaterialSwitch;
+import com.google.android.material.textfield.TextInputEditText;
+
+import org.sensorhub.android.R;
+
+public class EditServerProfileActivity extends AppCompatActivity {
+
+ public static final String EXTRA_PROFILE_ID = "profile_id";
+
+ private ServerProfileRepository repo;
+ private ServerProfile profile;
+ private boolean isEdit;
+
+ private TextInputEditText nameInput, hostInput, portInput, endpointInput;
+ private TextInputEditText usernameInput, passwordInput;
+ private TextInputEditText tokenInput, clientIdInput, clientSecretInput;
+ private MaterialSwitch tlsSwitch, sslSwitch;
+ private MaterialButtonToggleGroup profileTypeToggle, authModeToggle;
+ private View basicAuthFields, oauthFields;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_edit_server_profile);
+
+ repo = new ServerProfileRepository(this);
+
+ String profileId = getIntent().getStringExtra(EXTRA_PROFILE_ID);
+ isEdit = profileId != null;
+ profile = isEdit ? repo.getById(profileId) : new ServerProfile();
+
+ if (isEdit && profile == null) {
+ Toast.makeText(this, R.string.msg_profile_not_found, Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ setupToolbar();
+ bindViews();
+ setupListeners();
+ if (isEdit) populateFields();
+ updateSslVisibility();
+
+ MaterialButton saveButton = findViewById(R.id.btn_save);
+ saveButton.setOnClickListener(v -> saveProfile());
+ }
+
+ private void setupToolbar() {
+ MaterialToolbar toolbar = findViewById(R.id.edit_profile_toolbar);
+ toolbar.setTitle(isEdit ? getString(R.string.title_edit_server) : getString(R.string.btn_add_server));
+ toolbar.setNavigationOnClickListener(v -> finish());
+ }
+
+ private void bindViews() {
+ nameInput = findViewById(R.id.edit_name);
+ hostInput = findViewById(R.id.edit_host);
+ portInput = findViewById(R.id.edit_port);
+ endpointInput = findViewById(R.id.edit_endpoint);
+ usernameInput = findViewById(R.id.edit_username);
+ passwordInput = findViewById(R.id.edit_password);
+ tokenInput = findViewById(R.id.edit_token_endpoint);
+ clientIdInput = findViewById(R.id.edit_client_id);
+ clientSecretInput = findViewById(R.id.edit_client_secret);
+ tlsSwitch = findViewById(R.id.switch_tls);
+ sslSwitch = findViewById(R.id.switch_disable_ssl);
+ profileTypeToggle = findViewById(R.id.toggle_profile_type);
+ authModeToggle = findViewById(R.id.toggle_auth_mode);
+ basicAuthFields = findViewById(R.id.basic_auth_fields);
+ oauthFields = findViewById(R.id.oauth_fields);
+ }
+
+ private void setupListeners() {
+ authModeToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> {
+ if (isChecked) updateAuthVisibility();
+ });
+
+ tlsSwitch.setOnCheckedChangeListener((btn, checked) -> updateSslVisibility());
+ }
+
+ private void updateAuthVisibility() {
+ int checkedId = authModeToggle.getCheckedButtonId();
+ basicAuthFields.setVisibility(checkedId == R.id.btn_auth_basic ? View.VISIBLE : View.GONE);
+ oauthFields.setVisibility(checkedId == R.id.btn_auth_oauth ? View.VISIBLE : View.GONE);
+ }
+
+ private void updateSslVisibility() {
+ sslSwitch.setVisibility(tlsSwitch.isChecked() ? View.VISIBLE : View.GONE);
+ if (!tlsSwitch.isChecked()) {
+ sslSwitch.setChecked(false);
+ }
+ }
+
+ private void populateFields() {
+ nameInput.setText(profile.name);
+ hostInput.setText(profile.host);
+ portInput.setText(String.valueOf(profile.port));
+ endpointInput.setText(profile.endpointPath);
+ usernameInput.setText(profile.username);
+ tlsSwitch.setChecked(profile.enableTls);
+ sslSwitch.setChecked(profile.disableSslCheck);
+ updateSslVisibility();
+
+ // Profile type toggle
+ profileTypeToggle.check(profile.useConSysClient ? R.id.btn_type_consys : R.id.btn_type_sos);
+
+ // Auth mode toggle
+ if (profile.oAuthEnabled) {
+ authModeToggle.check(R.id.btn_auth_oauth);
+ } else if (profile.username != null && !profile.username.isEmpty()) {
+ authModeToggle.check(R.id.btn_auth_basic);
+ } else {
+ authModeToggle.check(R.id.btn_auth_none);
+ }
+ updateAuthVisibility();
+
+ // Secure fields
+ passwordInput.setText(repo.getPassword(profile.id));
+ tokenInput.setText(repo.getOAuthTokenEndpoint(profile.id));
+ clientIdInput.setText(repo.getOAuthClientId(profile.id));
+ clientSecretInput.setText(repo.getOAuthClientSecret(profile.id));
+ }
+
+ private void saveProfile() {
+ String name = nameInput.getText().toString().trim();
+ String host = hostInput.getText().toString().trim();
+ String portStr = portInput.getText().toString().trim();
+ String endpoint = endpointInput.getText().toString().trim();
+
+ if (name.isEmpty() || host.isEmpty() || portStr.isEmpty()) {
+ Toast.makeText(this, R.string.msg_name_host_port_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (host.contains(" ") || host.contains("://")) {
+ Toast.makeText(this, R.string.msg_no_protocol, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ int port;
+ try {
+ port = Integer.parseInt(portStr);
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, R.string.msg_port_number, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (port < 1 || port > 65535) {
+ Toast.makeText(this, R.string.msg_port_range, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (!endpoint.isEmpty() && !endpoint.startsWith("/")) {
+ endpoint = "/" + endpoint;
+ }
+
+ int authCheckedId = authModeToggle.getCheckedButtonId();
+
+ profile.name = name;
+ profile.host = host;
+ profile.port = port;
+ profile.endpointPath = endpoint;
+ profile.username = authCheckedId == R.id.btn_auth_basic
+ ? usernameInput.getText().toString().trim() : "";
+ profile.enableTls = tlsSwitch.isChecked();
+ profile.disableSslCheck = sslSwitch.isChecked();
+ profile.useConSysClient = profileTypeToggle.getCheckedButtonId() == R.id.btn_type_consys;
+ profile.oAuthEnabled = authCheckedId == R.id.btn_auth_oauth;
+
+ repo.save(profile);
+
+ String password = authCheckedId == R.id.btn_auth_basic
+ ? passwordInput.getText().toString().trim() : "";
+ repo.setPassword(profile.id, password);
+
+ if (profile.oAuthEnabled) {
+ repo.setOAuthClientId(profile.id, clientIdInput.getText().toString().trim());
+ repo.setOAuthClientSecret(profile.id, clientSecretInput.getText().toString().trim());
+ repo.setOAuthTokenEndpoint(profile.id, tokenInput.getText().toString().trim());
+ }
+
+ setResult(RESULT_OK);
+ finish();
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/server/ServerAdapter.java b/sensorhub-android-app/src/org/sensorhub/android/server/ServerAdapter.java
new file mode 100644
index 00000000..d58772b0
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/server/ServerAdapter.java
@@ -0,0 +1,82 @@
+package org.sensorhub.android.server;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.materialswitch.MaterialSwitch;
+
+import org.sensorhub.android.R;
+
+import java.util.List;
+
+public class ServerAdapter extends RecyclerView.Adapter {
+
+ public interface Listener {
+ void onEditClicked(ServerProfile profile);
+ void onEnabledToggled(ServerProfile profile, boolean enabled);
+ void onDeleteRequested(ServerProfile profile);
+ }
+
+ private final List servers;
+ private final Listener listener;
+
+ public ServerAdapter(List servers, Listener listener) {
+ this.servers = servers;
+ this.listener = listener;
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ TextView name, summary, mode;
+ MaterialSwitch enabledSwitch;
+ ImageButton editButton;
+
+ public ViewHolder(View view) {
+ super(view);
+ name = view.findViewById(R.id.profile_name);
+ summary = view.findViewById(R.id.profile_summary);
+ mode = view.findViewById(R.id.profile_mode);
+ enabledSwitch = view.findViewById(R.id.profile_enabled_switch);
+ editButton = view.findViewById(R.id.btn_edit_profile);
+ }
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_server_profile, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ ServerProfile p = servers.get(position);
+
+ holder.name.setText(p.name);
+ holder.summary.setText(p.getDisplaySummary());
+ holder.mode.setText(p.getClientModeLabel());
+
+ holder.enabledSwitch.setOnCheckedChangeListener(null);
+ holder.enabledSwitch.setChecked(p.enabled);
+ holder.enabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) ->
+ listener.onEnabledToggled(p, isChecked));
+
+ holder.editButton.setOnClickListener(v -> listener.onEditClicked(p));
+
+ holder.itemView.setOnLongClickListener(v -> {
+ listener.onDeleteRequested(p);
+ return true;
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return servers.size();
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfile.java b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfile.java
new file mode 100644
index 00000000..43576b9e
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfile.java
@@ -0,0 +1,101 @@
+package org.sensorhub.android.server;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.UUID;
+
+public class ServerProfile {
+ public String id;
+ public String name;
+ public String host;
+ public int port;
+ public String endpointPath;
+ public String username;
+ public boolean enableTls;
+ public boolean disableSslCheck;
+ public boolean useConSysClient;
+ public boolean oAuthEnabled;
+ public boolean enabled;
+ public String password;
+ public String clientId;
+ public String clientSecret;
+ public String tokenEndpoint;
+
+ public ServerProfile() {
+ this.id = UUID.randomUUID().toString();
+ this.name = "Local Server";
+ this.host = "127.0.0.1";
+ this.port = 8080;
+ this.endpointPath = "/sensorhub/api";
+ this.username = "";
+ this.enableTls = false;
+ this.disableSslCheck = false;
+ this.useConSysClient = true;
+ this.oAuthEnabled = false;
+ this.enabled = true;
+ }
+
+ public JSONObject toJson() throws JSONException {
+ JSONObject obj = new JSONObject();
+ obj.put("id", id);
+ obj.put("name", name);
+ obj.put("host", host);
+ obj.put("port", port);
+ obj.put("endpointPath", endpointPath);
+ obj.put("username", username);
+ obj.put("enableTls", enableTls);
+ obj.put("disableSslCheck", disableSslCheck);
+ obj.put("useConSysClient", useConSysClient);
+ obj.put("oAuthEnabled", oAuthEnabled);
+ obj.put("enabled", enabled);
+ return obj;
+ }
+
+ public static ServerProfile fromJson(JSONObject obj) throws JSONException {
+ ServerProfile p = new ServerProfile();
+ p.id = obj.getString("id");
+ p.name = obj.optString("name", "");
+ p.host = obj.optString("host", "127.0.0.1");
+ p.port = obj.optInt("port", 8080);
+ p.endpointPath = obj.optString("endpointPath", "/sensorhub/api");
+ p.username = obj.optString("username", "");
+ p.enableTls = obj.optBoolean("enableTls", false);
+ p.disableSslCheck = obj.optBoolean("disableSslCheck", false);
+ p.useConSysClient = obj.optBoolean("useConSysClient", true);
+ p.oAuthEnabled = obj.optBoolean("oAuthEnabled", false);
+ p.enabled = obj.optBoolean("enabled", true);
+ return p;
+ }
+
+ public URL buildClientUrl() {
+ String cleanHost = host.replace("http://", "").replace("https://", "").trim();
+ if (cleanHost.isEmpty())
+ cleanHost = "127.0.0.1";
+
+ String path = endpointPath != null ? endpointPath.trim() : "";
+ if (!path.isEmpty() && !path.startsWith("/")) {
+ path = "/" + path;
+ }
+
+
+ String urlStr = (enableTls ? "https://" : "http://") + cleanHost + ":" + port + path;
+ try {
+ return new URI(urlStr).toURL();
+ } catch (URISyntaxException | MalformedURLException e) {
+ return null;
+ }
+ }
+
+ public String getDisplaySummary() {
+ return host + ":" + port + (endpointPath != null ? endpointPath : "");
+ }
+
+ public String getClientModeLabel() {
+ return useConSysClient ? "Connected Systems" : "SOS-T";
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfileRepository.java b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfileRepository.java
new file mode 100644
index 00000000..9453db9f
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfileRepository.java
@@ -0,0 +1,127 @@
+package org.sensorhub.android.server;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.preference.PreferenceManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.sensorhub.android.SecurePrefs;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ServerProfileRepository {
+ private static final String KEY_PROFILES_JSON = "server_profiles_json";
+ private final Context context;
+ private final SharedPreferences prefs;
+
+ public ServerProfileRepository(Context context) {
+ this.context = context.getApplicationContext();
+ this.prefs = PreferenceManager.getDefaultSharedPreferences(this.context);
+ }
+
+ public List getAll() {
+ List profiles = new ArrayList<>();
+ String json = prefs.getString(KEY_PROFILES_JSON, null);
+ if (json == null) return profiles;
+
+ try {
+ JSONArray arr = new JSONArray(json);
+ for (int i = 0; i < arr.length(); i++) {
+ profiles.add(ServerProfile.fromJson(arr.getJSONObject(i)));
+ }
+ } catch (JSONException e) {
+ // corrupted data, return empty
+ }
+ return profiles;
+ }
+
+ public List getEnabled() {
+ List enabled = new ArrayList<>();
+ for (ServerProfile p : getAll()) {
+ if (p.enabled) enabled.add(p);
+ }
+ return enabled;
+ }
+
+ public ServerProfile getById(String id) {
+ for (ServerProfile p : getAll()) {
+ if (p.id.equals(id)) return p;
+ }
+ return null;
+ }
+
+ public void save(ServerProfile profile) {
+ List all = getAll();
+ boolean found = false;
+ for (int i = 0; i < all.size(); i++) {
+ if (all.get(i).id.equals(profile.id)) {
+ all.set(i, profile);
+ found = true;
+ break;
+ }
+ }
+ if (!found) all.add(profile);
+ persist(all);
+ }
+
+ public void delete(String id) {
+ List all = getAll();
+ all.removeIf(p -> p.id.equals(id));
+ persist(all);
+ SecurePrefs.removeByPrefix(context, "profile_" + id + "_");
+ }
+
+ public void setEnabled(String id, boolean enabled) {
+ ServerProfile p = getById(id);
+ if (p != null) {
+ p.enabled = enabled;
+ save(p);
+ }
+ }
+
+ public String getPassword(String profileId) {
+ return SecurePrefs.get(context, "profile_" + profileId + "_password", null);
+ }
+
+ public void setPassword(String profileId, String password) {
+ SecurePrefs.put(context, "profile_" + profileId + "_password", password);
+ }
+
+ public String getOAuthTokenEndpoint(String profileId) {
+ return SecurePrefs.get(context, "profile_" + profileId + "_oauth_token_endpoint", "");
+ }
+
+ public void setOAuthTokenEndpoint(String profileId, String value) {
+ SecurePrefs.put(context, "profile_" + profileId + "_oauth_token_endpoint", value);
+ }
+
+ public String getOAuthClientId(String profileId) {
+ return SecurePrefs.get(context, "profile_" + profileId + "_oauth_client_id", "");
+ }
+
+ public void setOAuthClientId(String profileId, String value) {
+ SecurePrefs.put(context, "profile_" + profileId + "_oauth_client_id", value);
+ }
+
+ public String getOAuthClientSecret(String profileId) {
+ return SecurePrefs.get(context, "profile_" + profileId + "_oauth_client_secret", "");
+ }
+
+ public void setOAuthClientSecret(String profileId, String value) {
+ SecurePrefs.put(context, "profile_" + profileId + "_oauth_client_secret", value);
+ }
+
+ private void persist(List profiles) {
+ JSONArray arr = new JSONArray();
+ for (ServerProfile p : profiles) {
+ try {
+ arr.put(p.toJson());
+ } catch (JSONException ignored) {
+ }
+ }
+ prefs.edit().putString(KEY_PROFILES_JSON, arr.toString()).apply();
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfilesActivity.java b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfilesActivity.java
new file mode 100644
index 00000000..9fba3146
--- /dev/null
+++ b/sensorhub-android-app/src/org/sensorhub/android/server/ServerProfilesActivity.java
@@ -0,0 +1,95 @@
+package org.sensorhub.android.server;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.sensorhub.android.R;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ServerProfilesActivity extends AppCompatActivity implements ServerAdapter.Listener {
+
+ private RecyclerView recyclerView;
+ private TextView emptyText;
+ private ServerAdapter adapter;
+ private final List servers = new ArrayList<>();
+ private ServerProfileRepository repo;
+
+ private final ActivityResultLauncher editLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ refreshList();
+ });
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_server_profiles);
+
+ MaterialToolbar toolbar = findViewById(R.id.server_profiles_toolbar);
+ setSupportActionBar(toolbar);
+ toolbar.setNavigationOnClickListener(v -> onBackPressed());
+
+ repo = new ServerProfileRepository(this);
+
+ recyclerView = findViewById(R.id.server_list);
+ emptyText = findViewById(R.id.empty_text);
+
+ FloatingActionButton fab = findViewById(R.id.fab_add_server);
+ fab.setOnClickListener(v -> {
+ Intent intent = new Intent(this, EditServerProfileActivity.class);
+ editLauncher.launch(intent);
+ });
+
+ adapter = new ServerAdapter(servers, this);
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ recyclerView.setAdapter(adapter);
+
+ refreshList();
+ }
+
+ @Override
+ public void onEditClicked(ServerProfile profile) {
+ Intent intent = new Intent(this, EditServerProfileActivity.class);
+ intent.putExtra(EditServerProfileActivity.EXTRA_PROFILE_ID, profile.id);
+ editLauncher.launch(intent);
+ }
+
+ @Override
+ public void onEnabledToggled(ServerProfile profile, boolean enabled) {
+ repo.setEnabled(profile.id, enabled);
+ }
+
+ @Override
+ public void onDeleteRequested(ServerProfile profile) {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.title_delete_server)
+ .setMessage(getString(R.string.msg_delete_server, profile.name))
+ .setPositiveButton(R.string.btn_delete, (d, w) -> {
+ repo.delete(profile.id);
+ refreshList();
+ })
+ .setNegativeButton(R.string.btn_cancel, null)
+ .show();
+ }
+
+ private void refreshList() {
+ servers.clear();
+ servers.addAll(repo.getAll());
+ adapter.notifyDataSetChanged();
+ emptyText.setVisibility(servers.isEmpty() ? View.VISIBLE : View.GONE);
+ emptyText.setText(servers.isEmpty() ? getString(R.string.empty_server_profiles) : "");
+ }
+}
diff --git a/sensorhub-android-app/src/org/sensorhub/impl/sensor/swe/ProxySensor/ProxySensor.java b/sensorhub-android-app/src/org/sensorhub/impl/sensor/swe/ProxySensor/ProxySensor.java
index 7e259d4a..1d839606 100644
--- a/sensorhub-android-app/src/org/sensorhub/impl/sensor/swe/ProxySensor/ProxySensor.java
+++ b/sensorhub-android-app/src/org/sensorhub/impl/sensor/swe/ProxySensor/ProxySensor.java
@@ -64,7 +64,7 @@ public void onReceive(Context context, Intent intent) {
try {
stopSOSStreams();
} catch (SensorHubException e) {
- e.printStackTrace();
+ Log.e(TAG, "Error stopping SOS streams", e);
}
}
}
diff --git a/sensorhub-android-controller/AndroidManifest.xml b/sensorhub-android-controller/AndroidManifest.xml
new file mode 100644
index 00000000..26edfd10
--- /dev/null
+++ b/sensorhub-android-controller/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sensorhub-android-controller/README.md b/sensorhub-android-controller/README.md
new file mode 100644
index 00000000..54dfb2e4
--- /dev/null
+++ b/sensorhub-android-controller/README.md
@@ -0,0 +1,16 @@
+# Android Controller Driver
+
+OpenSensorHub driver for Android gamepad controllers. Captures real-time input from any connected gamepad via USB.
+
+## Captured Inputs
+
+- **Buttons**: A, B, X, Y, L1, R1, L3 (left stick click), R3 (right stick click), Mode, Start, Select
+- **Triggers**: Left trigger, Right trigger (analog 0.0 - 1.0)
+- **Joysticks**: Left stick X/Y, Right stick X/Y (analog -1.0 to 1.0)
+- **D-Pad**: 8-directional (UP, DOWN, LEFT, RIGHT, and diagonals) plus NONE
+
+## Setup
+
+1. Connect a gamepad controller to the Android device (USB)
+2. Enable the controller sensor in the osh-android app sensors tab
+3. The driver auto-detects connected gamepads and listens for events
diff --git a/sensorhub-android-controller/build.gradle b/sensorhub-android-controller/build.gradle
new file mode 100644
index 00000000..1dbc2de0
--- /dev/null
+++ b/sensorhub-android-controller/build.gradle
@@ -0,0 +1,44 @@
+apply plugin: 'com.android.library'
+
+description = 'Android Controller'
+ext.details = 'Driver for Android Controller Sensors'
+version = '1.0.0'
+
+dependencies {
+ //api 'org.sensorhub:sensorhub-core:' + oshCoreVersion
+ api project(':sensorhub-core')
+ api project(':sensorhub-android-service')
+
+ implementation project(path: ':sensorhub-driver-android')
+}
+
+configurations.configureEach {
+ exclude group: "ch.qos.logback"
+}
+
+
+android {
+ namespace 'org.sensorhub.impl.sensor.controller'
+ compileSdkVersion rootProject.compileSdkVersion
+ buildToolsVersion rootProject.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.minSdkVersion
+ targetSdkVersion rootProject.targetSdkVersion
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src/main/java']
+ resources.srcDirs = ['src/main/resources']
+ res.srcDirs = ['res']
+ assets.srcDirs = ['assets']
+ jniLibs.srcDirs = ['libs']
+ }
+ }
+}
\ No newline at end of file
diff --git a/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerConfig.java b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerConfig.java
new file mode 100644
index 00000000..1193a586
--- /dev/null
+++ b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerConfig.java
@@ -0,0 +1,52 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.controller;
+
+import org.sensorhub.android.SensorHubService;
+import org.sensorhub.api.sensor.SensorConfig;
+
+import android.content.Context;
+import android.provider.Settings;
+
+
+/**
+ * Configuration class for the Android Controller driver.
+ *
+ * @author Kalyn Stricklin
+ * @since 05/26/2024
+ */
+public class ControllerConfig extends SensorConfig
+{
+ public ControllerConfig()
+ {
+ this.moduleClass = ControllerDriver.class.getCanonicalName();
+ }
+
+ public String deviceName = "controller";
+ public String uid_extension;
+
+ public static String getUid() {
+ Context context = SensorHubService.getContext();
+ return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
+ }
+
+ public String getUidWithExt() {
+ String baseUid = getUid();
+ if (uid_extension != null && !uid_extension.isEmpty())
+ return baseUid + ":" + uid_extension;
+ return baseUid;
+ }
+}
diff --git a/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerDriver.java b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerDriver.java
new file mode 100644
index 00000000..5162cfbb
--- /dev/null
+++ b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerDriver.java
@@ -0,0 +1,264 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.controller;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import net.opengis.sensorml.v20.PhysicalComponent;
+
+import org.sensorhub.android.SensorHubService;
+import org.sensorhub.api.sensor.SensorException;
+import org.sensorhub.impl.sensor.AbstractSensorModule;
+import org.sensorhub.impl.sensor.android.SensorMLBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+
+/**
+ * Android gamepad controller driver. Captures button presses, trigger axes, joystick axes and D-Pad input from any connected gamepad
+ *
+ * @author Kalyn Stricklin
+ * @since 05/26/2024
+ */
+public class ControllerDriver extends AbstractSensorModule implements InputManager.InputDeviceListener {
+ private final ArrayList smlComponents;
+ private final SensorMLBuilder smlBuilder;
+ static final String UID_PREFIX = "urn:osh:sensor:controller:";
+ static final Logger logger = LoggerFactory.getLogger(ControllerDriver.class.getSimpleName());
+ private Context context;
+ ControllerOutput output;
+ private HandlerThread eventThread;
+ private Handler eventHandler;
+ private InputManager inputManager;
+ private int controllerDeviceId = -1;
+
+ private boolean btnA, btnB, btnX, btnY,
+ btnL1, btnR1, btnL3, btnR3,
+ btnMode, btnStart, btnSelect;
+ private float triggerL, triggerR,
+ leftX, leftY, rightX, rightY;
+ private String dpad = "NONE";
+ public ControllerDriver() {
+ this.smlComponents = new ArrayList();
+ this.smlBuilder = new SensorMLBuilder();
+ }
+
+ @Override
+ public void doInit() {
+ logger.info("Initializing Controller Sensor");
+ this.xmlID = "CONTROLLER_" + Build.SERIAL;
+ this.uniqueID = UID_PREFIX + config.getUidWithExt();
+
+ context = SensorHubService.getContext();
+ inputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+
+ findController();
+
+ output = new ControllerOutput(this);
+ output.doInit();
+ addOutput(output, false);
+ }
+
+ @Override
+ public void doStart() throws SensorException {
+ eventThread = new HandlerThread("ControllerThread");
+ eventThread.start();
+ eventHandler = new Handler(eventThread.getLooper());
+
+ inputManager.registerInputDeviceListener(this, eventHandler);
+
+ logger.info("Controller sensor started, device ID: {}", controllerDeviceId);
+ }
+
+ private void findController() {
+ int[] deviceIds = inputManager.getInputDeviceIds();
+ for (int id : deviceIds) {
+ InputDevice device = inputManager.getInputDevice(id);
+ if (device != null && isGamepad(device)) {
+ controllerDeviceId = id;
+ logger.info("Found controller: {} (id={})", device.getName(), id);
+ return;
+ }
+ }
+ logger.warn("No gamepad controller connected");
+ }
+
+ private boolean isGamepad(InputDevice device) {
+ return device.supportsSource(InputDevice.SOURCE_GAMEPAD) || device.supportsSource(InputDevice.SOURCE_JOYSTICK);
+ }
+
+ public boolean onKeyEvent(KeyEvent event) {
+ if ((event.getSource() & InputDevice.SOURCE_GAMEPAD) == 0
+ && (event.getSource() & InputDevice.SOURCE_JOYSTICK) == 0)
+ return false;
+
+ if (event.getRepeatCount() > 0)
+ return true;
+
+ boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
+ int keyCode = event.getKeyCode();
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_A: btnA = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_B: btnB = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_X: btnX = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_Y: btnY = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_L1: btnL1 = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_R1: btnR1 = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBL: btnL3 = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBR: btnR3 = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_MODE: btnMode = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_START: btnStart = pressed; break;
+ case KeyEvent.KEYCODE_BUTTON_SELECT: btnSelect = pressed; break;
+ default: return false;
+ }
+
+ logger.info("Button: {} {}", keyCodeName(keyCode), pressed ? "PRESSED" : "RELEASED");
+ publishState();
+ return true;
+ }
+
+ public boolean onMotionEvent(MotionEvent event) {
+ if ((event.getSource() & InputDevice.SOURCE_JOYSTICK) == 0)
+ return false;
+ if (event.getAction() != MotionEvent.ACTION_MOVE)
+ return false;
+
+ InputDevice device = event.getDevice();
+
+ leftX = getCenteredAxis(event, device, MotionEvent.AXIS_X);
+ leftY = getCenteredAxis(event, device, MotionEvent.AXIS_Y);
+ rightX = getCenteredAxis(event, device, MotionEvent.AXIS_Z);
+ rightY = getCenteredAxis(event, device, MotionEvent.AXIS_RZ);
+
+ triggerL = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
+ triggerR = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
+
+ float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
+ float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
+ dpad = hatToDpad(hatX, hatY);
+
+ publishState();
+ return true;
+ }
+
+ private void publishState() {
+ output.setData(
+ btnA, btnB, btnX, btnY,
+ btnL1, btnR1, triggerL, triggerR,
+ btnL3, btnR3,
+ btnMode, btnStart, btnSelect,
+ dpad,
+ leftX, leftY, rightX, rightY
+ );
+ }
+
+ private static float getCenteredAxis(MotionEvent event, InputDevice device, int axis) {
+ InputDevice.MotionRange range = device.getMotionRange(axis, event.getSource());
+ if (range != null) {
+ float flat = range.getFlat();
+ float value = event.getAxisValue(axis);
+ if (Math.abs(value) > flat)
+ return value;
+ }
+ return 0;
+ }
+
+ private static String hatToDpad(float hatX, float hatY) {
+ boolean left = Float.compare(hatX, -1.0f) == 0;
+ boolean right = Float.compare(hatX, 1.0f) == 0;
+ boolean up = Float.compare(hatY, -1.0f) == 0;
+ boolean down = Float.compare(hatY, 1.0f) == 0;
+
+ if (up && left) return "UP_LEFT";
+ if (up && right) return "UP_RIGHT";
+ if (down && left) return "DOWN_LEFT";
+ if (down && right) return "DOWN_RIGHT";
+ if (up) return "UP";
+ if (down) return "DOWN";
+ if (left) return "LEFT";
+ if (right) return "RIGHT";
+ return "NONE";
+ }
+
+ private static String keyCodeName(int keyCode) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_A: return "A";
+ case KeyEvent.KEYCODE_BUTTON_B: return "B";
+ case KeyEvent.KEYCODE_BUTTON_X: return "X";
+ case KeyEvent.KEYCODE_BUTTON_Y: return "Y";
+ case KeyEvent.KEYCODE_BUTTON_L1: return "L1";
+ case KeyEvent.KEYCODE_BUTTON_R1: return "R1";
+ case KeyEvent.KEYCODE_BUTTON_THUMBL: return "L3";
+ case KeyEvent.KEYCODE_BUTTON_THUMBR: return "R3";
+ case KeyEvent.KEYCODE_BUTTON_MODE: return "MODE";
+ case KeyEvent.KEYCODE_BUTTON_START: return "START";
+ case KeyEvent.KEYCODE_BUTTON_SELECT: return "SELECT";
+ default: return "KEY_" + keyCode;
+ }
+ }
+
+ @Override
+ public void onInputDeviceAdded(int deviceId) {
+ InputDevice device = inputManager.getInputDevice(deviceId);
+ if (device != null && isGamepad(device)) {
+ controllerDeviceId = deviceId;
+ logger.info("Controller connected: {} (id={})", device.getName(), deviceId);
+ }
+ }
+
+ @Override
+ public void onInputDeviceRemoved(int deviceId) {
+ if (deviceId == controllerDeviceId) {
+ logger.info("Controller disconnected (id={})", deviceId);
+ controllerDeviceId = -1;
+ }
+ }
+
+ @Override
+ public void onInputDeviceChanged(int deviceId) {
+ logger.debug("Input device changed: {}", deviceId);
+ }
+
+ @Override
+ public void doStop() {
+ if (inputManager != null) {
+ inputManager.unregisterInputDeviceListener(this);
+ }
+
+ if (eventThread != null) {
+ eventThread.quitSafely();
+ eventThread = null;
+ }
+
+ eventHandler = null;
+ logger.info("Controller sensor stopped");
+ }
+
+ @Override
+ public boolean isConnected() {
+ return controllerDeviceId >= 0;
+ }
+}
diff --git a/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerOutput.java b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerOutput.java
new file mode 100644
index 00000000..cf7dcb5a
--- /dev/null
+++ b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/ControllerOutput.java
@@ -0,0 +1,201 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.controller;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+
+
+/**
+ * Single unified output for gamepad controller state:
+ * buttons, triggers, joystick axes, and D-Pad.
+ *
+ * @author Kalyn Stricklin
+ * @since 05/26/2024
+ */
+public class ControllerOutput extends AbstractSensorOutput
+{
+ DataComponent dataStruct;
+ DataEncoding dataEncoding;
+ private static final String SENSOR_OUTPUT_NAME = "controller";
+ private static final String SENSOR_OUTPUT_LABEL = "Gamepad Controller";
+ private static final Logger logger = LoggerFactory.getLogger(ControllerOutput.class);
+
+ protected ControllerOutput(ControllerDriver parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ SWEHelper fac = new SWEHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("GamepadState"))
+ .addField("time", fac.createTime()
+ .asSamplingTimeIsoUTC()
+ .label("Sampling Time")
+ .build())
+ .addField("btnA", fac.createBoolean()
+ .label("A Button")
+ .definition(SWEHelper.getPropertyUri("ButtonA"))
+ .build())
+ .addField("btnB", fac.createBoolean()
+ .label("B Button")
+ .definition(SWEHelper.getPropertyUri("ButtonB"))
+ .build())
+ .addField("btnX", fac.createBoolean()
+ .label("X Button")
+ .definition(SWEHelper.getPropertyUri("ButtonX"))
+ .build())
+ .addField("btnY", fac.createBoolean()
+ .label("Y Button")
+ .definition(SWEHelper.getPropertyUri("ButtonY"))
+ .build())
+ .addField("btnL1", fac.createBoolean()
+ .label("Left Bumper")
+ .definition(SWEHelper.getPropertyUri("LeftBumper"))
+ .build())
+ .addField("btnR1", fac.createBoolean()
+ .label("Right Bumper")
+ .definition(SWEHelper.getPropertyUri("RightBumper"))
+ .build())
+ .addField("triggerL", fac.createQuantity()
+ .label("Left Trigger")
+ .definition(SWEHelper.getPropertyUri("LeftTrigger"))
+ .build())
+ .addField("triggerR", fac.createQuantity()
+ .label("Right Trigger")
+ .definition(SWEHelper.getPropertyUri("RightTrigger"))
+ .build())
+ .addField("btnL3", fac.createBoolean()
+ .label("Left Stick Click")
+ .definition(SWEHelper.getPropertyUri("LeftStickClick"))
+ .build())
+ .addField("btnR3", fac.createBoolean()
+ .label("Right Stick Click")
+ .definition(SWEHelper.getPropertyUri("RightStickClick"))
+ .build())
+ .addField("btnMode", fac.createBoolean()
+ .label("Mode Button")
+ .definition(SWEHelper.getPropertyUri("ModeButton"))
+ .build())
+ .addField("btnStart", fac.createBoolean()
+ .label("Start Button")
+ .definition(SWEHelper.getPropertyUri("StartButton"))
+ .build())
+ .addField("btnSelect", fac.createBoolean()
+ .label("Select Button")
+ .definition(SWEHelper.getPropertyUri("SelectButton"))
+ .build())
+ .addField("dpad", fac.createCategory()
+ .label("D-Pad Direction")
+ .definition(SWEHelper.getPropertyUri("DPadDirection"))
+ .addAllowedValues("NONE", "UP", "UP_RIGHT", "RIGHT", "DOWN_RIGHT",
+ "DOWN", "DOWN_LEFT", "LEFT", "UP_LEFT")
+ .build())
+ .addField("leftStickX", fac.createQuantity()
+ .label("Left Stick X")
+ .definition(SWEHelper.getPropertyUri("LeftStickX"))
+ .addAllowedInterval(-1.0, 1.0)
+ .build())
+ .addField("leftStickY", fac.createQuantity()
+ .label("Left Stick Y")
+ .definition(SWEHelper.getPropertyUri("LeftStickY"))
+ .build())
+ .addField("rightStickX", fac.createQuantity()
+ .label("Right Stick X")
+ .definition(SWEHelper.getPropertyUri("RightStickX"))
+ .addAllowedInterval(-1.0, 1.0)
+ .build())
+ .addField("rightStickY", fac.createQuantity()
+ .label("Right Stick Y")
+ .definition(SWEHelper.getPropertyUri("RightStickY"))
+ .build())
+
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+ public void setData(boolean a, boolean b, boolean x, boolean y,
+ boolean l1, boolean r1, float triggerL, float triggerR,
+ boolean l3, boolean r3,
+ boolean mode, boolean start, boolean select,
+ String dpad,
+ float leftX, float leftY, float rightX, float rightY) {
+
+ DataBlock dataBlock;
+ if (latestRecord == null)
+ dataBlock = dataStruct.createDataBlock();
+ else
+ dataBlock = latestRecord.renew();
+
+ int idx = 0;
+ dataBlock.setDoubleValue(idx++, System.currentTimeMillis() / 1000d);
+
+ dataBlock.setBooleanValue(idx++, a);
+ dataBlock.setBooleanValue(idx++, b);
+ dataBlock.setBooleanValue(idx++, x);
+ dataBlock.setBooleanValue(idx++, y);
+
+ dataBlock.setBooleanValue(idx++, l1);
+ dataBlock.setBooleanValue(idx++, r1);
+
+ dataBlock.setDoubleValue(idx++, triggerL);
+ dataBlock.setDoubleValue(idx++, triggerR);
+
+ dataBlock.setBooleanValue(idx++, l3);
+ dataBlock.setBooleanValue(idx++, r3);
+
+ dataBlock.setBooleanValue(idx++, mode);
+ dataBlock.setBooleanValue(idx++, start);
+ dataBlock.setBooleanValue(idx++, select);
+
+ dataBlock.setStringValue(idx++, dpad);
+
+ dataBlock.setDoubleValue(idx++, leftX);
+ dataBlock.setDoubleValue(idx++, leftY);
+ dataBlock.setDoubleValue(idx++, rightX);
+ dataBlock.setDoubleValue(idx++, rightY);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 1;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+}
diff --git a/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/Descriptor.java b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/Descriptor.java
new file mode 100644
index 00000000..e501a9f9
--- /dev/null
+++ b/sensorhub-android-controller/src/main/java/org/sensorhub/impl/sensor/controller/Descriptor.java
@@ -0,0 +1,75 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.controller;
+
+import org.sensorhub.api.module.IModule;
+import org.sensorhub.api.module.IModuleProvider;
+import org.sensorhub.api.module.ModuleConfig;
+
+
+/**
+ *
+ * Descriptor of Android Controller driver module for automatic discovery
+ * by the ModuleRegistry
+ *
+ *
+ * @author Kalyn Stricklin
+ * @since 05/26/2024
+ */
+public class Descriptor implements IModuleProvider
+{
+
+ @Override
+ public String getModuleName()
+ {
+ return "Android Controller Driver";
+ }
+
+
+ @Override
+ public String getModuleDescription()
+ {
+ return "Driver supporting Android Controllers";
+ }
+
+
+ @Override
+ public String getModuleVersion()
+ {
+ return "0.1";
+ }
+
+
+ @Override
+ public String getProviderName()
+ {
+ return "Botts Innovative Research, Inc.";
+ }
+
+
+ @Override
+ public Class extends IModule>> getModuleClass()
+ {
+ return ControllerDriver.class;
+ }
+
+
+ @Override
+ public Class extends ModuleConfig> getModuleConfigClass()
+ {
+ return ControllerConfig.class;
+ }
+
+}
diff --git a/sensorhub-android-controller/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider b/sensorhub-android-controller/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
new file mode 100644
index 00000000..3bd1f5f8
--- /dev/null
+++ b/sensorhub-android-controller/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
@@ -0,0 +1 @@
+org.sensorhub.impl.sensor.controller.Descriptor
\ No newline at end of file
diff --git a/sensorhub-android-controller/src/test/java/empty b/sensorhub-android-controller/src/test/java/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-android-controller/src/test/resources/empty b/sensorhub-android-controller/src/test/resources/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-android-lib/build.gradle b/sensorhub-android-lib/build.gradle
index 00a387f2..d53b422d 100644
--- a/sensorhub-android-lib/build.gradle
+++ b/sensorhub-android-lib/build.gradle
@@ -9,6 +9,7 @@ dependencies {
api project(':sensorhub-driver-angelsensor')
api project(':sensorhub-driver-android')
api project(':sensorhub-driver-kestrel')
+ api project(':sensorhub-android-garmin')
// api project(':sensorhub-driver-flirone')
// api project(':sensorhub-client-consys-okhttp')
// api project(':sensorhub-android-flirone')
diff --git a/sensorhub-android-lib/res/values-v11/styles.xml b/sensorhub-android-lib/res/values-v11/styles.xml
deleted file mode 100644
index 3c02242a..00000000
--- a/sensorhub-android-lib/res/values-v11/styles.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/sensorhub-android-lib/res/values-v14/styles.xml b/sensorhub-android-lib/res/values-v14/styles.xml
deleted file mode 100644
index a91fd037..00000000
--- a/sensorhub-android-lib/res/values-v14/styles.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
diff --git a/sensorhub-android-polar/build.gradle b/sensorhub-android-polar/build.gradle
index c8fac684..bbc4c9d2 100644
--- a/sensorhub-android-polar/build.gradle
+++ b/sensorhub-android-polar/build.gradle
@@ -2,7 +2,7 @@ apply plugin: 'com.android.library'
description = 'Polar HeartRate Monitors'
ext.details = 'Driver for Polar H0/H10 HeartRate Monitors'
-version = '2.0.1'
+version = '3.0.0'
dependencies {
//api 'org.sensorhub:sensorhub-core:' + oshCoreVersion
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/AccelerometerOutput.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/AccelerometerOutput.java
new file mode 100644
index 00000000..9f4fcf9d
--- /dev/null
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/AccelerometerOutput.java
@@ -0,0 +1,99 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.polar;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+import net.opengis.swe.v20.DataRecord;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.helper.GeoPosHelper;
+
+/**
+ * Output for 3-axis accelerometer data from Polar H10.
+ * Streams motion data at configurable rates (25/50/100/200 Hz).
+ *
+ * @author Kalyn Stricklin
+ * @since 2025
+ */
+public class AccelerometerOutput extends AbstractSensorOutput {
+
+ private static final String SENSOR_OUTPUT_NAME = "accelerometer";
+ private static final String SENSOR_OUTPUT_LABEL = "Accelerometer Output";
+ private static final Logger logger = LoggerFactory.getLogger(AccelerometerOutput.class);
+
+ private static final double MG_TO_MS2 = 9.80665 / 1000.0;
+
+ DataRecord dataStruct;
+ DataEncoding dataEncoding;
+
+ protected AccelerometerOutput(Polar parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ GeoPosHelper fac = new GeoPosHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .description("3-axis accelerometer data from Polar H10")
+ .addField("samplingTime", fac.createTime()
+ .asSamplingTimeIsoUTC()
+ .label("Sample Time"))
+ .addField("acceleration", fac.createAccelerationVector("m/s2"))
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 1.0 / 50.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+
+ /**
+ * @param xMg X-axis acceleration in milligravity (mG)
+ * @param yMg Y-axis acceleration in milligravity (mG)
+ * @param zMg Z-axis acceleration in milligravity (mG)
+ */
+ public void setData(int xMg, int yMg, int zMg) {
+ DataBlock dataBlock = dataStruct.createDataBlock();
+
+ dataBlock.setDoubleValue(0, System.currentTimeMillis() / 1000d);
+ dataBlock.setDoubleValue(1, xMg * MG_TO_MS2);
+ dataBlock.setDoubleValue(2, yMg * MG_TO_MS2);
+ dataBlock.setDoubleValue(3, zMg * MG_TO_MS2);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+}
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/BatteryOutput.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/BatteryOutput.java
index 35126a8d..7d10f0b1 100644
--- a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/BatteryOutput.java
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/BatteryOutput.java
@@ -8,7 +8,8 @@
WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
for the specific language governing rights and limitations under the License.
- Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/ECGOutput.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/ECGOutput.java
new file mode 100644
index 00000000..38a26d12
--- /dev/null
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/ECGOutput.java
@@ -0,0 +1,94 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.polar;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+import net.opengis.swe.v20.DataRecord;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+
+/**
+ * Output for raw ECG waveform data from Polar H10.
+ * Streams electrocardiogram voltage samples at 130 Hz.
+ *
+ * @author Kalyn Stricklin
+ * @since 2025
+ */
+public class ECGOutput extends AbstractSensorOutput {
+
+ private static final String SENSOR_OUTPUT_NAME = "ecg";
+ private static final String SENSOR_OUTPUT_LABEL = "ECG Output";
+ private static final Logger logger = LoggerFactory.getLogger(ECGOutput.class);
+
+ DataRecord dataStruct;
+ DataEncoding dataEncoding;
+
+ protected ECGOutput(Polar parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ SWEHelper fac = new SWEHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("ECGWaveform"))
+ .description("Raw electrocardiogram waveform from Polar H10")
+ .addField("samplingTime", fac.createTime().asSamplingTimeIsoUTC())
+ .addField("voltage", fac.createQuantity()
+ .label("ECG Voltage")
+ .definition(SWEHelper.getPropertyUri("Voltage"))
+ .description("ECG voltage sample")
+ .uom("uV")
+ .build())
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 1.0 / 130.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+
+ public void setData(int voltageUv) {
+ DataBlock dataBlock = dataStruct.createDataBlock();
+
+ dataBlock.setDoubleValue(0, System.currentTimeMillis() / 1000d);
+ dataBlock.setIntValue(1, voltageUv);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+}
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/HeartRateOutput.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/HeartRateOutput.java
index 71748029..18dda79f 100644
--- a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/HeartRateOutput.java
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/HeartRateOutput.java
@@ -8,7 +8,8 @@
WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
for the specific language governing rights and limitations under the License.
- Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PPIOutput.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PPIOutput.java
new file mode 100644
index 00000000..751ad7e3
--- /dev/null
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PPIOutput.java
@@ -0,0 +1,113 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.polar;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+import net.opengis.swe.v20.DataRecord;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+
+/**
+ * Output for Pulse-to-Pulse Interval (PPI) data from Polar devices.
+ * Provides beat-to-beat intervals for heart rate variability (HRV) analysis.
+ *
+ * @author Kalyn Stricklin
+ * @since 2025
+ */
+public class PPIOutput extends AbstractSensorOutput {
+
+ private static final String SENSOR_OUTPUT_NAME = "ppi";
+ private static final String SENSOR_OUTPUT_LABEL = "PPI / RR Interval Output";
+ private static final Logger logger = LoggerFactory.getLogger(PPIOutput.class);
+
+ DataRecord dataStruct;
+ DataEncoding dataEncoding;
+
+ protected PPIOutput(Polar parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ SWEHelper fac = new SWEHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("PulseToPulseInterval"))
+ .description("Beat-to-beat pulse interval data for HRV analysis")
+ .addField("samplingTime", fac.createTime().asSamplingTimeIsoUTC())
+ .addField("ppiInterval", fac.createQuantity()
+ .label("PPI Interval")
+ .definition(SWEHelper.getPropertyUri("PulseToPulseInterval"))
+ .description("Pulse-to-pulse interval (RR interval)")
+ .uom("ms")
+ .build())
+ .addField("heartRate", fac.createQuantity()
+ .label("Heart Rate")
+ .definition(SWEHelper.getPropertyUri("HeartRate"))
+ .description("Heart rate derived from PPI")
+ .uom("1/min")
+ .build())
+ .addField("skinContactSupported", fac.createBoolean()
+ .label("Skin Contact Supported")
+ .definition(SWEHelper.getPropertyUri("SkinContactSupported"))
+ .description("Whether the device supports skin contact detection")
+ .build())
+ .addField("skinContactStatus", fac.createBoolean()
+ .label("Skin Contact Status")
+ .definition(SWEHelper.getPropertyUri("SkinContactStatus"))
+ .description("Whether the device has skin contact")
+ .build())
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 1.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+
+ public void setData(int ppiMs, int hr, boolean skinContactSupported, boolean skinContactStatus) {
+ DataBlock dataBlock = dataStruct.createDataBlock();
+
+ dataBlock.setDoubleValue(0, System.currentTimeMillis() / 1000d);
+ dataBlock.setIntValue(1, ppiMs);
+ dataBlock.setIntValue(2, hr);
+ dataBlock.setBooleanValue(3, skinContactSupported);
+ dataBlock.setBooleanValue(4, skinContactStatus);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+}
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/Polar.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/Polar.java
index c9fd2821..cc058183 100644
--- a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/Polar.java
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/Polar.java
@@ -8,43 +8,33 @@
WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
for the specific language governing rights and limitations under the License.
- Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
******************************* END LICENSE BLOCK ***************************/
package org.sensorhub.impl.sensor.polar;
-import android.Manifest;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.content.Context;
-import android.content.pm.PackageManager;
import android.os.Build;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.core.app.ActivityCompat;
-
import com.polar.sdk.api.PolarBleApi;
import com.polar.sdk.api.PolarBleApiCallback;
import com.polar.sdk.api.PolarBleApiDefaultImpl;
+import com.polar.sdk.api.model.PolarAccelerometerData;
import com.polar.sdk.api.model.PolarDeviceInfo;
+import com.polar.sdk.api.model.PolarEcgData;
import com.polar.sdk.api.model.PolarHrData;
+import com.polar.sdk.api.model.PolarPpiData;
import net.opengis.sensorml.v20.PhysicalComponent;
import org.sensorhub.android.SensorHubService;
-import org.sensorhub.android.comm.ble.BleConfig;
-import org.sensorhub.android.comm.ble.BleNetwork;
-import org.sensorhub.api.comm.ble.GattCallback;
-import org.sensorhub.api.comm.ble.IGattCharacteristic;
-import org.sensorhub.api.comm.ble.IGattClient;
-import org.sensorhub.api.comm.ble.IGattField;
-import org.sensorhub.api.comm.ble.IGattService;
-import org.sensorhub.api.common.SensorHubException;
import org.sensorhub.api.sensor.SensorException;
import org.sensorhub.impl.sensor.AbstractSensorModule;
import org.sensorhub.impl.sensor.android.SensorMLBuilder;
@@ -54,10 +44,24 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import java.util.UUID;
+import io.reactivex.rxjava3.disposables.CompositeDisposable;
+import io.reactivex.rxjava3.disposables.Disposable;
+
/**
+ * OpenSensorHub driver for Polar H9/H10 heart rate monitors.
+ *
+ * Uses the Polar BLE SDK for all communication, supporting:
+ *
+ * - Heart Rate (all models) via SDK callback
+ * - Battery Level (all models) via SDK callback
+ * - PPI / RR Intervals (all models) via SDK streaming
+ * - ECG waveform at 130 Hz (H10 only) via SDK streaming
+ * - 3-axis accelerometer (H10 only) via SDK streaming
+ *
*
* @author Kalyn Stricklin
* @since Jan 13, 2023
@@ -68,24 +72,19 @@ public class Polar extends AbstractSensorModule {
private final ArrayList smlComponents;
private final SensorMLBuilder smlBuilder;
static final Logger logger = LoggerFactory.getLogger(Polar.class.getSimpleName());
- private static final UUID HEARTRATE_CHARACTERISTIC_UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb");
- private static final UUID BATTERY_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
- private static final UUID DEVICE_INFORMATION_SERVICE = UUID.fromString("0000180A-0000-1000-8000-00805F9B34FB");
- private static final UUID HEART_RATE_SERVICE = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb");
- private static final UUID BATTERY_SERVICE = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb");
private Context context;
private BluetoothAdapter btAdapter;
- BatteryOutput batteryOutput;
+
HeartRateOutput heartRateOutput;
- private HandlerThread eventThread;
- PolarBleApi api;
+ BatteryOutput batteryOutput;
+ PPIOutput ppiOutput;
+ ECGOutput ecgOutput;
+ AccelerometerOutput accelerometerOutput;
- private boolean btConnected = false;
- private BleNetwork bleNetwork;
- private IGattClient gattClient;
- private IGattCharacteristic heartRateChar;
- private IGattCharacteristic batteryLevelChar;
+ PolarBleApi api;
+ private boolean deviceConnected = false;
+ private final CompositeDisposable disposables = new CompositeDisposable();
public Polar() {
this.smlComponents = new ArrayList();
@@ -94,171 +93,141 @@ public Polar() {
@Override
public void doInit() {
- logger.info("Initializing Polar heart monitor sensor");
+ logger.info("Initializing Polar sensor driver");
this.xmlID = "POLAR_" + Build.SERIAL;
this.uniqueID = UID_PREFIX + config.getUidWithExt();
context = SensorHubService.getContext();
- PolarApiCallback();
-
- BleConfig bleConfig = new BleConfig();
- bleConfig.androidContext = context;
-
- bleNetwork = new BleNetwork();
- try {
- bleNetwork.init(bleConfig);
- bleNetwork.start();
- } catch (SensorHubException e) {
- throw new RuntimeException(e);
- }
+ initPolarApi();
heartRateOutput = new HeartRateOutput(this);
heartRateOutput.doInit();
addOutput(heartRateOutput, false);
+
+ batteryOutput = new BatteryOutput(this);
+ batteryOutput.doInit();
+ addOutput(batteryOutput, false);
+
+ if (config.enablePpi) {
+ ppiOutput = new PPIOutput(this);
+ ppiOutput.doInit();
+ addOutput(ppiOutput, false);
+ }
+
+ // H10 only
+ if (config.enableEcg) {
+ ecgOutput = new ECGOutput(this);
+ ecgOutput.doInit();
+ addOutput(ecgOutput, false);
+ }
+
+ // H10 only
+ if (config.enableAccelerometer) {
+ accelerometerOutput = new AccelerometerOutput(this);
+ accelerometerOutput.doInit();
+ addOutput(accelerometerOutput, false);
+ }
}
@Override
public void doStart() throws SensorException {
- if (bleNetwork == null) {
- logger.error("BLE network is not initialized");
+ if (config.deviceId == null || config.deviceId.isEmpty()) {
+ throw new SensorException("Polar device ID is required (printed on the device)");
}
- if (context.checkSelfPermission(Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_DENIED) {
- ActivityCompat.requestPermissions((Activity) context, new String[]{Manifest.permission.BLUETOOTH}, 1);
+ try {
+ api.connectToDevice(config.deviceId);
+ logger.info("Connecting to Polar device: {}", config.deviceId);
+ } catch (Exception e) {
+ throw new SensorException("Failed to connect to Polar device: " + config.deviceId, e);
}
-
- bleNetwork.connectGatt(config.device_name, gattCallback);
-
- eventThread = new HandlerThread("PolarMonitorThread");
- eventThread.start();
- Handler eventHandler = new Handler(eventThread.getLooper());
}
@Override
public void doStop() {
- if (gattClient != null) {
- gattClient.disconnect();
- gattClient.close();
- gattClient = null;
- }
+ disposables.clear();
- if (bleNetwork != null) {
+ if (api != null && config.deviceId != null) {
try {
- bleNetwork.stop();
- } catch (SensorHubException e) {
- logger.error("Error stopping BLE network");
+ api.disconnectFromDevice(config.deviceId);
+ } catch (Exception e) {
+ logger.error("Error disconnecting from Polar device", e);
}
- bleNetwork = null;
}
+
+ deviceConnected = false;
}
@Override
public boolean isConnected() {
- return true;
+ return deviceConnected;
}
-
- private GattCallback gattCallback = new GattCallback() {
- @Override
- public void onConnected(IGattClient gatt, int status) {
- gattClient = gatt;
- btConnected = true;
-
- logger.info("Polar HR Monitor is connected");
-
- gattClient.discoverServices();
-
+ private void startStreaming(Set extends PolarBleApi.PolarDeviceDataType> availableTypes) {
+ String deviceId = config.deviceId;
+
+ if (ppiOutput != null && availableTypes.contains(PolarBleApi.PolarDeviceDataType.PPI)) {
+ Disposable ppiDisposable = api.startPpiStreaming(deviceId)
+ .subscribe(
+ ppiData -> {
+ for (PolarPpiData.PolarPpiSample sample : ppiData.getSamples()) {
+ ppiOutput.setData(
+ sample.getPpi(),
+ sample.getHr(),
+ sample.getSkinContactSupported(),
+ sample.getSkinContactStatus()
+ );
+ }
+ },
+ error -> logger.error("PPI streaming error", error)
+ );
+ disposables.add(ppiDisposable);
+ logger.info("PPI streaming started");
}
- @Override
- public void onDisconnected(IGattClient gatt, int status) {
- btConnected = false;
- logger.info("Polar HR Monitor is disconnected");
+ // ECG streaming H10 only
+ if (ecgOutput != null && availableTypes.contains(PolarBleApi.PolarDeviceDataType.ECG)) {
+ Disposable ecgDisposable = api.requestStreamSettings(deviceId, PolarBleApi.PolarDeviceDataType.ECG)
+ .flatMapPublisher(settings -> api.startEcgStreaming(deviceId, settings))
+ .subscribe(
+ ecgData -> {
+ for (PolarEcgData.PolarEcgDataSample sample : ecgData.getSamples()) {
+ ecgOutput.setData(sample.getVoltage());
+ }
+ },
+ error -> logger.error("ECG streaming error", error)
+ );
+ disposables.add(ecgDisposable);
+ logger.info("ECG streaming started");
}
-
- @Override
- public void onServicesDiscovered(IGattClient gatt, int status) {
- IGattService hrService = null;
- IGattService batteryService = null;
-
- for (IGattService service : gattClient.getServices()) {
-
- UUID uuid = service.getType();
- if (uuid.equals(HEART_RATE_SERVICE)) {
- hrService = service;
- } else if (uuid.equals(BATTERY_SERVICE)) {
- batteryService = service;
- }
- }
-
- for (IGattCharacteristic characteristic : hrService.getCharacteristics()) {
- UUID uuid = characteristic.getType();
-
- if (uuid.equals(HEARTRATE_CHARACTERISTIC_UUID)) {
- heartRateChar = characteristic;
- }
- }
-
- for (IGattCharacteristic characteristic : batteryService.getCharacteristics()) {
- UUID uuid = characteristic.getType();
-
- if (uuid.equals(BATTERY_LEVEL_CHARACTERISTIC_UUID)) {
- batteryLevelChar = characteristic;
- }
- }
-
- if (heartRateChar != null) {
- gattClient.setCharacteristicNotification(heartRateChar, true);
- }
-
- if (batteryLevelChar != null) {
- gattClient.setCharacteristicNotification(batteryLevelChar, true);
- }
-
- if (batteryLevelChar != null) {
- gattClient.readCharacteristic(batteryLevelChar);
- }
- }
-
- @Override
- public void onCharacteristicChanged(IGattClient gatt, IGattField characteristic) {
-
- UUID uuid = characteristic.getType();
-
- if (uuid.equals(HEARTRATE_CHARACTERISTIC_UUID)) {
- byte[] data = characteristic.getValue().array();
- int hr = parseHeartRate(data);
- heartRateOutput.setData(hr);
- }
-
- if (uuid.equals(BATTERY_LEVEL_CHARACTERISTIC_UUID)) {
- byte[] data = characteristic.getValue().array();
- int battery = data[0] & 0xFF;
-// batteryOutput.setData(battery);
- }
+ // Accelerometer streaming H10 only
+ if (accelerometerOutput != null && availableTypes.contains(PolarBleApi.PolarDeviceDataType.ACC)) {
+ Disposable accDisposable = api.requestStreamSettings(deviceId, PolarBleApi.PolarDeviceDataType.ACC)
+ .flatMapPublisher(settings -> api.startAccStreaming(deviceId, settings))
+ .subscribe(
+ accData -> {
+ for (PolarAccelerometerData.PolarAccelerometerDataSample sample : accData.getSamples()) {
+ accelerometerOutput.setData(
+ sample.getX(),
+ sample.getY(),
+ sample.getZ()
+ );
+ }
+ },
+ error -> logger.error("Accelerometer streaming error", error)
+ );
+ disposables.add(accDisposable);
+ logger.info("Accelerometer streaming started");
}
- };
-
- private int parseHeartRate(byte[] data) {
- if (data == null || data.length == 0)
- return 0;
-
- int heartRate = 0;
- int format = data[0] & 0x01;
-
- if (format == 0) {
- heartRate = data[1] & 0xFF;
- } else {
- heartRate = (data[1] & 0xFF) | ((data[2] & 0xFF) << 8);
- }
-
- return heartRate;
}
- public void PolarApiCallback() {
- //set all desired features
+ /**
+ * Initializes the Polar BLE SDK with all supported features and sets up callbacks
+ * for HR, battery, device connection
+ */
+ private void initPolarApi() {
Set features = new HashSet<>(Arrays.asList(
PolarBleApi.PolarBleSdkFeature.FEATURE_HR,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SDK_MODE,
@@ -268,78 +237,87 @@ public void PolarApiCallback() {
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_OFFLINE_RECORDING,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ONLINE_STREAMING,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP
-
));
+
api = PolarBleApiDefaultImpl.defaultImplementation(context.getApplicationContext(), features);
api.setApiCallback(new PolarBleApiCallback() {
+
@Override
public void batteryLevelReceived(@NonNull String identifier, int level) {
super.batteryLevelReceived(identifier, level);
- logger.debug("Battery: " + level);
+ logger.debug("Battery level: {}%", level);
+ if (batteryOutput != null) {
+ batteryOutput.setData(level);
+ }
}
@Override
public void blePowerStateChanged(boolean powered) {
super.blePowerStateChanged(powered);
if (powered) {
- context = SensorHubService.getContext();
- final BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Activity.BLUETOOTH_SERVICE);
+ BluetoothManager bluetoothManager =
+ (BluetoothManager) context.getSystemService(Activity.BLUETOOTH_SERVICE);
btAdapter = bluetoothManager.getAdapter();
if (btAdapter == null || !btAdapter.isEnabled()) {
- Toast.makeText(context, "bluetooth adapter is null or not enabled", Toast.LENGTH_LONG).show();
+ logger.warn("Bluetooth adapter is null or not enabled");
}
} else {
- Toast.makeText(context, "Powered is false, no bluetooth connection", Toast.LENGTH_LONG).show();
+ logger.warn("Bluetooth powered off");
}
- logger.debug("Bluetooth state changed " + powered);
}
@Override
- public void bleSdkFeatureReady(@NonNull String identifier, @NonNull PolarBleApi.PolarBleSdkFeature feature) {
+ public void bleSdkFeatureReady(@NonNull String identifier,
+ @NonNull PolarBleApi.PolarBleSdkFeature feature) {
super.bleSdkFeatureReady(identifier, feature);
+ logger.info("SDK feature ready: {}", feature);
}
@Override
public void deviceConnected(@NonNull PolarDeviceInfo polarDeviceInfo) {
super.deviceConnected(polarDeviceInfo);
- Toast.makeText(context, "Connected to polar device", Toast.LENGTH_SHORT).show();
- isConnected();
+ deviceConnected = true;
+ logger.info("Connected to Polar device: {}", polarDeviceInfo.getDeviceId());
}
@Override
public void deviceConnecting(@NonNull PolarDeviceInfo polarDeviceInfo) {
super.deviceConnecting(polarDeviceInfo);
- Toast.makeText(context, "Connecting to polar device", Toast.LENGTH_SHORT).show();
- logger.debug("connecting" + polarDeviceInfo.getDeviceId());
+ logger.info("Connecting to Polar device: {}", polarDeviceInfo.getDeviceId());
}
@Override
public void deviceDisconnected(@NonNull PolarDeviceInfo polarDeviceInfo) {
super.deviceDisconnected(polarDeviceInfo);
- Toast.makeText(context, "Disconnected from polar device", Toast.LENGTH_SHORT).show();
- logger.debug("Device disconnected " + polarDeviceInfo.getDeviceId());
+ deviceConnected = false;
+ logger.info("Disconnected from Polar device: {}", polarDeviceInfo.getDeviceId());
}
@Override
- public void disInformationReceived(@NonNull String identifier, @NonNull UUID uuid, @NonNull String value) {
+ public void disInformationReceived(@NonNull String identifier,
+ @NonNull UUID uuid, @NonNull String value) {
super.disInformationReceived(identifier, uuid, value);
+ logger.debug("Device info - {}: {}", uuid, value);
}
@Override
public void hrFeatureReady(@NonNull String identifier) {
super.hrFeatureReady(identifier);
- Toast.makeText(context, "Heart Rate feature ready for polar device", Toast.LENGTH_SHORT).show();
+ logger.info("Heart Rate feature ready");
}
@Override
- public void hrNotificationReceived(@NonNull String identifier, @NonNull PolarHrData.PolarHrSample data) {
+ public void hrNotificationReceived(@NonNull String identifier,
+ @NonNull PolarHrData.PolarHrSample data) {
super.hrNotificationReceived(identifier, data);
- logger.debug("HR notifications received");
- int currentHR = data.getHr();
- logger.debug("Current HR: " + currentHR);
- int restingHR = data.getRrsMs().get(0);
- logger.debug("RRS: " + restingHR);
+ int hr = data.getHr();
+ heartRateOutput.setData(hr);
+
+ List rrsMs = data.getRrsMs();
+ if (rrsMs != null && !rrsMs.isEmpty()) {
+ logger.trace("HR: {} bpm, RR intervals: {}", hr, rrsMs);
+ }
}
@Override
@@ -353,11 +331,12 @@ public void sdkModeFeatureAvailable(@NonNull String identifier) {
}
@Override
- public void streamingFeaturesReady(@NonNull String identifier, @NonNull Set extends PolarBleApi.PolarDeviceDataType> features) {
+ public void streamingFeaturesReady(@NonNull String identifier,
+ @NonNull Set extends PolarBleApi.PolarDeviceDataType> features) {
super.streamingFeaturesReady(identifier, features);
+ logger.info("Streaming features ready: {}", features);
+ startStreaming(features);
}
-
});
}
-
}
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarConfig.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarConfig.java
index 846e0f2a..e8f576b1 100644
--- a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarConfig.java
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarConfig.java
@@ -1,16 +1,17 @@
/***************************** BEGIN LICENSE BLOCK ***************************
-The contents of this file are subject to the Mozilla Public License, v. 2.0.
-If a copy of the MPL was not distributed with this file, You can obtain one
-at http://mozilla.org/MPL/2.0/.
-
-Software distributed under the License is distributed on an "AS IS" basis,
-WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-for the specific language governing rights and limitations under the License.
-
-Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
-
-******************************* END LICENSE BLOCK ***************************/
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
package org.sensorhub.impl.sensor.polar;
@@ -34,8 +35,16 @@ public PolarConfig()
this.moduleClass = Polar.class.getCanonicalName();
}
- public String device_name;
+ /** Polar device ID printed on the device (e.g. "A1B2C3D4") */
+ public String deviceId;
public String uid_extension;
+ public boolean enablePpi = true;
+
+ /** Enable ECG waveform streaming (H10 only) */
+ public boolean enableEcg = false;
+
+ /** Enable accelerometer streaming (H10 only) */
+ public boolean enableAccelerometer = false;
public static String getUid() {
Context context = SensorHubService.getContext();
diff --git a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarDescriptor.java b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarDescriptor.java
index df51544c..0a62108b 100644
--- a/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarDescriptor.java
+++ b/sensorhub-android-polar/src/main/java/org/sensorhub/impl/sensor/polar/PolarDescriptor.java
@@ -1,17 +1,17 @@
/***************************** BEGIN LICENSE BLOCK ***************************
-The contents of this file are subject to the Mozilla Public License, v. 2.0.
-If a copy of the MPL was not distributed with this file, You can obtain one
-at http://mozilla.org/MPL/2.0/.
-
-Software distributed under the License is distributed on an "AS IS" basis,
-WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-for the specific language governing rights and limitations under the License.
-
-Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
-
-******************************* END LICENSE BLOCK ***************************/
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
package org.sensorhub.impl.sensor.polar;
import org.sensorhub.api.module.IModule;
@@ -34,14 +34,14 @@ public class PolarDescriptor implements IModuleProvider
@Override
public String getModuleName()
{
- return "Polar Heart Rate Driver";
+ return "Polar H9/H10 Driver";
}
@Override
public String getModuleDescription()
{
- return "Driver supporting Polar H9 and H10 Heart Rate Sensors";
+ return "Driver for Polar H9/H10 sensors supporting HR, Battery, PPI, ECG, and Accelerometer via Polar SDK";
}
diff --git a/sensorhub-android-service/src/org/sensorhub/android/OkHttpClientWrapper.java b/sensorhub-android-service/src/org/sensorhub/android/OkHttpClientWrapper.java
index 3d4ea094..5e63652a 100644
--- a/sensorhub-android-service/src/org/sensorhub/android/OkHttpClientWrapper.java
+++ b/sensorhub-android-service/src/org/sensorhub/android/OkHttpClientWrapper.java
@@ -21,8 +21,7 @@
import com.google.gson.JsonSyntaxException;
import com.google.gson.stream.JsonReader;
-import org.sensorhub.impl.service.consys.client.ConSysApiClientConfig;
-import org.sensorhub.impl.service.consys.client.TokenHandler;
+import org.sensorhub.impl.service.consys.client.ITokenHandler;
import org.sensorhub.impl.service.consys.client.http.IHttpClient;
import org.sensorhub.impl.service.consys.resource.ResourceFormat;
@@ -35,14 +34,10 @@
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
-import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import okhttp3.Call;
import okhttp3.Callback;
-import okhttp3.ConnectionPool;
-import okhttp3.Credentials;
-import okhttp3.Dispatcher;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -53,27 +48,41 @@
public class OkHttpClientWrapper implements IHttpClient, Closeable
{
protected OkHttpClient http;
- protected TokenHandler tokenHandler;
+ protected ITokenHandler tokenHandler;
+ protected String username;
+ protected char[] password;
- public OkHttpClientWrapper() {
+ public OkHttpClientWrapper() {}
+ @Override
+ public void setUsername(String username) {
+ this.username = username;
+ rebuildHttpClient();
}
@Override
- public void setConfig(ConSysApiClientConfig config) {
- shutdownClient();
+ public void setPassword(char[] password) {
+ this.password = password;
+ rebuildHttpClient();
+ }
+
+ @Override
+ public void setTokenHandler(ITokenHandler tokenHandler) {
+ this.tokenHandler = tokenHandler;
+ }
- if (config.conSysOAuth.oAuthEnabled) {
- tokenHandler = new TokenHandler(config.conSysOAuth);
+ protected void rebuildHttpClient() {
+ OkHttpClient.Builder builder = new OkHttpClient.Builder();
+ if (username != null && !username.isEmpty()) {
+ var finalPwd = password != null ? new String(password) : "";
+ builder.authenticator((route, response) -> {
+ String credential = okhttp3.Credentials.basic(username, finalPwd);
+ return response.request().newBuilder()
+ .header("Authorization", credential)
+ .build();
+ });
}
- this.http = new OkHttpClient.Builder().authenticator((route, response) -> {
- final String finalPwd = config.conSys.password != null ? new String(config.conSys.password) : "";
-
- String credential = Credentials.basic(config.conSys.user, finalPwd);
- return response.request().newBuilder()
- .header(HttpHeaders.AUTHORIZATION, credential)
- .build();
- }).build();
+ this.http = builder.build();
}
@Override
diff --git a/sensorhub-android-service/src/org/sensorhub/android/SensorHubService.java b/sensorhub-android-service/src/org/sensorhub/android/SensorHubService.java
index 5aab62bb..9ec8354f 100644
--- a/sensorhub-android-service/src/org/sensorhub/android/SensorHubService.java
+++ b/sensorhub-android-service/src/org/sensorhub/android/SensorHubService.java
@@ -1,5 +1,6 @@
package org.sensorhub.android;
+import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -18,24 +19,31 @@
import android.os.IBinder;
import android.os.PowerManager;
import android.os.Process;
+import android.os.SystemClock;
+
+import com.ctc.wstx.stax.WstxInputFactory;
+import com.ctc.wstx.stax.WstxOutputFactory;
import org.sensorhub.api.common.SensorHubException;
import org.sensorhub.api.module.IModuleConfigRepository;
import org.sensorhub.impl.SensorHub;
import org.sensorhub.impl.SensorHubConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.vast.xml.XMLImplFinder;
import javax.xml.parsers.DocumentBuilderFactory;
public class SensorHubService extends Service
{
+ private static final Logger log = LoggerFactory.getLogger(SensorHubService.class);
final IBinder binder = new LocalBinder();
private HandlerThread msgThread;
private Handler msgHandler;
SensorHubAndroid sensorhub;
boolean hasVideo;
- static Context context;
- static SurfaceTexture videoTex;
+ private static Context appContext;
+ private static SurfaceTexture videoTex;
private PowerManager.WakeLock wakeLock;
private WifiManager.WifiLock wifiLock;
@@ -58,10 +66,8 @@ public void onCreate() {
try
{
- // keep handle to Android context so it can be retrieved by OSH components
- SensorHubService.context = getApplicationContext();
+ SensorHubService.appContext = getApplicationContext();
- // create video surface texture here so it's not destroyed when pausing the app
SensorHubService.videoTex = new SurfaceTexture(1);
SensorHubService.videoTex.detachFromGLContext();
@@ -69,8 +75,8 @@ public void onCreate() {
//Dexter.loadFromAssets(this.getApplicationContext(), "stax-api-1.0-2.dex");
// set default StAX implementation
- XMLImplFinder.setStaxInputFactory(com.ctc.wstx.stax.WstxInputFactory.class.newInstance());
- XMLImplFinder.setStaxOutputFactory(com.ctc.wstx.stax.WstxOutputFactory.class.newInstance());
+ XMLImplFinder.setStaxInputFactory(WstxInputFactory.class.newInstance());
+ XMLImplFinder.setStaxOutputFactory(WstxOutputFactory.class.newInstance());
// set default DOM implementation
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
@@ -82,12 +88,11 @@ public void onCreate() {
msgThread.start();
msgHandler = new Handler(msgThread.getLooper());
- // Start as foreground service with notification
startForegroundService();
}
catch (Exception e)
{
- e.printStackTrace();
+ log.error("Error: " + e.getMessage());
}
}
@@ -169,18 +174,30 @@ public synchronized void startSensorHub(final IModuleConfigRepository config, fi
this.hasVideo = hasVideo;
- // Acquire wake locks BEFORE starting the hub
+ if (hasVideo) {
+ if (videoTex != null) {
+ videoTex.release();
+ }
+ videoTex = new SurfaceTexture(1);
+ videoTex.detachFromGLContext();
+ }
+
acquireWakeLocks();
msgHandler.post(new Runnable() {
public void run() {
- // create and start sensorhub instance
sensorhub = new SensorHubAndroid(new SensorHubConfig(), config);
try {
sensorhub.start();
} catch (SensorHubException e) {
- e.printStackTrace();
- // Release locks if startup fails
+ log.error("Error starting SensorHub: " + e.getMessage());
+ try {
+ sensorhub.stop();
+ } catch (Exception ex) {
+ log.error("Error stopping failed SensorHub", ex);
+ }
+ sensorhub = null;
+ SensorHubService.this.hasVideo = false;
releaseWakeLocks();
}
}
@@ -203,7 +220,7 @@ private void acquireWakeLocks() {
.getSystemService(Context.WIFI_SERVICE);
if (wifiManager != null && wifiLock == null) {
wifiLock = wifiManager.createWifiLock(
- WifiManager.WIFI_MODE_FULL_HIGH_PERF,
+ WifiManager.WIFI_MODE_FULL_LOW_LATENCY,
"SensorHub::WiFiLock"
);
wifiLock.acquire();
@@ -226,32 +243,13 @@ private void releaseWakeLocks() {
public synchronized void stopSensorHub()
{
- if (sensorhub == null)
- return;
+ if (sensorhub != null) {
+ sensorhub.stop();
+ sensorhub = null;
+ }
this.hasVideo = false;
- final SensorHubAndroid hubToStop = sensorhub;
- sensorhub = null;
-
- final java.util.concurrent.CountDownLatch stopLatch = new java.util.concurrent.CountDownLatch(1);
-
- msgHandler.post(new Runnable() {
- public void run() {
- try {
- hubToStop.stop();
- } finally {
- stopLatch.countDown();
- }
- }
- });
-
- try {
- stopLatch.await(15, java.util.concurrent.TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
-
releaseWakeLocks();
}
@@ -272,10 +270,24 @@ public void onDestroy()
SensorHubService.videoTex.release();
SensorHubService.videoTex = null;
}
- SensorHubService.context = null;
super.onDestroy();
}
+ @Override
+ public void onTaskRemoved(Intent rootIntent) {
+ log.info("Task removed, scheduling restart");
+ Intent restartIntent = new Intent(getApplicationContext(), SensorHubService.class);
+ PendingIntent pendingIntent = PendingIntent.getService(
+ getApplicationContext(), 1, restartIntent,
+ PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE
+ );
+ AlarmManager alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE);
+ if (alarmManager != null) {
+ alarmManager.set(AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + 1000, pendingIntent);
+ }
+ super.onTaskRemoved(rootIntent);
+ }
@Override
public IBinder onBind(Intent intent)
@@ -304,6 +316,6 @@ public static SurfaceTexture getVideoTexture()
public static Context getContext()
{
- return context;
+ return appContext;
}
}
\ No newline at end of file
diff --git a/sensorhub-android-service/src/org/sensorhub/android/comm/BluetoothManager.java b/sensorhub-android-service/src/org/sensorhub/android/comm/BluetoothManager.java
index 9846a0d1..3d47b583 100644
--- a/sensorhub-android-service/src/org/sensorhub/android/comm/BluetoothManager.java
+++ b/sensorhub-android-service/src/org/sensorhub/android/comm/BluetoothManager.java
@@ -107,30 +107,36 @@ public void onReceive(Context context, Intent intent)
/**
- * Returns the first paired device whose name matches the given pattern
- * @param macAddress regular expression to match device names
+ * Returns the first paired device whose address or name matches the given identifier.
+ * Tries MAC address match first, then falls back to name matching (startsWith, case-insensitive).
+ * @param deviceId MAC address or device name to match
* @return first matching device
- * @throws IOException if a paired device with a matching name cannot be found
+ * @throws IOException if a paired device with a matching address or name cannot be found
*/
- public BluetoothDevice findDevice(String macAddress) throws IOException
+ public BluetoothDevice findDevice(String deviceId) throws IOException
{
BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
if(btAdapter == null || !btAdapter.isEnabled()) throw new IOException("Bluetooth is not available or not enabled");
+ // match by MAC address
for (BluetoothDevice dev: btAdapter.getBondedDevices())
{
- if (dev.getAddress() != null && dev.getAddress().startsWith(macAddress)) {
+ if (dev.getAddress() != null && dev.getAddress().equalsIgnoreCase(deviceId)) {
return dev;
}
-// if(dev.getName() != null && dev.getName().startsWith(deviceNameRegex)){
-// return dev;
-// }
-// if (dev.getName().matches(deviceNameRegex))
-// return dev;
}
-
- throw new IOException("Cannot find device " + macAddress);
+
+ // match by device name (case-insensitive startsWith)
+ String lowerDeviceId = deviceId.toLowerCase();
+ for (BluetoothDevice dev: btAdapter.getBondedDevices())
+ {
+ if (dev.getName() != null && dev.getName().toLowerCase().startsWith(lowerDeviceId)) {
+ return dev;
+ }
+ }
+
+ throw new IOException("Cannot find device " + deviceId);
}
diff --git a/sensorhub-android-service/src/org/sensorhub/android/comm/ble/BleNetwork.java b/sensorhub-android-service/src/org/sensorhub/android/comm/ble/BleNetwork.java
index 4e495896..3cc7b166 100644
--- a/sensorhub-android-service/src/org/sensorhub/android/comm/ble/BleNetwork.java
+++ b/sensorhub-android-service/src/org/sensorhub/android/comm/ble/BleNetwork.java
@@ -41,6 +41,11 @@
import android.bluetooth.le.ScanSettings;
import android.content.Context;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
public class BleNetwork extends AbstractModule implements IBleNetwork
@@ -253,12 +258,112 @@ public boolean startPairing(String address)
@Override
+ @SuppressLint("MissingPermission")
public void connectGatt(String address, GattCallback callback)
{
- BluetoothDevice btDevice = aBleAdapter.getRemoteDevice(address);
+ String resolvedAddress = resolveDeviceAddress(address);
+ BluetoothDevice btDevice = aBleAdapter.getRemoteDevice(resolvedAddress);
GattClientImpl client = new GattClientImpl(aContext, btDevice, callback);
client.connect();
- log.info("Connecting to BT device " + address + "...");
+ log.info("Connecting to BT device " + resolvedAddress + " (input: " + address + ")...");
+ }
+
+ private static final long BLE_NAME_SCAN_TIMEOUT_MS = 15000;
+
+ /**
+ * Resolves a device identifier to a MAC address.
+ * If the input is already a valid MAC address, returns it directly.
+ * Otherwise, searches bonded devices by name first, then falls back to
+ * a short BLE scan filtered by device name (for unbonded BLE devices like Kestrel).
+ */
+ @SuppressLint("MissingPermission")
+ private String resolveDeviceAddress(String deviceId)
+ {
+ // If it looks like a MAC address, use it directly
+ if (deviceId != null && deviceId.matches("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"))
+ return deviceId;
+
+ String lowerInput = deviceId != null ? deviceId.toLowerCase() : "";
+
+ // First: search bonded devices by name
+ if (aBleAdapter != null) {
+ for (BluetoothDevice dev : aBleAdapter.getBondedDevices()) {
+ String name = dev.getName();
+ if (name != null && name.toLowerCase().startsWith(lowerInput)) {
+ log.info("Resolved device name '{}' to bonded MAC {}", deviceId, dev.getAddress());
+ return dev.getAddress();
+ }
+ }
+ }
+
+ // Second: targeted BLE scan by name (for unbonded BLE devices)
+ log.info("Device '{}' not bonded, starting targeted BLE scan...", deviceId);
+ String scannedAddress = scanForDeviceByName(deviceId);
+ if (scannedAddress != null) {
+ log.info("BLE scan resolved '{}' to MAC {}", deviceId, scannedAddress);
+ return scannedAddress;
+ }
+
+ log.warn("Could not resolve device identifier '{}' to a MAC address, using as-is", deviceId);
+ return deviceId;
+ }
+
+ /**
+ * Performs a short BLE scan filtered by device name.
+ * Returns the MAC address of the first matching device, or null if not found within the timeout.
+ */
+ @SuppressLint("MissingPermission")
+ private String scanForDeviceByName(String deviceName)
+ {
+ if (aBleAdapter == null || !aBleAdapter.isEnabled())
+ return null;
+
+ BluetoothLeScanner scanner = aBleAdapter.getBluetoothLeScanner();
+ if (scanner == null)
+ return null;
+
+ String lowerName = deviceName != null ? deviceName.toLowerCase() : "";
+ AtomicReference foundAddress = new AtomicReference<>(null);
+ CountDownLatch latch = new CountDownLatch(1);
+
+ ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(int callbackType, ScanResult result) {
+ if (result == null || result.getDevice() == null)
+ return;
+
+ BluetoothDevice device = result.getDevice();
+ String name = device.getName();
+
+ if (name != null && name.toLowerCase().startsWith(lowerName)) {
+ foundAddress.set(device.getAddress());
+ log.info("BLE scan found '{}' at {}", name, device.getAddress());
+ latch.countDown();
+ }
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ log.error("BLE scan failed with error code {}", errorCode);
+ latch.countDown();
+ }
+ };
+
+ ScanSettings settings = new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .setReportDelay(0)
+ .build();
+
+ scanner.startScan(Collections.emptyList(), settings, callback);
+
+ try {
+ latch.await(BLE_NAME_SCAN_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ scanner.stopScan(callback);
+ return foundAddress.get();
}
public void setContext(Context context) {
diff --git a/sensorhub-android-template/AndroidManifest.xml b/sensorhub-android-template/AndroidManifest.xml
new file mode 100644
index 00000000..d2a3abe1
--- /dev/null
+++ b/sensorhub-android-template/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sensorhub-android-template/README.md b/sensorhub-android-template/README.md
new file mode 100644
index 00000000..5822066c
--- /dev/null
+++ b/sensorhub-android-template/README.md
@@ -0,0 +1,91 @@
+# Template Driver Integration
+
+## 1. Add the Template Module
+- Duplciate the template directory
+- Rename it appropriately
+
+## 2. Add dependency to App Module:
+ - In 'sensorhub-android-app' `build.gradle` we need to include the project as a dependency:
+```groovy
+ implementation project(':sensorhub-android-template')
+```
+## 3. Add Preferences UI
+- In `res/xml/pref_sensors.xml`, add:
+```xml
+
+
+
+```
+
+- In `SensorsFragment.java`, include the "enabled" and "options" in the SWITCH_DEPENDENTS map.
+-
+- **Note:** If the driver uses BLE to connect you must also add the ability to select the devices 'BLE Address' (Examples: Kestrel, Trupulse, Meshtastic,+ Polar)
+- Add device selection:
+```xml
+
+```
+- In `SensorsFragment.java`, include the "template_device_address" under the BT_DEVICE_PREF_KEYS
+
+## 4. Update `MainActivity`
+- Import the drivers Config class
+`import org.sensorhub.impl.sensor.template.TemplateConfig;`
+- Add to `Sensors Enum`
+```
+Template
+```
+
+- Enable Push check
+Update `isPushingSensors(Sensors sensor)`:
+```
+if (Sensors.Template.equals(sensor)) {
+ return prefs.getBoolean("template_enabled", false)
+ && prefs.getStringSet("template_options", Collections.emptySet()).contains("PUSH_REMOTE");
+ }
+```
+- Add to updateConfig(...)
+```
+ // Template Driver
+ enabled = prefs.getBoolean("template_enabled", false);
+ if (enabled) {
+ SensorConfig templateConfig = new SensorConfig();
+ templateConfig.id = "TEMPLATE_DRIVER_";
+ templateConfig.name = "Template [" + deviceName + "]";
+ templateConfig.autoStart = true;
+ templateConfig.lastUpdated = ANDROID_SENSORS_LAST_UPDATED;
+ templateConfig.uid_extension = prefs.getString("uid_extension", "");
+ sensorhubConfig.add(templateConfig);
+ }
+```
+
+
+### Adding External Modules (osh-addons/osh-core/...)
+This is slightly different process then local modules
+1. Include the module in `settings.gradle`
+```groovy
+'sensors/positioning/sensorhub-driver-trupulse'
+```
+**>**: Ensure the module path in settings.gradle matches the project folder structure exactly, and you include the correct submodule repository
+
+2. Add Dependency in `sensorhub-android-lib`
+```groovy
+api project(':sensorhub-driver-kestrel')
+```
+3. Repeat steps 3-5 in the first set of instructions
\ No newline at end of file
diff --git a/sensorhub-android-template/build.gradle b/sensorhub-android-template/build.gradle
new file mode 100644
index 00000000..be7d7faa
--- /dev/null
+++ b/sensorhub-android-template/build.gradle
@@ -0,0 +1,47 @@
+apply plugin: 'com.android.library'
+
+description = 'Template Driver'
+ext.details = 'Driver template for android'
+version = '1.0.0'
+
+dependencies {
+ //api 'org.sensorhub:sensorhub-core:' + oshCoreVersion
+ api project(':sensorhub-core')
+ api project(':sensorhub-android-service')
+
+ implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
+ implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation project(path: ':sensorhub-driver-android')
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.core:core:1.5.0'
+}
+configurations.configureEach {
+ exclude group: "ch.qos.logback"
+}
+
+android {
+ namespace 'org.sensorhub.impl.sensor.template'
+ compileSdkVersion rootProject.compileSdkVersion
+ buildToolsVersion rootProject.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.minSdkVersion
+ targetSdkVersion rootProject.targetSdkVersion
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src/main/java']
+ resources.srcDirs = ['src/main/resources']
+ res.srcDirs = ['res']
+ assets.srcDirs = ['assets']
+ jniLibs.srcDirs = ['libs']
+ }
+ }
+}
diff --git a/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Descriptor.java b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Descriptor.java
new file mode 100644
index 00000000..c11a90ad
--- /dev/null
+++ b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Descriptor.java
@@ -0,0 +1,76 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.template;
+
+import org.sensorhub.api.module.IModule;
+import org.sensorhub.api.module.IModuleProvider;
+import org.sensorhub.api.module.ModuleConfig;
+
+
+/**
+ *
+ * Descriptor of Android sensors driver module for automatic discovery
+ * by the ModuleRegistry
+ *
+ *
+ * @author Alex Robin
+ * @since Sep 7, 2013
+ */
+public class Descriptor implements IModuleProvider
+{
+
+ @Override
+ public String getModuleName()
+ {
+ return "Template";
+ }
+
+
+ @Override
+ public String getModuleDescription()
+ {
+ return "Driver template";
+ }
+
+
+ @Override
+ public String getModuleVersion()
+ {
+ return "0.1";
+ }
+
+
+ @Override
+ public String getProviderName()
+ {
+ return "GeoRobotix LLC";
+ }
+
+
+ @Override
+ public Class extends IModule>> getModuleClass()
+ {
+ return Sensor.class;
+ }
+
+
+ @Override
+ public Class extends ModuleConfig> getModuleConfigClass()
+ {
+ return TemplateConfig.class;
+ }
+
+}
diff --git a/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Output.java b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Output.java
new file mode 100644
index 00000000..3aa99130
--- /dev/null
+++ b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Output.java
@@ -0,0 +1,101 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.template;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+import org.vast.swe.helper.GeoPosHelper;
+
+
+/**
+ * Output for template WiFi access point scan results
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class Output extends AbstractSensorOutput
+{
+ DataComponent dataStruct;
+ DataEncoding dataEncoding;
+ private static final String SENSOR_OUTPUT_NAME = "wifiScan";
+ private static final String SENSOR_OUTPUT_LABEL = "WiFi Access Point Scan";
+ private static final Logger logger = LoggerFactory.getLogger(Output.class);
+
+ protected Output(Sensor parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ GeoPosHelper fac = new GeoPosHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("WifiScanResult"))
+ .addField("time", fac.createTime()
+ .asSamplingTimeIsoUTC()
+ .label("Sampling Time")
+ .build())
+ .addField("text", fac.createText()
+ .label("Text")
+ .definition(SWEHelper.getPropertyUri("Text"))
+ .description("Example text field")
+ .build())
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+
+ public void setData(String text) {
+
+ DataBlock dataBlock;
+ if (latestRecord == null)
+ dataBlock = dataStruct.createDataBlock();
+ else
+ dataBlock = latestRecord.renew();
+
+ int idx = 0;
+ dataBlock.setDoubleValue(idx++, System.currentTimeMillis() / 1000d);
+ dataBlock.setStringValue(idx++, text);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 10.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+}
diff --git a/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Sensor.java b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Sensor.java
new file mode 100644
index 00000000..c094d75a
--- /dev/null
+++ b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/Sensor.java
@@ -0,0 +1,118 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.template;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import net.opengis.sensorml.v20.PhysicalComponent;
+
+import org.sensorhub.android.SensorHubService;
+import org.sensorhub.api.sensor.SensorException;
+import org.sensorhub.impl.sensor.AbstractSensorModule;
+import org.sensorhub.impl.sensor.android.SensorMLBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+/**
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class Sensor extends AbstractSensorModule {
+ static final String UID_PREFIX = "urn:osh:sensor:template:driver:";
+
+ private final ArrayList smlComponents;
+ private final SensorMLBuilder smlBuilder;
+ static final Logger logger = LoggerFactory.getLogger(Sensor.class.getSimpleName());
+ private Context context;
+ Output output;
+ private HandlerThread eventThread;
+ private Handler eventHandler;
+ Thread processingThread;
+ volatile boolean doProcessing = true;
+
+
+ public Sensor() {
+ this.smlComponents = new ArrayList();
+ this.smlBuilder = new SensorMLBuilder();
+ }
+
+ @Override
+ public void doInit() {
+ logger.info("Initializing Sensor");
+ this.xmlID = "TEMPLATE_DRIVER_" + Build.SERIAL;
+ this.uniqueID = UID_PREFIX + config.getUidWithExt();
+
+ context = SensorHubService.getContext();
+
+ output = new Output(this);
+ output.doInit();
+ addOutput(output, false);
+
+ }
+
+ @Override
+ public void doStart() throws SensorException {
+ eventThread = new HandlerThread("TemplateThread");
+ eventThread.start();
+ eventHandler = new Handler(eventThread.getLooper());
+
+ startProcessing();
+ }
+
+ public void startProcessing() {
+ doProcessing = true;
+
+ processingThread = new Thread(() -> {
+ while (doProcessing) {
+ output.setData( "Sample Data");
+
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ processingThread.start();
+ }
+
+ public void stopProcessing() {
+ doProcessing = false;
+ }
+
+ @Override
+ public void doStop() {
+
+ if (eventThread != null) {
+ eventThread.quitSafely();
+ eventThread = null;
+ }
+
+ eventHandler = null;
+ logger.info("Sensor stopped");
+ }
+
+ @Override
+ public boolean isConnected() {
+ return processingThread != null && processingThread.isAlive();
+ }
+}
diff --git a/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/TemplateConfig.java b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/TemplateConfig.java
new file mode 100644
index 00000000..ed6d5126
--- /dev/null
+++ b/sensorhub-android-template/src/main/java/org/sensorhub/impl/sensor/template/TemplateConfig.java
@@ -0,0 +1,51 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.template;
+
+import org.sensorhub.android.SensorHubService;
+
+import android.content.Context;
+import android.provider.Settings;
+
+
+/**
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class TemplateConfig extends org.sensorhub.api.sensor.SensorConfig
+{
+
+ public TemplateConfig()
+ {
+ this.moduleClass = Sensor.class.getCanonicalName();
+ }
+ public String uid_extension;
+
+ public long scanIntervalMs = 10000;
+
+ public static String getUid() {
+ Context context = SensorHubService.getContext();
+ return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
+ }
+
+ public String getUidWithExt()
+ {
+ String baseUid = getUid();
+ if (uid_extension != null && !uid_extension.isEmpty())
+ return baseUid + ":" + uid_extension;
+ return baseUid;
+ }
+}
diff --git a/sensorhub-android-template/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider b/sensorhub-android-template/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
new file mode 100644
index 00000000..924ba4ab
--- /dev/null
+++ b/sensorhub-android-template/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
@@ -0,0 +1 @@
+org.sensorhub.impl.sensor.template.Descriptor
\ No newline at end of file
diff --git a/sensorhub-android-template/src/test/java/empty b/sensorhub-android-template/src/test/java/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-android-template/src/test/resources/empty b/sensorhub-android-template/src/test/resources/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-android-wardriving/AndroidManifest.xml b/sensorhub-android-wardriving/AndroidManifest.xml
new file mode 100644
index 00000000..d2a3abe1
--- /dev/null
+++ b/sensorhub-android-wardriving/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sensorhub-android-wardriving/README.md b/sensorhub-android-wardriving/README.md
new file mode 100644
index 00000000..0bb1e3a4
--- /dev/null
+++ b/sensorhub-android-wardriving/README.md
@@ -0,0 +1,27 @@
+# Wardriving WiFi and BLE Scan Driver
+
+OpenSensorHub driver that performs wardriving by scanning for nearby WiFi access points and Bluetooth Low Energy (BLE) devices, and device's GPS location at the time of scan.
+
+## Outputs
+
+### WiFi Scan
+Each observation captures a single access point:
+- **BSSID** - MAC address of the access point
+- **SSID** - Network name (empty for hidden networks)
+- **RSSI** - Signal strength in dBm
+- **Frequency** - Channel frequency in MHz
+- **Capabilities** - Security/encryption schemes (e.g. WPA2, WPA3)
+- **Location** - GPS lat/lon/alt of the device at scan time
+
+### BLE Scan
+Each observation captures a single BLE device:
+- **Device Address** - MAC address of the BLE device
+- **Device Name** - Advertised name (if available)
+- **RSSI** - Signal strength in dBm
+- **Location** - GPS lat/lon/alt of the device at scan time
+
+## Setup
+1. Enable the wardriving sensor in the osh-android app sensors tab
+2. Ensure WiFi and Bluetooth are enabled on the device
+3. Grant location and nearby device permissions if prompted
+4. The driver begins periodic WiFi scans and continuous BLE scanning automatically
\ No newline at end of file
diff --git a/sensorhub-android-wardriving/build.gradle b/sensorhub-android-wardriving/build.gradle
new file mode 100644
index 00000000..ef5842a0
--- /dev/null
+++ b/sensorhub-android-wardriving/build.gradle
@@ -0,0 +1,47 @@
+apply plugin: 'com.android.library'
+
+description = 'Wardriving'
+ext.details = 'Driver for scanning and logging wireless networks'
+version = '1.0.0'
+
+dependencies {
+ //api 'org.sensorhub:sensorhub-core:' + oshCoreVersion
+ api project(':sensorhub-core')
+ api project(':sensorhub-android-service')
+
+ implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
+ implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation project(path: ':sensorhub-driver-android')
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.core:core:1.5.0'
+}
+configurations.configureEach {
+ exclude group: "ch.qos.logback"
+}
+
+android {
+ namespace 'org.sensorhub.impl.sensor.wardriving'
+ compileSdkVersion rootProject.compileSdkVersion
+ buildToolsVersion rootProject.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.minSdkVersion
+ targetSdkVersion rootProject.targetSdkVersion
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src/main/java']
+ resources.srcDirs = ['src/main/resources']
+ res.srcDirs = ['res']
+ assets.srcDirs = ['assets']
+ jniLibs.srcDirs = ['libs']
+ }
+ }
+}
diff --git a/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/BLEOutput.java b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/BLEOutput.java
new file mode 100644
index 00000000..dd458d8b
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/BLEOutput.java
@@ -0,0 +1,116 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.wardriving;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+import org.vast.swe.helper.GeoPosHelper;
+
+
+/**
+ * Output for BLE device scan results.
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class BLEOutput extends AbstractSensorOutput
+{
+ DataComponent dataStruct;
+ DataEncoding dataEncoding;
+ private static final String SENSOR_OUTPUT_NAME = "bleScan";
+ private static final String SENSOR_OUTPUT_LABEL = "BLE Device Scan";
+ private static final Logger logger = LoggerFactory.getLogger(BLEOutput.class);
+
+ protected BLEOutput(Wardriving parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ GeoPosHelper fac = new GeoPosHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("BLEScanResult"))
+ .addField("time", fac.createTime()
+ .asSamplingTimeIsoUTC()
+ .label("Sampling Time")
+ .build())
+ .addField("deviceAddress", fac.createText()
+ .label("Device Address")
+ .definition(SWEHelper.getPropertyUri("NetworkAddress"))
+ .description("MAC address of the BLE device")
+ .build())
+ .addField("deviceName", fac.createText()
+ .label("Device Name")
+ .definition(SWEHelper.getPropertyUri("DeviceName"))
+ .description("Advertised name of the BLE device")
+ .build())
+ .addField("rssi", fac.createQuantity()
+ .label("Signal Strength")
+ .definition(SWEHelper.getPropertyUri("SignalStrength"))
+ .description("Received signal strength indicator")
+ .build())
+ .addField("location", fac.newLocationVectorLLA(
+ SWEHelper.getPropertyUri("SensorLocation")))
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+ public void setData(String deviceAddress, String deviceName, int rssi, double lat, double lon, double alt) {
+ DataBlock dataBlock;
+ if (latestRecord == null)
+ dataBlock = dataStruct.createDataBlock();
+ else
+ dataBlock = latestRecord.renew();
+
+ int idx = 0;
+ dataBlock.setDoubleValue(idx++, System.currentTimeMillis() / 1000d);
+ dataBlock.setStringValue(idx++, deviceAddress != null ? deviceAddress : "");
+ dataBlock.setStringValue(idx++, deviceName != null ? deviceName : "");
+ dataBlock.setIntValue(idx++, rssi);
+ dataBlock.setDoubleValue(idx++, lat);
+ dataBlock.setDoubleValue(idx++, lon);
+ dataBlock.setDoubleValue(idx++, alt);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 10.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+}
diff --git a/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Descriptor.java b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Descriptor.java
new file mode 100644
index 00000000..f666c9ee
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Descriptor.java
@@ -0,0 +1,76 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.wardriving;
+
+import org.sensorhub.api.module.IModule;
+import org.sensorhub.api.module.IModuleProvider;
+import org.sensorhub.api.module.ModuleConfig;
+
+
+/**
+ *
+ * Descriptor of Android sensors driver module for automatic discovery
+ * by the ModuleRegistry
+ *
+ *
+ * @author Alex Robin
+ * @since Sep 7, 2013
+ */
+public class Descriptor implements IModuleProvider
+{
+
+ @Override
+ public String getModuleName()
+ {
+ return "Wardriving";
+ }
+
+
+ @Override
+ public String getModuleDescription()
+ {
+ return "Driver for collecting wireless networks";
+ }
+
+
+ @Override
+ public String getModuleVersion()
+ {
+ return "0.1";
+ }
+
+
+ @Override
+ public String getProviderName()
+ {
+ return "GeoRobotix LLC";
+ }
+
+
+ @Override
+ public Class extends IModule>> getModuleClass()
+ {
+ return Wardriving.class;
+ }
+
+
+ @Override
+ public Class extends ModuleConfig> getModuleConfigClass()
+ {
+ return WardrivingConfig.class;
+ }
+
+}
diff --git a/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Wardriving.java b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Wardriving.java
new file mode 100644
index 00000000..f806e839
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/Wardriving.java
@@ -0,0 +1,351 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.wardriving;
+
+import android.Manifest;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import androidx.core.content.ContextCompat;
+
+import net.opengis.sensorml.v20.PhysicalComponent;
+
+import org.sensorhub.android.SensorHubService;
+import org.sensorhub.api.sensor.SensorException;
+import org.sensorhub.impl.sensor.AbstractSensorModule;
+import org.sensorhub.impl.sensor.android.SensorMLBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Wardriving sensor driver that scans for WiFi access points and
+ * records their details along with the device's GPS location.
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class Wardriving extends AbstractSensorModule {
+ static final String UID_PREFIX = "urn:osh:sensor:wardriving:";
+
+ private final ArrayList smlComponents;
+ private final SensorMLBuilder smlBuilder;
+ static final Logger logger = LoggerFactory.getLogger(Wardriving.class.getSimpleName());
+
+ private Context context;
+ WifiOutput wifiOutput;
+ BLEOutput bleOutput;
+ private HandlerThread eventThread;
+ private Handler eventHandler;
+ private BluetoothLeScanner bluetoothLeScanner;
+ private BluetoothManager bluetoothManager;
+ private WifiManager wifiManager;
+ private LocationManager locationManager;
+ private BroadcastReceiver wifiReceiver;
+ private LocationListener locationListener;
+
+ private volatile double currentLat = 0.0;
+ private volatile double currentLon = 0.0;
+ private volatile double currentAlt = 0.0;
+ private volatile boolean scanning = false;
+ private Runnable scanRunnable;
+ private ScanCallback bleScanCallback;
+
+ public Wardriving() {
+ this.smlComponents = new ArrayList();
+ this.smlBuilder = new SensorMLBuilder();
+ }
+
+ @Override
+ public void doInit() {
+ logger.info("Initializing Wardriving Sensor");
+ this.xmlID = "WARDRIVING_" + Build.SERIAL;
+ this.uniqueID = UID_PREFIX + config.getUidWithExt();
+
+ context = SensorHubService.getContext();
+
+ bleOutput = new BLEOutput(this);
+ bleOutput.doInit();
+ addOutput(bleOutput, false);
+
+ wifiOutput = new WifiOutput(this);
+ wifiOutput.doInit();
+ addOutput(wifiOutput, false);
+ }
+
+ @Override
+ public void doStart() throws SensorException {
+ eventThread = new HandlerThread("WardrivingThread");
+ eventThread.start();
+ eventHandler = new Handler(eventThread.getLooper());
+
+ wifiManager = (WifiManager) context.getApplicationContext()
+ .getSystemService(Context.WIFI_SERVICE);
+ locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+
+ if (wifiManager == null)
+ throw new SensorException("WiFi service not available");
+
+ if (!wifiManager.isWifiEnabled()) {
+ logger.warn("WiFi is disabled");
+ }
+
+ wifiReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ logger.info("WiFi scan broadcast received");
+ handleScanResults();
+ }
+ };
+
+ IntentFilter filter = new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION);
+ context.registerReceiver(wifiReceiver, filter);
+
+ // start GPS location updates
+ startLocationUpdates();
+
+ scanning = true;
+ scanRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (scanning) {
+ logger.info("Triggering WiFi scan");
+ boolean started = wifiManager.startScan();
+ logger.info("WiFi scan started: {}", started);
+ eventHandler.postDelayed(this, config.scanIntervalMs);
+ }
+ }
+ };
+ eventHandler.post(scanRunnable);
+
+ // start BLE scanning
+ startBleScan();
+
+ logger.info("Wardriving sensor started");
+ }
+
+ private void startBleScan() {
+ bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (bluetoothManager == null) {
+ logger.warn("BluetoothManager not available, skipping BLE scan");
+ return;
+ }
+
+ BluetoothAdapter adapter = bluetoothManager.getAdapter();
+ if (adapter == null || !adapter.isEnabled()) {
+ logger.warn("Bluetooth adapter not available or disabled, skipping BLE scan");
+ return;
+ }
+
+ try {
+ if (ContextCompat.checkSelfPermission(context,
+ Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
+ logger.error("BLUETOOTH_SCAN permission not granted");
+ return;
+ }
+
+ bluetoothLeScanner = adapter.getBluetoothLeScanner();
+ if (bluetoothLeScanner == null) {
+ logger.warn("BLE scanner not available");
+ return;
+ }
+
+ bleScanCallback = new ScanCallback() {
+ @Override
+ public void onScanResult(int callbackType, android.bluetooth.le.ScanResult result) {
+ if (!scanning) return;
+
+ String address = result.getDevice().getAddress();
+ String name = null;
+ try {
+ name = result.getDevice().getName();
+ } catch (SecurityException e) {
+ }
+ int rssi = result.getRssi();
+
+ bleOutput.setData(address, name, rssi, currentLat, currentLon, currentAlt);
+ }
+
+ @Override
+ public void onBatchScanResults(List results) {
+ for (android.bluetooth.le.ScanResult result : results) {
+ onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, result);
+ }
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ logger.error("BLE scan failed with error code: {}", errorCode);
+ }
+ };
+
+ ScanSettings settings = new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .setReportDelay(0)
+ .build();
+
+ bluetoothLeScanner.startScan(Collections.emptyList(), settings, bleScanCallback);
+ logger.info("BLE scanning started");
+
+ } catch (SecurityException e) {
+ logger.error("Security exception starting BLE scan", e);
+ }
+ }
+
+ private void startLocationUpdates() {
+ locationListener = new LocationListener() {
+ @Override
+ public void onLocationChanged(Location location) {
+ currentLat = location.getLatitude();
+ currentLon = location.getLongitude();
+ currentAlt = location.hasAltitude() ? location.getAltitude() : 0.0;
+ }
+
+ @Override
+ public void onStatusChanged(String provider, int status, Bundle extras) {}
+ @Override
+ public void onProviderEnabled(String provider) {}
+ @Override
+ public void onProviderDisabled(String provider) {}
+ };
+
+ try {
+ if (ContextCompat.checkSelfPermission(context,
+ Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
+
+ locationManager.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ 1000, 0, locationListener, eventThread.getLooper());
+
+ Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
+ if (last != null) {
+ currentLat = last.getLatitude();
+ currentLon = last.getLongitude();
+ currentAlt = last.hasAltitude() ? last.getAltitude() : 0.0;
+ }
+ } else {
+ logger.error("Location permission not granted");
+ }
+ } catch (SecurityException e) {
+ logger.error("Security exception requesting location updates", e);
+ }
+ }
+
+ private void handleScanResults() {
+ if (!scanning)
+ return;
+
+ try {
+ List results = wifiManager.getScanResults();
+ if (results == null || results.isEmpty()) {
+ logger.debug("No WiFi scan results");
+ return;
+ }
+
+ logger.info("Scan found {} WiFi access points at [{}, {}]",
+ results.size(), currentLat, currentLon);
+
+ for (ScanResult ap : results) {
+ logger.info("AP: BSSID={} SSID=\"{}\" RSSI={}dBm Freq={}MHz Security={}",
+ ap.BSSID,
+ ap.SSID != null ? ap.SSID : "",
+ ap.level,
+ ap.frequency,
+ ap.capabilities);
+
+ wifiOutput.setData(
+ ap.BSSID,
+ ap.SSID,
+ ap.level,
+ ap.frequency,
+ ap.capabilities,
+ currentLat,
+ currentLon,
+ currentAlt
+ );
+ }
+ } catch (SecurityException e) {
+ logger.error("Security exception reading scan results", e);
+ }
+ }
+
+ @Override
+ public void doStop() {
+ scanning = false;
+
+ if (eventHandler != null && scanRunnable != null) {
+ eventHandler.removeCallbacks(scanRunnable);
+ }
+
+ if (wifiReceiver != null) {
+ try {
+ context.unregisterReceiver(wifiReceiver);
+ } catch (IllegalArgumentException e) {
+ logger.warn("WiFi receiver already unregistered");
+ }
+ wifiReceiver = null;
+ }
+
+ if (bluetoothLeScanner != null && bleScanCallback != null) {
+ try {
+ bluetoothLeScanner.stopScan(bleScanCallback);
+ } catch (SecurityException e) {
+ logger.warn("Security exception stopping BLE scan", e);
+ }
+ bleScanCallback = null;
+ bluetoothLeScanner = null;
+ }
+
+ if (locationManager != null && locationListener != null) {
+ locationManager.removeUpdates(locationListener);
+ locationListener = null;
+ }
+
+ if (eventThread != null) {
+ eventThread.quitSafely();
+ eventThread = null;
+ }
+
+ eventHandler = null;
+ logger.info("Wardriving sensor stopped");
+ }
+
+ @Override
+ public boolean isConnected() {
+ return wifiManager != null && scanning;
+ }
+}
diff --git a/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WardrivingConfig.java b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WardrivingConfig.java
new file mode 100644
index 00000000..82daa6b9
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WardrivingConfig.java
@@ -0,0 +1,52 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+The contents of this file are subject to the Mozilla Public License, v. 2.0.
+If a copy of the MPL was not distributed with this file, You can obtain one
+at http://mozilla.org/MPL/2.0/.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+for the specific language governing rights and limitations under the License.
+
+Copyright (C) 2012-2015 Sensia Software LLC. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.wardriving;
+
+import org.sensorhub.android.SensorHubService;
+import org.sensorhub.api.sensor.SensorConfig;
+
+import android.content.Context;
+import android.provider.Settings;
+
+
+/**
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class WardrivingConfig extends SensorConfig
+{
+
+ public WardrivingConfig()
+ {
+ this.moduleClass = Wardriving.class.getCanonicalName();
+ }
+ public String uid_extension;
+
+ public long scanIntervalMs = 10000;
+
+ public static String getUid() {
+ Context context = SensorHubService.getContext();
+ return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
+ }
+
+ public String getUidWithExt()
+ {
+ String baseUid = getUid();
+ if (uid_extension != null && !uid_extension.isEmpty())
+ return baseUid + ":" + uid_extension;
+ return baseUid;
+ }
+}
diff --git a/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WifiOutput.java b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WifiOutput.java
new file mode 100644
index 00000000..438a8b59
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/java/org/sensorhub/impl/sensor/wardriving/WifiOutput.java
@@ -0,0 +1,132 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ The Initial Developer is Botts Innovative Research Inc. Portions created by the Initial
+ Developer are Copyright (C) 2025 the Initial Developer. All Rights Reserved.
+
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.wardriving;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+import net.opengis.swe.v20.DataEncoding;
+
+import org.sensorhub.api.data.DataEvent;
+import org.sensorhub.impl.sensor.AbstractSensorOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.vast.swe.SWEHelper;
+import org.vast.swe.helper.GeoPosHelper;
+
+
+/**
+ * Output for wardriving WiFi access point scan results
+ *
+ * @author Kalyn Stricklin
+ * @since April 6, 2026
+ */
+public class WifiOutput extends AbstractSensorOutput
+{
+ DataComponent dataStruct;
+ DataEncoding dataEncoding;
+ private static final String SENSOR_OUTPUT_NAME = "wifiScan";
+ private static final String SENSOR_OUTPUT_LABEL = "WiFi Access Point Scan";
+ private static final Logger logger = LoggerFactory.getLogger(WifiOutput.class);
+
+ protected WifiOutput(Wardriving parent) {
+ super(SENSOR_OUTPUT_NAME, parent);
+ }
+
+ public void doInit() {
+ GeoPosHelper fac = new GeoPosHelper();
+
+ dataStruct = fac.createRecord()
+ .name(SENSOR_OUTPUT_NAME)
+ .label(SENSOR_OUTPUT_LABEL)
+ .definition(SWEHelper.getPropertyUri("WifiScanResult"))
+ .addField("time", fac.createTime()
+ .asSamplingTimeIsoUTC()
+ .label("Sampling Time")
+ .build())
+ .addField("bssid", fac.createText()
+ .label("BSSID")
+ .definition(SWEHelper.getPropertyUri("NetworkAddress"))
+ .description("MAC address of the access point")
+ .build())
+ .addField("ssid", fac.createText()
+ .label("SSID")
+ .definition(SWEHelper.getPropertyUri("NetworkName"))
+ .description("Network name (may be empty for hidden networks)")
+ .build())
+ .addField("rssi", fac.createQuantity()
+ .label("Signal Strength")
+ .definition(SWEHelper.getPropertyUri("SignalStrength"))
+ .description("Received signal strength indicator")
+ .build())
+ .addField("frequency", fac.createQuantity()
+ .label("Channel Frequency")
+ .definition(SWEHelper.getPropertyUri("RadioFrequency"))
+ .description("Center frequency of the channel in MHz")
+ .build())
+ .addField("capabilities", fac.createText()
+ .label("Security Capabilities")
+ .definition(SWEHelper.getPropertyUri("SecurityCapabilities"))
+ .description("Authentication and encryption schemes supported")
+ .build())
+ .addField("location", fac.newLocationVectorLLA(
+ SWEHelper.getPropertyUri("SensorLocation")))
+
+ .build();
+
+ dataEncoding = fac.newTextEncoding(",", "\n");
+ }
+
+
+ public void setData(String bssid, String ssid, int rssi, int frequency,
+ String capabilities, double lat, double lon, double alt) {
+
+ DataBlock dataBlock;
+ if (latestRecord == null)
+ dataBlock = dataStruct.createDataBlock();
+ else
+ dataBlock = latestRecord.renew();
+
+ int idx = 0;
+ dataBlock.setDoubleValue(idx++, System.currentTimeMillis() / 1000d);
+ dataBlock.setStringValue(idx++, bssid);
+ dataBlock.setStringValue(idx++, ssid != null ? ssid : "");
+ dataBlock.setIntValue(idx++, rssi);
+ dataBlock.setIntValue(idx++, frequency);
+ dataBlock.setStringValue(idx++, capabilities != null ? capabilities : "");
+ dataBlock.setDoubleValue(idx++, lat);
+ dataBlock.setDoubleValue(idx++, lon);
+ dataBlock.setDoubleValue(idx++, alt);
+
+ latestRecord = dataBlock;
+ latestRecordTime = System.currentTimeMillis();
+ eventHandler.publish(new DataEvent(latestRecordTime, this, dataBlock));
+ }
+
+ @Override
+ public double getAverageSamplingPeriod() {
+ return 10.0;
+ }
+
+ @Override
+ public DataComponent getRecordDescription() {
+ return dataStruct;
+ }
+
+ @Override
+ public DataEncoding getRecommendedEncoding() {
+ return dataEncoding;
+ }
+}
diff --git a/sensorhub-android-wardriving/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider b/sensorhub-android-wardriving/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
new file mode 100644
index 00000000..26092ad3
--- /dev/null
+++ b/sensorhub-android-wardriving/src/main/resources/META-INF/services/org.sensorhub.api.module.IModuleProvider
@@ -0,0 +1 @@
+org.sensorhub.impl.sensor.wardriving.Descriptor
\ No newline at end of file
diff --git a/sensorhub-android-wardriving/src/test/java/empty b/sensorhub-android-wardriving/src/test/java/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-android-wardriving/src/test/resources/empty b/sensorhub-android-wardriving/src/test/resources/empty
new file mode 100644
index 00000000..e69de29b
diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidOrientationEulerOutput.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidOrientationEulerOutput.java
index 232f594e..9fb868e0 100644
--- a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidOrientationEulerOutput.java
+++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidOrientationEulerOutput.java
@@ -100,7 +100,7 @@ public void onSensorChanged(SensorEvent e)
AndroidOrientationQuatOutput.getQuaternionFromVector(att, e.values);
att.normalize();
- // Y direction in phone ref frame
+ // Y direction in phone ref frame ( the top of the screen )
look.x = 0;
look.y = 1;
look.z = 0;
diff --git a/settings.gradle b/settings.gradle
index 8a7c6248..d9ac7b45 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -32,6 +32,9 @@ def repos = [
'sensors/health/sensorhub-driver-angelsensor',
'processing/sensorhub-process-vecmath',
'processing/sensorhub-process-geoloc'
+ ],
+ 'botts-addons' : [
+ 'services/sensorhub-service-discovery'
]
]
diff --git a/submodules/botts-addons b/submodules/botts-addons
new file mode 160000
index 00000000..7271af1a
--- /dev/null
+++ b/submodules/botts-addons
@@ -0,0 +1 @@
+Subproject commit 7271af1a739256a4170e5281f62efea0a57b41dc
diff --git a/submodules/osh-addons b/submodules/osh-addons
index dfcd8e3f..029d3235 160000
--- a/submodules/osh-addons
+++ b/submodules/osh-addons
@@ -1 +1 @@
-Subproject commit dfcd8e3fcf63acfa421ca292b0315a64bca60735
+Subproject commit 029d3235e45bf3fe91b2ef619ab53fec7f7fc03e
diff --git a/submodules/osh-core b/submodules/osh-core
index b8db019a..a413b4d1 160000
--- a/submodules/osh-core
+++ b/submodules/osh-core
@@ -1 +1 @@
-Subproject commit b8db019a1e1715c1badaab8433e50b59a6072d24
+Subproject commit a413b4d19c5ec00d6bdef84305d9dd9992bc1a15