diff --git a/sensorhub-android-app/res/drawable/ic_zoom_in.xml b/sensorhub-android-app/res/drawable/ic_zoom_in.xml
new file mode 100644
index 0000000..0f499d6
--- /dev/null
+++ b/sensorhub-android-app/res/drawable/ic_zoom_in.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/sensorhub-android-app/res/drawable/ic_zoom_out.xml b/sensorhub-android-app/res/drawable/ic_zoom_out.xml
new file mode 100644
index 0000000..f17b746
--- /dev/null
+++ b/sensorhub-android-app/res/drawable/ic_zoom_out.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/sensorhub-android-app/res/layout/fragment_dashboard.xml b/sensorhub-android-app/res/layout/fragment_dashboard.xml
index a69bda0..a67b21f 100644
--- a/sensorhub-android-app/res/layout/fragment_dashboard.xml
+++ b/sensorhub-android-app/res/layout/fragment_dashboard.xml
@@ -73,16 +73,6 @@
-
+
+
+
+
+
+
+
+
+
+
Enter name or address manually…
Scan for new devices…
Switch Camera
+ Zoom In
+ Zoom Out
Stopping SensorHub
diff --git a/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java b/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java
index 62f2ee9..67ff07c 100644
--- a/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java
+++ b/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java
@@ -76,6 +76,11 @@ public class DashboardFragment extends Fragment implements TextureView.SurfaceTe
private TextureView textureView;
private MaterialCardView videoStatusCard;
private MaterialButton btnToggleVideo;
+ private MaterialButton btnFlipCamera;
+ private MaterialButton btnZoomIn;
+ private MaterialButton btnZoomOut;
+ private LinearLayout videoControlsOverlay;
+ private int currentZoomLevel = 0;
private MaterialCardView meshtasticCard;
private View videoStatusDot;
@@ -119,6 +124,16 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
btnToggleVideo.setOnClickListener(v -> toggleVideoPreview());
+ videoControlsOverlay = view.findViewById(R.id.video_controls_overlay);
+
+ btnFlipCamera = view.findViewById(R.id.btn_flip_camera);
+ btnFlipCamera.setOnClickListener(v -> flipCamera());
+
+ btnZoomIn = view.findViewById(R.id.btn_zoom_in);
+ btnZoomIn.setOnClickListener(v -> adjustZoom(1));
+ btnZoomOut = view.findViewById(R.id.btn_zoom_out);
+ btnZoomOut.setOnClickListener(v -> adjustZoom(-1));
+
meshtasticCard = view.findViewById(R.id.meshtastic_card);
view.findViewById(R.id.btn_meshtastic_msg).setOnClickListener(v -> showMeshtasticDialog());
@@ -177,6 +192,8 @@ private void stopHub() {
hideVideoPreview();
clearTextureView();
videoStatusCard.setVisibility(View.GONE);
+ if (videoControlsOverlay != null) videoControlsOverlay.setVisibility(View.GONE);
+ currentZoomLevel = 0;
if (meshtasticCard != null) meshtasticCard.setVisibility(View.GONE);
newStatusMessage(getString(R.string.sensorhub_stopped));
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
@@ -562,6 +579,7 @@ private void updateVideoStatusCard() {
boolean hasVideo = service != null && service.hasVideo();
videoStatusCard.setVisibility(hasVideo ? View.VISIBLE : View.GONE);
+ updateVideoControlsVisibility();
if (hasVideo && videoInfoText.length() > 0) {
videoInfoArea.setText(videoInfoText.toString());
@@ -581,15 +599,80 @@ private void toggleVideoPreview() {
textureView.setVisibility(View.VISIBLE);
btnToggleVideo.setText(R.string.btn_hide);
serverStatusContainer.setBackgroundColor(getResources().getColor(R.color.overlay_light, requireActivity().getTheme()));
+ updateVideoControlsVisibility();
showVideo();
} else {
hideVideoPreview();
}
}
+ @SuppressWarnings("deprecation")
+ private void updateVideoControlsVisibility() {
+ SensorHubService service = provider.getBoundService();
+ boolean hasVideo = service != null && service.hasVideo();
+
+ if (videoControlsOverlay != null)
+ videoControlsOverlay.setVisibility(hasVideo && videoPreviewVisible ? View.VISIBLE : View.GONE);
+
+ if (btnFlipCamera != null) {
+ boolean showFlip = hasVideo && android.hardware.Camera.getNumberOfCameras() > 1;
+ btnFlipCamera.setVisibility(showFlip ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void flipCamera() {
+ AndroidSensorsDriver sensors = provider.getAndroidSensors();
+ if (sensors == null) return;
+
+ try {
+ int currentId = sensors.getConfiguration().selectedCameraId;
+ android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo();
+ android.hardware.Camera.getCameraInfo(currentId, info);
+
+ String targetFacing = (info.facing == android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK)
+ ? "FRONT" : "BACK";
+
+ int targetId = -1;
+ int targetFacingInt = "FRONT".equals(targetFacing)
+ ? android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT
+ : android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK;
+
+ for (int i = 0; i < android.hardware.Camera.getNumberOfCameras(); i++) {
+ android.hardware.Camera.CameraInfo camInfo = new android.hardware.Camera.CameraInfo();
+ android.hardware.Camera.getCameraInfo(i, camInfo);
+ if (camInfo.facing == targetFacingInt) {
+ targetId = i;
+ break;
+ }
+ }
+
+ if (targetId >= 0) {
+ sensors.switchCamera(targetId);
+ currentZoomLevel = 0;
+ Toast.makeText(requireContext(), "Switched to " + targetFacing.toLowerCase() + " camera", Toast.LENGTH_SHORT).show();
+ }
+ } catch (Exception e) {
+ Toast.makeText(requireContext(), "Failed to switch camera", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void adjustZoom(int direction) {
+ AndroidSensorsDriver sensors = provider.getAndroidSensors();
+ if (sensors == null) return;
+
+ try {
+ currentZoomLevel = Math.max(0, currentZoomLevel + direction);
+ sensors.setCameraZoom(currentZoomLevel);
+ } catch (Exception e) {
+ Toast.makeText(requireContext(), "Zoom not supported", Toast.LENGTH_SHORT).show();
+ }
+ }
+
private void hideVideoPreview() {
videoPreviewVisible = false;
textureView.setVisibility(View.GONE);
+ if (videoControlsOverlay != null) videoControlsOverlay.setVisibility(View.GONE);
if (btnToggleVideo != null) btnToggleVideo.setText(R.string.btn_show);
serverStatusContainer.setBackgroundColor(0x00000000);
}
diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java
new file mode 100644
index 0000000..aea463a
--- /dev/null
+++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.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 GeoRobotix Innovative Research Inc.. Portions created by the Initial
+Developer are Copyright (C) 2026 the Initial Developer. All Rights Reserved.
+
+******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.android;
+
+
+import android.hardware.Camera;
+
+import net.opengis.swe.v20.DataBlock;
+import net.opengis.swe.v20.DataComponent;
+
+import org.sensorhub.api.command.CommandException;
+import org.sensorhub.api.sensor.SensorException;
+import org.sensorhub.impl.sensor.AbstractSensorControl;
+import org.vast.swe.SWEHelper;
+
+
+public class AndroidCameraControl extends AbstractSensorControl
+{
+ private final DataComponent commandDescription;
+
+ // field indices in the command DataBlock
+ private static final int CAMERA_FACING_IDX = 0;
+ private static final int ZOOM_LEVEL_IDX = 1;
+
+
+ public AndroidCameraControl(AndroidSensorsDriver parentSensor)
+ {
+ super("cameraControl", parentSensor);
+
+ SWEHelper fac = new SWEHelper();
+ commandDescription = fac.createRecord()
+ .name("cameraControl")
+ .addField("cameraFacing", fac.createCategory()
+ .definition(SWEHelper.getPropertyUri("CameraSelector"))
+ .label("Camera Facing Direction")
+ .description("Select front or back camera")
+ .addAllowedValues("FRONT", "BACK")
+ .build())
+ .addField("zoomLevel", fac.createCount()
+ .definition(SWEHelper.getPropertyUri("ZoomLevel"))
+ .label("Zoom Level")
+ .description("Camera zoom level (0 = no zoom, max depends on device)")
+ .build())
+ .build();
+ }
+
+
+ @Override
+ public DataComponent getCommandDescription()
+ {
+ return commandDescription;
+ }
+
+
+ @Override
+ protected boolean execCommand(DataBlock command) throws CommandException
+ {
+ try
+ {
+ // handle camera facing change
+ String facing = command.getStringValue(CAMERA_FACING_IDX);
+ if (facing != null && !facing.isEmpty())
+ {
+ int newCameraId = findCameraId(facing);
+ parentSensor.switchCamera(newCameraId);
+ }
+
+ // handle zoom level change
+ int zoomLevel = command.getIntValue(ZOOM_LEVEL_IDX);
+ if (zoomLevel >= 0)
+ {
+ parentSensor.setCameraZoom(zoomLevel);
+ }
+
+ return true;
+ }
+ catch (SensorException e)
+ {
+ throw new CommandException("Failed to execute camera command", e);
+ }
+ }
+
+
+ private int findCameraId(String facing) throws CommandException
+ {
+ int targetFacing = "FRONT".equals(facing)
+ ? Camera.CameraInfo.CAMERA_FACING_FRONT
+ : Camera.CameraInfo.CAMERA_FACING_BACK;
+
+ for (int i = 0; i < Camera.getNumberOfCameras(); i++)
+ {
+ Camera.CameraInfo info = new Camera.CameraInfo();
+ Camera.getCameraInfo(i, info);
+ if (info.facing == targetFacing)
+ return i;
+ }
+
+ throw new CommandException("No " + facing + " camera found on this device");
+ }
+}
diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java
index 2fb5f6c..18f680e 100644
--- a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java
+++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java
@@ -43,6 +43,7 @@
import org.sensorhub.impl.sensor.android.audio.AndroidAudioOutputAAC;
import org.sensorhub.impl.sensor.android.audio.AndroidAudioOutputOPUS;
import org.sensorhub.impl.sensor.android.audio.AudioEncoderConfig;
+import org.sensorhub.impl.sensor.android.video.AndroidCameraOutput;
import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputH264;
import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputH265;
import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputMJPEG;
@@ -77,6 +78,7 @@ public class AndroidSensorsDriver extends AbstractSensorModule smlComponents;
+ AndroidCameraOutput currentCameraOutput;
public AndroidSensorsDriver()
@@ -148,8 +150,14 @@ protected synchronized void doInit() throws SensorHubException
// create data interfaces for cameras
if (androidContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
+ {
createCameraOutputs(androidContext);
+ // register camera control (front/back switching + zoom)
+ if (currentCameraOutput != null)
+ addControlInput(new AndroidCameraControl(this));
+ }
+
// create data interfaces for audio
if (androidContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_MICROPHONE))
createAudioOutputs(androidContext);
@@ -273,6 +281,8 @@ protected void useCamera(IStreamingDataInterface output, int cameraId)
{
addOutput(output, false);
smlComponents.add(smlBuilder.getComponentDescription(cameraId));
+ if (output instanceof AndroidCameraOutput)
+ currentCameraOutput = (AndroidCameraOutput) output;
log.info("Getting data from camera #" + cameraId);
}
@@ -292,7 +302,27 @@ protected void useAudio(IStreamingDataInterface output, String srcName)
log.info("Getting data from audio source " + srcName);
}
-
+
+ public void switchCamera(int newCameraId) throws SensorException
+ {
+ if (currentCameraOutput == null)
+ throw new SensorException("No camera output to switch");
+
+ currentCameraOutput.switchCamera(newCameraId);
+ config.selectedCameraId = newCameraId;
+ log.info("Switched to camera #{}", newCameraId);
+ }
+
+
+ public void setCameraZoom(int zoomLevel) throws SensorException
+ {
+ if (currentCameraOutput == null)
+ throw new SensorException("No camera output to set zoom on");
+
+ currentCameraOutput.setZoom(zoomLevel);
+ }
+
+
@Override
protected void doStop() throws SensorException
{
diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java
index 8cae659..5b5148c 100644
--- a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java
+++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java
@@ -420,6 +420,85 @@ public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
+ public void switchCamera(int newCameraId) throws SensorException
+ {
+ if (newCameraId == this.cameraId)
+ return;
+
+ if (camera != null)
+ {
+ camera.stopPreview();
+ camera.setPreviewCallbackWithBuffer(null);
+ camera.release();
+ camera = null;
+ }
+
+ if (mCodec != null)
+ {
+ mCodec.stop();
+ mCodec.release();
+ mCodec = null;
+ }
+
+ if (bgLooper != null)
+ {
+ bgLooper.quit();
+ bgLooper = null;
+ }
+
+ codecInfoData = null;
+
+ this.cameraId = newCameraId;
+ initCam();
+ initCodec();
+
+ if (mCodec != null)
+ mCodec.start();
+
+ try
+ {
+ if (previewTexture != null)
+ camera.setPreviewTexture(previewTexture);
+ camera.startPreview();
+ }
+ catch (Exception e)
+ {
+ throw new SensorException("Cannot restart camera preview after switch", e);
+ }
+ }
+
+
+ public void setZoom(int zoomLevel) throws SensorException
+ {
+ if (camera == null)
+ throw new SensorException("Camera is not initialized");
+ if (bgLooper == null)
+ throw new SensorException("Camera looper is not running");
+
+ new Handler(bgLooper).post(() -> {
+ try
+ {
+ Camera.Parameters params = camera.getParameters();
+ if (!params.isZoomSupported())
+ {
+ log.warn("Zoom is not supported on camera {}", cameraId);
+ return;
+ }
+
+ int maxZoom = params.getMaxZoom();
+ int clampedZoom = Math.max(0, Math.min(zoomLevel, maxZoom));
+ params.setZoom(clampedZoom);
+ camera.setParameters(params);
+ log.info("Zoom set to {}/{} on camera #{}", clampedZoom, maxZoom, cameraId);
+ }
+ catch (Exception e)
+ {
+ log.error("Failed to set zoom on camera {}", cameraId, e);
+ }
+ });
+ }
+
+
@Override
public void stop()
{