Author Archives: Leo Cheung

Android Video Recorder For ID Tags

By using Android’s TextClock and Location/Geocoder on top of its CameraX API as shown in the previous blog post, we now have a mobile app equipped with the functionality of overlaying live videos with time and locale info. Such functionality is useful especially if the “when” and “where” are supposed to be integral content of the recordings.

Use cases for ID tagged items & IoT

Below are a few use cases when ID tag (e.g. QR code, RFID) or IoT sensor (e.g. ZigBee, LwM2M) technologies are involved.

  • Inventory records of tagged products – By video recording detected tag IDs of tagged products along with time and locale as integral content of the recordings, traceable records will be readily available for inventory auditing.
  • Provenance of tagged collectibles – Similarly, live videos capturing the time and locale of collectibles/memorabilia (e.g. original artworks) tagged with unique IDs from the owners in an event (e.g. original artists at an exhibition) can be used as a critical part of the provenance of their authenticity. Potential buyers could look up from a trusted tag data source (e.g. a central database or decentralized blockchain) for tag ID verifications.
  • IoT sensors scanning & recording – By bundling the recorder app with protocol-specific scanner toolkit, IoT sensor devices can be scanned and recorded for testing/auditing purpose.

Recall that the time-locale overlaid recording feature being built is through the recording of the the camera screen on which the time and locale info are displayed. To maximize flexibility, rather than using a 3rd-party screen recording app, we’re going to roll out our own screen recorder.

HBRecorder

HBRecorder is a popular Android screen recording library. Minimal permissions needed to be included in AndroidManifest.xml for the recorder library are:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

Let’s add a start/stop button at the bottom of the UI layout.

content_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

            ...

        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:background="@color/colorPrimary">

            <Button
                android:id="@+id/button_start"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/start_recording"
                android:textColor="@android:color/white"
                android:background="@drawable/ripple_effect"
                tools:text="@string/start_recording"/>

        </RelativeLayout>
    </RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Next, we make the main class MainActivity implement HBRecorderListener for custom application logic upon occurrence of events including:

  • HBRecorderOnStart()
  • HBRecorderOnComplete()
  • HBRecorderOnError()
  • HBRecorderOnPause()
  • HBRecorderOnResume()
  • onRequestPermissionsResult()
  • onActivityResult()

For basic screen recording purposes using the library, source code for MainActivity.java highlighted with HBRecorder implementation is included at the end of this post.

Full source code for the Android time-locale video recorder with simulated ID tag scanning/recording is available at this GitHub repo.

Recording detected barcodes / QR codes

Now that we have a time-locale overlaid screen recording app in place, we’re ready to use it for specific use cases that involve ID tags. If barcode or QR code is being used for ID tagging, one could use Google’s ML Kit API (in particular, MlKitAnalyzer and BarcodeScanner) to visually pattern match barcode/QR code on Android’s camera PreviewView.

The following sample code from Android developers website uses LifecycleCameraController to create an image analyzer with MlKitAnalyzer to set up a BarcodeScanner for detecting QR codes:

BarcodeScannerOptions options = new BarcodeScannerOptions.Builder()
   .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
   .build();
BarcodeScanner barcodeScanner = BarcodeScanning.getClient(options);

cameraController.setImageAnalysisAnalyzer(executor,
    new MlKitAnalyzer(List.of(barcodeScanner), COORDINATE_SYSTEM_VIEW_REFERENCED,
    executor, result -> {
 });

Source code for the CameraX-MLKit can be found in this Android camera-samples repo. The official sample code is in Kotlin, though some Java implementations are available out there.

Recording RFID scans

To live record detected RFID tags, it’d involve a little more effort. A scanner capable of scanning RFID tags and transmitting the scanned data to the Android phone (thru Bluetooth, wired USB, etc) will be needed. One of the leading RFID scanner manufacturers is Zebra which also offers an Android SDK for their products.

The SDK setup would involve downloading the latest RFID API library, configuring the RFID API library via Gradle, creating a new import module (:RFIDAPI3Library) and adding the library as a project-level dependency. To use the RFID scanning features, import the library from within the main app and make class MainActivity implement RFIDHandler‘s RFID interface to customize lifecycle routines (e.g. onPause, onResume) and reader operational methods to handle things like reader trigger being pressed, processing detected tag IDs, etc.

The dependencies in app/build.gradle will include the RFID module.

dependencies {

    ...

    implementation project(':RFIDAPI3Library')
}

MainActivity.java will look something like below.

public class MainActivity extends AppCompatActivity implements RFIDHandler.ResponseHandlerInterface {

    ...

    RFIDHandler rfidHandler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        rfidHandler = new RFIDHandler();
        ...
    }

    ...
    
    @Override
    public void handleTagdata(TagData[] tagData) {
        final StringBuilder sb = new StringBuilder();
        for (int index = 0; index < tagData.length; index++) {
            sb.append(tagData[index].getTagID() + "\n");
        }
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                textrfid.append(sb.toString());
            }
        });
    }

    @Override
    public void handleTriggerPress(boolean pressed) {
        if (pressed) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    textrfid.setText("");
                }
            });
            rfidHandler.performInventory();
        } else
            rfidHandler.stopInventory();
    }
}

IoT sensors scanning/recording

In IoT, commonly used protocols include LwM2M (Lightweight M2M), MQTT (MQ Telemetry Transport), ZigBee (IEEE 802.15.4 compliant WPAN protocol), Z-wave, etc. With an emphasis in being lightweight and interoperability, LwM2M has gained a lot of momentum in recent years. For ZigBee, since the protocol spans across from the application to physical layers, a ZigBee hub which connects to the Android device via Bluetooth/WiFi might be needed.

Specific expertise in the IoT protocol of choice is required for the video recorder app implementation. That’s beyond of scope of what would like to focus on in this blog post. For those who want to delve deeper into the details, some of the open-source libraries which might be of interest are Leshan Java library for LwM2M (server & client Java impl) and ZigBee API for Java.

Final thoughts

Obviously, there are countless other use cases the time-locale video recorder can be used for recording, reconciling and proofing valuable items or products via QR code, RFID or IoT communication protocols like what have been described. What’s remarkable about such a customizable video recorder is that despite its simplicity, the app can be readily integrated with any suitable mobile SDK or library module for a given ID tag / IoT sensor reader to provide a low-cost solution on a consumer-grade mobile device usable virtually any time and anywhere.

Appendix

MainActivity.java highlighted with HBRecorder implementation:

import com.hbisoft.hbrecorder.HBRecorder;
import com.hbisoft.hbrecorder.HBRecorderListener;

public class MainActivity extends AppCompatActivity implements HBRecorderListener {

    ...

    private HBRecorder hbRecorder;

    private static final int SCREEN_RECORD_REQUEST_CODE = 777;
    private static final int PERMISSION_REQ_ID_RECORD_AUDIO = 22;
    private static final int PERMISSION_REQ_POST_NOTIFICATIONS = 33;
    private static final int PERMISSION_REQ_ID_WRITE_EXTERNAL_STORAGE = PERMISSION_REQ_ID_RECORD_AUDIO + 1;
    private boolean hasPermissions = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ...

        initViews();
        setOnClickListeners();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            hbRecorder = new HBRecorder(this, this);
            if (hbRecorder.isBusyRecording()) {
                startbtn.setText(R.string.stop_recording);
            }
        }
    }

    ...

    private void createFolder() {
        File f1 = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), "HBRecorder");
        if (!f1.exists()) {
            if (f1.mkdirs()) {
                Log.i("Folder ", "created");
            }
        }
    }

    private void initViews() {
        startbtn = findViewById(R.id.button_start);
    }

    private void setOnClickListeners() {
        startbtn.setOnClickListener(v -> {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS, PERMISSION_REQ_POST_NOTIFICATIONS) && checkSelfPermission(Manifest.permission.RECORD_AUDIO, PERMISSION_REQ_ID_RECORD_AUDIO)) {
                        hasPermissions = true;
                    }
                }
                else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    if (checkSelfPermission(Manifest.permission.RECORD_AUDIO, PERMISSION_REQ_ID_RECORD_AUDIO)) {
                        hasPermissions = true;
                    }
                } else {
                    if (checkSelfPermission(Manifest.permission.RECORD_AUDIO, PERMISSION_REQ_ID_RECORD_AUDIO) && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, PERMISSION_REQ_ID_WRITE_EXTERNAL_STORAGE)) {
                        hasPermissions = true;
                    }
                }
                if (hasPermissions) {
                    if (hbRecorder.isBusyRecording()) {
                        hbRecorder.stopScreenRecording();
                        startbtn.setText(R.string.start_recording);
                    }
                    //else start recording
                    else {
                        startRecordingScreen();
                    }
                }
            } else {
                showLongToast("This library requires API 21>");
            }
        });
    }

    @Override
    public void HBRecorderOnStart() {
        Log.e("HBRecorder", "HBRecorderOnStart called");
    }

    @Override
    public void HBRecorderOnComplete() {
        startbtn.setText(R.string.start_recording);
        showLongToast("Saved Successfully");
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            if (hbRecorder.wasUriSet()) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ) {
                    updateGalleryUri();
                } else {
                    refreshGalleryFile();
                }
            }else{
                refreshGalleryFile();
            }
        }

    }

    @Override
    public void HBRecorderOnError(int errorCode, String reason) {
        if (errorCode == SETTINGS_ERROR) {
            showLongToast(getString(R.string.settings_not_supported_message));
        } else if ( errorCode == MAX_FILE_SIZE_REACHED_ERROR) {
            showLongToast(getString(R.string.max_file_size_reached_message));
        } else {
            showLongToast(getString(R.string.general_recording_error_message));
            Log.e("HBRecorderOnError", reason);
        }
        startbtn.setText(R.string.start_recording);

    }

    @Override
    public void HBRecorderOnPause() {
    }

    @Override
    public void HBRecorderOnResume() {
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void refreshGalleryFile() {
        MediaScannerConnection.scanFile(this,
                new String[]{hbRecorder.getFilePath()}, null,
                new MediaScannerConnection.OnScanCompletedListener() {
                    public void onScanCompleted(String path, Uri uri) {
                        Log.i("ExternalStorage", "Scanned " + path + ":");
                        Log.i("ExternalStorage", "-> uri=" + uri);
                    }
                });
    }

    @RequiresApi(api = Build.VERSION_CODES.Q)
    private void updateGalleryUri(){
        contentValues.clear();
        contentValues.put(MediaStore.Video.Media.IS_PENDING, 0);
        getContentResolver().update(mUri, contentValues, null, null);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void startRecordingScreen() {
        quickSettings();
        MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
        Intent permissionIntent = mediaProjectionManager != null ? mediaProjectionManager.createScreenCaptureIntent() : null;
        startActivityForResult(permissionIntent, SCREEN_RECORD_REQUEST_CODE);
        startbtn.setText(R.string.stop_recording);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void quickSettings() {
        hbRecorder.setAudioBitrate(128000);
        hbRecorder.setAudioSamplingRate(44100);
        hbRecorder.recordHDVideo(true);
        hbRecorder.isAudioEnabled(true);
        hbRecorder.setNotificationSmallIcon(R.drawable.icon);
        hbRecorder.setNotificationTitle(getString(R.string.stop_recording_notification_title));
        hbRecorder.setNotificationDescription(getString(R.string.stop_recording_notification_message));
    }

    private boolean checkSelfPermission(String permission, int requestCode) {
        if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{permission}, requestCode);
            return false;
        }
        return true;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        switch (requestCode) {
            case PERMISSION_REQ_POST_NOTIFICATIONS:
                if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    checkSelfPermission(Manifest.permission.RECORD_AUDIO, PERMISSION_REQ_ID_RECORD_AUDIO);
                } else {
                    hasPermissions = false;
                    showLongToast("No permission for " + Manifest.permission.POST_NOTIFICATIONS);
                }
                break;
            case PERMISSION_REQ_ID_RECORD_AUDIO:
                if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, PERMISSION_REQ_ID_WRITE_EXTERNAL_STORAGE);
                } else {
                    hasPermissions = false;
                    showLongToast("No permission for " + Manifest.permission.RECORD_AUDIO);
                }
                break;
            case PERMISSION_REQ_ID_WRITE_EXTERNAL_STORAGE:
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    hasPermissions = true;
                    startRecordingScreen();
                } else {
                    if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                        hasPermissions = true;
                        //Permissions was provided
                        //Start screen recording
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                            startRecordingScreen();
                        }
                    } else {
                        hasPermissions = false;
                        showLongToast("No permission for " + Manifest.permission.WRITE_EXTERNAL_STORAGE);
                    }
                }
                break;
            default:
                break;
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            if (requestCode == SCREEN_RECORD_REQUEST_CODE) {
                if (resultCode == RESULT_OK) {
                    setOutputPath();
                    hbRecorder.startScreenRecording(data, resultCode);

                }
            }
        }
    }

    ContentResolver resolver;
    ContentValues contentValues;
    Uri mUri;
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void setOutputPath() {
        String filename = generateFileName();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            resolver = getContentResolver();
            contentValues = new ContentValues();
            contentValues.put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/" + "HBRecorder");
            contentValues.put(MediaStore.Video.Media.TITLE, filename);
            contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
            contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4");
            mUri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues);
            hbRecorder.setFileName(filename);
            hbRecorder.setOutputUri(mUri);
        } else {
            createFolder();
            hbRecorder.setOutputPath(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES) +"/HBRecorder");
        }
    }

    private String generateFileName() {
        SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault());
        Date curDate = new Date(System.currentTimeMillis());
        return formatter.format(curDate).replace(" ", "");
    }

    private void showLongToast(final String msg) {
        Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_LONG).show();
    }
}

Android Video With Time & Locale

One would think displaying a ticking time clock and locale information in a live recording video clip on Android must be a readily available built-in option. Well, not really, but there are a number of Android apps out there that allow users to add the wanted info. But what if you want to add such info to your own custom video recording app? That’s a mobile app component required in a recent R&D project.

The required work turns out to be less trivial than expected. Android covers a wide range of hardware profiles, and adding text to video recorded or being recorded requires the video to be re-encoded, which is hardware dependent. Fortunately, the video content required by the project doesn’t need to be of super high resolution. Given that contemporary mobile devices have decent screen resolution (e.g. even an old Android Pixel-3 phone has a screen resolution of 1080 x 2160), I decided to go for a much simpler approach by displaying the time and locale on screen while recording the screen view as the video content.

Android CameraX

The latest Android API for camera functionality is CameraX. As we know, Google has made Kotlin (as opposed to Java) the preferred programming language on Android since 2019. While Kotlin seems like a neat programming language with improvement (e.g. null safety) over Java, I’m not quite ready to justify diving into another new language for a simple mobile app, hence I’m going to stick to the good old Java and repurpose from this nice Java implementation repo on GitHub.

Let’s get right into the coding work. First, do a git clone of the base CameraX Java app.

git clone https://github.com/farazxsiddiqui/CameraX.git

From the cloned repo, create an Android Studio project, then, if wanted, modify the project name and path. To do that, either use Android Studio‘s refactor or manually modify paths and content of a few relevant files under ${projectDir}/app/ including build.gradle, main/AndroidManifest.xml, main app MainActivity.java, etc.

CameraX dependencies

Next, we review app/build.gradle to make sure the CameraX dependencies are there and upgrade cameraxVersion to a later version, if preferred.

build.gradle:

...

dependencies {
    ...
    def cameraxVersion = "1.1.0-alpha05"
    implementation "androidx.camera:camera-core:${cameraxVersion}"
    implementation "androidx.camera:camera-camera2:${cameraxVersion}"
    implementation "androidx.camera:camera-lifecycle:${cameraxVersion}"
    implementation 'androidx.camera:camera-view:1.0.0-alpha25'
}

Android TextClock

The Android API provides TextClock for constructing timestamp in a string of various formats as TextView. The following example UI layout highlights how to use it.

Let’s say we have the main display content content_main.xml in XML included from within the standard activity_main.xml.

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout

    ...

    <include layout="@layout/content_main" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

content_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

            ...

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:paddingLeft="16dp"
            android:paddingRight="16dp"
            android:orientation="vertical" >

            ...

            <TextClock
                android:id="@+id/textClock"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:format12Hour="yyyy-MM-dd hh:mm:ss a z"
                android:text="Date-time"
                android:textColor="#cccccc"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/button_some_action" />

            ...

        </LinearLayout>

        ...

    </RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

In this example, we set the timestamp format as follows so as to display the date/time/timezone like “2024-01-15 09:30:00 AM -0800”.

<TextClock
    android:format12Hour="yyyy-MM-dd hh:mm:ss a z"
/>

From within MainActivity.java, we then import android.widget.TextClock and get the view from the XML layout via findViewById(R.id.textClock).

MainActivity.java:

import android.widget.TextClock;

public class MainActivity extends AppCompatActivity {

    ...

    private TextClock clockTC;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ...

        clockTC = findViewById(R.id.textClock);
        // clockTC.setFormat12Hour("yyyy-MM-dd hh:mm:ss a z");

    }

    ...
}

Android Location / Geocoder

For locale info, we use Android’s Location, LocationManager, LocationListener and Geocoder APIs to access system location services, get location change notifications and display live locale info as TextView. On the UI layout, we expand the content to include the address/latitude/longitude info.

content_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

            ...

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:paddingLeft="16dp"
            android:paddingRight="16dp"
            android:orientation="vertical" >

            ...

            <androidx.appcompat.widget.AppCompatTextView
                android:id="@+id/textLoc"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:text="Location"
                android:textColor="#cccccc"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/textClock" />

            <androidx.appcompat.widget.AppCompatTextView
                android:id="@+id/textLatLon"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:text="Lat/Long"
                android:textColor="#cccccc"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/textLoc" />

            ...

        </LinearLayout>

        ...

    </RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

From within MainActivity.java, we make class MainActivity.java implement LocationListener and override onLocationChanged() to report live location of the mobile device the app resides on. Note that the double-typed lat/lon values are being truncated to 4 decimal places via String.format("%.4f", ...). That gives a location precision of down to ~10 meters, which is about the proximity of a building. Depending on the specific use case, one can adjust the precision limit accordingly.

MainActivity.java:

import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.location.Address;
import android.location.Geocoder;
import java.util.List;
import java.util.Locale;

public class MainActivity extends AppCompatActivity implements LocationListener {

    ...

    protected String provider;
    protected LocationManager locationManager;
    protected LocationListener locationListener;
    protected TextView textLocation;
    protected TextView textLatLon;
    protected String latitude,longitude;
    protected Geocoder geocoder;
    protected List<Address> addresses;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ...

        textLocation = (TextView) findViewById(R.id.textLoc);
        locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
                || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this);
        }
        geocoder = new Geocoder(this, Locale.getDefault());

        ...
    }

    ...

    public String addressOf(double latitude, double longitude) {
        try {
            addresses = geocoder.getFromLocation(latitude, longitude, 1);
            String address = addresses.get(0).getAddressLine(0);
            /*
            String city = addresses.get(0).getLocality();
            String state = addresses.get(0).getAdminArea();
            String country = addresses.get(0).getCountryName();
            return address + ", " + city + ", " + state + ", " + country;
            */
            return address;
        }
        catch (IOException e) {
            e.printStackTrace();
            return e.getMessage();
        }
    }

    @Override
    public void onLocationChanged(Location location) {
        double lat = location.getLatitude();
        double lon = location.getLongitude();
        textLocation = (TextView) findViewById(R.id.textLoc);
        textLocation.setText(addressOf(lat, lon));
        textLatLon = (TextView) findViewById(R.id.textLatLon);
        textLatLon.setText("( " + String.format("%.4f", lat) + " , " + String.format("%.4f", lon) + " )");
    }

    @Override
    public void onProviderDisabled(String provider) {
        Log.d("Latitude","disable");
    }

    @Override
    public void onProviderEnabled(String provider) {
        Log.d("Latitude","enable");
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {
        Log.d("Latitude","status");
    }

}

The Android app is now capable of displaying live time and locale info on the camera screen. To record videos that are overlaid with the displayed info, the simplest way would be to install a 3rd-party screen recorder such as AZ Recorder. To make the feature a part of a custom mobile app, integrating the screen recording functionality into the app might be necessary. That’s what we’re going to do in the next blog post. On top of that, we’ll explore how such features can be useful in some interesting use cases.

Dynamic IoT Streams With Akka GRPC

Having covered a basic use case for gRPC streaming of IoT device states in the previous blog post, we’re now moving onto a slightly more complex scenario that involves dynamically broadcasting response streams from the server to any participating clients.

Rather than having each of the gRPC clients receiving the IoT states update responses to their own request streams, the responses are now being broadcast to all participating clients, making it possible for any given client to work with the received states update of devices inside a property originally processed by other clients.

Akka gRPC streaming

Recall that we were leveraging Akka gRPC to:

  1. generate service interfaces from the Protobuf service schema that get implemented as Scala classes and Akka stream components to create HttpRequest => Future[HttpResponse] routes in the Akka-HTTP server that supports HTTP/2
  2. generate gRPC stubs through implementing the service interfaces with Akka Streams API to invoke the remote services
Akka gRPC Streaming

Dynamically broadcasting IoT streams

Just like in the previous use case, our IoT system of sensor devices consists of a gRPC server simulating algorithmic changes to device states as response streams to the requesting gRPC clients. There can be many clients participating/leaving at any time.

In our new use case, the server broadcasts response streams to requests from all clients and each client receives all the stream elements since its participation. It’s similar in some way to a dynamic pub/sub channel in which the gRPC clients subscribe to a service-topic published by the gRPC server.

How do we create such a channel? As shown in one of the GreeterService examples available at Lightbend developers guide, we could create one by coupling a MergeHub with a BroadcastHub. For more details, another blog post of mine covers the very topic.

A new Protobuf service

A majority of the source code described in the previous blog post remains unchanged, as we’re only adding a new RPC service along with its implementation.

First, we declare the new RPC service broadcastIotUpdate() in iotstream.proto under src/main/protobuf/.

service IotStreamService {
    rpc sendIotUpdate(stream StatesUpdateRequest) returns (stream StatesUpdateResponse) {}
    rpc broadcastIotUpdate(stream StatesUpdateRequest) returns (stream StatesUpdateResponse) {}
}

We then add the implementation of broadcastIotUpdate() in class IotStreamServiceImpl.

val (inHub: Sink[StatesUpdateRequest, NotUsed], outHub: Source[StatesUpdateResponse, NotUsed]) =
  MergeHub.source[StatesUpdateRequest]
    .via(updateIotFlow)
    .toMat(BroadcastHub.sink[StatesUpdateResponse])(Keep.both)
    .run()

val dynamicPubSubFlow: Flow[StatesUpdateRequest, StatesUpdateResponse, NotUsed] =
  Flow.fromSinkAndSource(inHub, outHub)

...

@Override
def broadcastIotUpdate(requests: Source[StatesUpdateRequest, NotUsed]): Source[StatesUpdateResponse, NotUsed] =
  requests.via(dynamicPubSubFlow).backpressureTimeout(backpressureTMO)

As shown in the above snippet, we “sandwich” updateIotFlow (which simulates algorithmic IoT states update) between a MergeHub and a BroadcastHub to materialize a tuple of sink and source, followed by turning them into the dynamicPubSubFlow via Akka Stream’s fromSinkAndSource(). The created flow will then serve like a dynamic pub/sub channel funneling incoming request streams from the gRPC clients to broadcast the response streams with updated states to all participating clients.

As for class IotStreamClient, we add a new command line argument, broadcastYN (1=Yes, 0=No), to the main() function’s argument list to indicate whether broadcast of response streams is wanted. The client application will call the specific RPC service in accordance with the value of broadcastYN.

val responseStream: Source[StatesUpdateResponse, NotUsed] = {
  if (broadcastYN == 0)
    client.sendIotUpdate(requestStream)
  else
    client.broadcastIotUpdate(requestStream)
}

The following diagram highlights the gRPC server/clients components, and the rest of the IoT system that manages the individual remote sensor devices. Note that the IoT Manager sub-system isn’t part of the application we’re focusing on. The sub-system could be designed on top of gRPC as well, or Akka Actors (i.e. similar to the Actor-based IotManager), or any other suitable tech stack.

IoT Streaming with Akka gRPC

Full source code for the gRPC client/server components is available at this GitHub repo.

Final thoughts

It should be noted that this is a simplified use case primarily for demonstrating how device states update from the gRPC server can be dynamically broadcast to the requesting clients. To strengthen the use case, we could maintain a key-value cache using Redis with unique device IDs as keys subject to a pre-set TTL (time-to-live) to prevent multiple gRPC clients processing for the same device simultaneously.

In addition, we might also log in persistence storages the who-what-and-when (i.e. client ID / states / timestamp) of the device states update, or if warranted by the business requirement, putting in place a distributed committed log system with Apache Kafka. Such enhancement would enable the Akka Streams-baced gRPC tech stack to provide a robust streaming mechanism comparable to those solutions like using Akka Actors with distributed pub/sub and persistence journal on clusters.

Sample output

Appended is sample output from the gRPC server and 3 gRPC clients. As shown in the output, one could mix and match clients with different broadcast options, in which case the group of clients with broadcast on will share among themselves all streams responded by the server to requests from themselves, whereas each member of the broadcast-off group will get only responded streams originated by itself.

Terminal #1: gRPC Server

% ./sbt "runMain akkagrpc.IotStreamServer"
[info] ...
[info] done compiling
[info] running (fork) akkagrpc.IotStreamServer 
[info] [2023-10-31 11:38:03,227] [INFO] [akka.event.slf4j.Slf4jLogger] [IotStreamServer-akka.actor.default-dispatcher-3] [] - Slf4jLogger started
[info] [Server] gRPC server bound to 127.0.0.1:8080

Terminal #2: gRPC Client1 — broadcast ON (broadcastYN = 1)

% ./sbt "runMain akkagrpc.IotStreamClient client1 1 1000 1019"
[info] welcome to sbt 1.9.6 (Oracle Corporation Java 11.0.19)
[info] loading global plugins from /Users/leo/.sbt/1.0/plugins
[info] loading settings for project akka-grpc-iot-stream-build from plugins.sbt ...
[info] loading project definition from /Users/leo/intellij/akka-grpc-iot-stream/project
[info] loading settings for project akka-grpc-iot-stream from build.sbt ...
[info] set current project to akka-grpc-iot-stream (in build file:/Users/leo/intellij/akka-grpc-iot-stream/)
[info] running (fork) akkagrpc.IotStreamClient client1 1 1000 1019
[info] [2023-10-31 11:53:39,910] [INFO] [akka.event.slf4j.Slf4jLogger] [IotStreamClient-akka.actor.default-dispatcher-3] [] - Slf4jLogger started
[info] Performing streaming requests from client1 ...
[info] [client1] REQUEST: 1000 5e468f SecurityAlarm | State: 0, Setting: 1
[info] [client1] REQUEST: 1000 70a7bf Lamp | State: 0, Setting: 2
[info] [client1] REQUEST: 1001 a6d07c Lamp | State: 1, Setting: 1
[info] [client1] REQUEST: 1001 b224cf SecurityAlarm | State: 0, Setting: 4
[info] [client1] REQUEST: 1001 df00a1 SecurityAlarm | State: 1, Setting: 4
[info] [client1] REQUEST: 1002 5d9abe Thermostat | State: 1, Setting: 69
[info] [client1] REQUEST: 1002 283a39 Thermostat | State: 1, Setting: 60
[info] [client1] REQUEST: 1002 70b354 SecurityAlarm | State: 0, Setting: 5
[info] [client1] REQUEST: 1002 c12ac7 Thermostat | State: 0, Setting: 66
[info] [client1] REQUEST: 1003 2587a9 Lamp | State: 0, Setting: 2
[info] [client1] REQUEST: 1004 f5732a Thermostat | State: 1, Setting: 62
[info] [client1] REQUEST: 1004 c7aaf1 Lamp | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client1] 1000 5e468f SecurityAlarm | State: 0, Setting: 5
[info] [client1] RESPONSE: [requester: client1] 1000 70a7bf Lamp | State: 0, Setting: 3
[info] [client1] RESPONSE: [requester: client1] 1001 a6d07c Lamp | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client1] 1001 b224cf SecurityAlarm | State: 0, Setting: 4
[info] [client1] RESPONSE: [requester: client1] 1001 df00a1 SecurityAlarm | State: 1, Setting: 3
[info] [client1] RESPONSE: [requester: client1] 1002 5d9abe Thermostat | State: 1, Setting: 67
[info] [client1] RESPONSE: [requester: client1] 1002 283a39 Thermostat | State: 0, Setting: 60
[info] [client1] RESPONSE: [requester: client1] 1002 70b354 SecurityAlarm | State: 0, Setting: 4
[info] [client1] RESPONSE: [requester: client1] 1002 c12ac7 Thermostat | State: 2, Setting: 64
[info] [client1] RESPONSE: [requester: client1] 1003 2587a9 Lamp | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client1] 1004 f5732a Thermostat | State: 0, Setting: 63
[info] [client1] REQUEST: 1004 72a5f5 SecurityAlarm | State: 0, Setting: 2
[info] [client1] RESPONSE: [requester: client1] 1004 c7aaf1 Lamp | State: 1, Setting: 2
[info] [client1] REQUEST: 1005 f859d3 Thermostat | State: 2, Setting: 70
[info] [client1] RESPONSE: [requester: client1] 1004 72a5f5 SecurityAlarm | State: 0, Setting: 3
[info] [client1] REQUEST: 1006 4a7da5 SecurityAlarm | State: 0, Setting: 2
[info] [client1] RESPONSE: [requester: client1] 1005 f859d3 Thermostat | State: 0, Setting: 70
[info] [client1] REQUEST: 1006 17ac1a Lamp | State: 0, Setting: 3
[info] [client1] RESPONSE: [requester: client1] 1006 4a7da5 SecurityAlarm | State: 0, Setting: 2
[info] [client1] REQUEST: 1006 58653a Lamp | State: 0, Setting: 2
[info] [client1] RESPONSE: [requester: client1] 1006 17ac1a Lamp | State: 0, Setting: 1
[info] [client1] REQUEST: 1006 0bfa66 SecurityAlarm | State: 1, Setting: 5
[info] [client1] RESPONSE: [requester: client1] 1006 58653a Lamp | State: 0, Setting: 2
[info] [client1] REQUEST: 1007 6caf32 SecurityAlarm | State: 0, Setting: 2
[info] [client1] RESPONSE: [requester: client1] 1006 0bfa66 SecurityAlarm | State: 0, Setting: 2
[info] [client1] REQUEST: 1007 2b67a7 SecurityAlarm | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client1] 1007 6caf32 SecurityAlarm | State: 0, Setting: 4
[info] [client1] RESPONSE: [requester: client3] 1040 f237b5 Lamp | State: 1, Setting: 3
[info] [client1] RESPONSE: [requester: client3] 1040 cbeb82 SecurityAlarm | State: 1, Setting: 2
[info] [client1] RESPONSE: [requester: client3] 1040 742dcd Thermostat | State: 1, Setting: 70
[info] [client1] RESPONSE: [requester: client3] 1041 337d19 Lamp | State: 1, Setting: 2
[info] [client1] RESPONSE: [requester: client3] 1041 6d19d6 Thermostat | State: 1, Setting: 64
[info] [client1] RESPONSE: [requester: client3] 1041 144297 Thermostat | State: 0, Setting: 69
[info] [client1] RESPONSE: [requester: client3] 1041 663271 SecurityAlarm | State: 1, Setting: 2
[info] [client1] RESPONSE: [requester: client3] 1042 83807a Lamp | State: 1, Setting: 1
[info] [client1] RESPONSE: [requester: client3] 1042 5ce343 Thermostat | State: 2, Setting: 60
[info] [client1] RESPONSE: [requester: client3] 1042 7ab082 Lamp | State: 1, Setting: 1
[info] [client1] RESPONSE: [requester: client3] 1042 eae803 Thermostat | State: 2, Setting: 65
[info] [client1] REQUEST: 1008 f0c753 Thermostat | State: 2, Setting: 67
[info] [client1] RESPONSE: [requester: client1] 1007 2b67a7 SecurityAlarm | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client3] 1043 88ee61 Lamp | State: 0, Setting: 2
[info] [client1] REQUEST: 1008 32ccb2 Lamp | State: 0, Setting: 2
[info] [client1] RESPONSE: [requester: client1] 1008 f0c753 Thermostat | State: 2, Setting: 65
[info] [client1] RESPONSE: [requester: client3] 1043 440b5b Lamp | State: 1, Setting: 3
. . .
. . .
[info] [client1] REQUEST: 1019 3f122b Thermostat | State: 0, Setting: 63
[info] [client1] RESPONSE: [requester: client1] 1019 6f15e0 Lamp | State: 1, Setting: 1
[info] [client1] RESPONSE: [requester: client3] 1055 6a6fde Lamp | State: 0, Setting: 2
[info] [client1] REQUEST: 1019 b8ce7b SecurityAlarm | State: 1, Setting: 5
[info] [client1] RESPONSE: [requester: client1] 1019 3f122b Thermostat | State: 1, Setting: 64
[info] [client1] RESPONSE: [requester: client3] 1056 60eab7 Thermostat | State: 2, Setting: 70
[info] [client1] REQUEST: 1019 93678e SecurityAlarm | State: 0, Setting: 3
[info] [client1] RESPONSE: [requester: client1] 1019 b8ce7b SecurityAlarm | State: 1, Setting: 5
[info] [client1] RESPONSE: [requester: client3] 1056 ae65ad Thermostat | State: 0, Setting: 66
[info] [client1] RESPONSE: [requester: client1] 1019 93678e SecurityAlarm | State: 1, Setting: 4
[info] [client1] RESPONSE: [requester: client3] 1056 cb5d9f Lamp | State: 0, Setting: 1
[info] [client1] RESPONSE: [requester: client3] 1057 c4abf5 Thermostat | State: 0, Setting: 73
[info] [client1] RESPONSE: [requester: client3] 1058 013a30 SecurityAlarm | State: 1, Setting: 5
[info] [client1] RESPONSE: [requester: client3] 1058 681c43 Lamp | State: 1, Setting: 2
[info] [client1] RESPONSE: [requester: client3] 1058 0f1673 Thermostat | State: 1, Setting: 65
[info] [client1] RESPONSE: [requester: client3] 1058 0c97dd Lamp | State: 1, Setting: 3
[info] [client1] RESPONSE: [requester: client3] 1059 e9834d Lamp | State: 1, Setting: 2
[info] [client1] RESPONSE: [requester: client3] 1059 93166b Thermostat | State: 1, Setting: 73

Terminal #3: gRPC Client2 — broadcast OFF (broadcastYN = 0)

% ./sbt "runMain akkagrpc.IotStreamClient client2 0 1020 1039"
[info] welcome to sbt 1.9.6 (Oracle Corporation Java 11.0.19)
[info] loading global plugins from /Users/leo/.sbt/1.0/plugins
[info] loading settings for project akka-grpc-iot-stream-build from plugins.sbt ...
[info] loading project definition from /Users/leo/intellij/akka-grpc-iot-stream/project
[info] loading settings for project akka-grpc-iot-stream from build.sbt ...
[info] set current project to akka-grpc-iot-stream (in build file:/Users/leo/intellij/akka-grpc-iot-stream/)
[info] running (fork) akkagrpc.IotStreamClient client2 0 1020 1039
[info] [2023-10-31 11:53:40,601] [INFO] [akka.event.slf4j.Slf4jLogger] [IotStreamClient-akka.actor.default-dispatcher-3] [] - Slf4jLogger started
[info] Performing streaming requests from client2 ...
[info] [client2] REQUEST: 1020 be1091 Lamp | State: 1, Setting: 3
[info] [client2] REQUEST: 1021 9e6b12 SecurityAlarm | State: 0, Setting: 3
[info] [client2] REQUEST: 1021 cf913c Thermostat | State: 1, Setting: 69
[info] [client2] REQUEST: 1022 4549d2 Lamp | State: 0, Setting: 1
[info] [client2] REQUEST: 1022 08e429 Thermostat | State: 2, Setting: 60
[info] [client2] REQUEST: 1023 aa1eea Lamp | State: 1, Setting: 3
[info] [client2] REQUEST: 1023 af1da0 Thermostat | State: 0, Setting: 70
[info] [client2] REQUEST: 1023 9e7439 Thermostat | State: 0, Setting: 64
[info] [client2] REQUEST: 1023 b21319 SecurityAlarm | State: 0, Setting: 4
[info] [client2] REQUEST: 1024 6ce4c5 SecurityAlarm | State: 1, Setting: 1
[info] [client2] REQUEST: 1024 827653 Thermostat | State: 0, Setting: 64
[info] [client2] REQUEST: 1024 ae359e SecurityAlarm | State: 0, Setting: 1
[info] [client2] RESPONSE: [requester: client2] 1020 be1091 Lamp | State: 0, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1021 9e6b12 SecurityAlarm | State: 0, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1021 cf913c Thermostat | State: 1, Setting: 67
[info] [client2] RESPONSE: [requester: client2] 1022 4549d2 Lamp | State: 0, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1022 08e429 Thermostat | State: 2, Setting: 61
[info] [client2] RESPONSE: [requester: client2] 1023 aa1eea Lamp | State: 1, Setting: 1
[info] [client2] RESPONSE: [requester: client2] 1023 af1da0 Thermostat | State: 2, Setting: 72
[info] [client2] RESPONSE: [requester: client2] 1023 9e7439 Thermostat | State: 2, Setting: 65
[info] [client2] RESPONSE: [requester: client2] 1023 b21319 SecurityAlarm | State: 0, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1024 6ce4c5 SecurityAlarm | State: 1, Setting: 5
[info] [client2] RESPONSE: [requester: client2] 1024 827653 Thermostat | State: 0, Setting: 64
[info] [client2] REQUEST: 1024 9b4378 Thermostat | State: 1, Setting: 64
[info] [client2] RESPONSE: [requester: client2] 1024 ae359e SecurityAlarm | State: 0, Setting: 1
[info] [client2] REQUEST: 1025 66f837 Lamp | State: 0, Setting: 1
[info] [client2] RESPONSE: [requester: client2] 1024 9b4378 Thermostat | State: 1, Setting: 66
[info] [client2] REQUEST: 1026 b0ac08 SecurityAlarm | State: 1, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1025 66f837 Lamp | State: 0, Setting: 1
[info] [client2] REQUEST: 1027 249cb9 SecurityAlarm | State: 0, Setting: 5
[info] [client2] RESPONSE: [requester: client2] 1026 b0ac08 SecurityAlarm | State: 0, Setting: 4
[info] [client2] REQUEST: 1027 d205d3 Lamp | State: 1, Setting: 2
[info] [client2] RESPONSE: [requester: client2] 1027 249cb9 SecurityAlarm | State: 0, Setting: 1
[info] [client2] REQUEST: 1027 6783fc Lamp | State: 0, Setting: 1
. . .
. . .
[info] [client2] REQUEST: 1038 af2956 Lamp | State: 0, Setting: 1
[info] [client2] RESPONSE: [requester: client2] 1037 ed954a Lamp | State: 0, Setting: 2
[info] [client2] REQUEST: 1038 656d9f Thermostat | State: 1, Setting: 63
[info] [client2] RESPONSE: [requester: client2] 1038 af2956 Lamp | State: 1, Setting: 3
[info] [client2] REQUEST: 1039 582904 Lamp | State: 1, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1038 656d9f Thermostat | State: 0, Setting: 62
[info] [client2] REQUEST: 1039 5daf4a SecurityAlarm | State: 0, Setting: 3
[info] [client2] RESPONSE: [requester: client2] 1039 582904 Lamp | State: 1, Setting: 1
[info] [client2] REQUEST: 1039 e31886 Thermostat | State: 1, Setting: 75
[info] [client2] RESPONSE: [requester: client2] 1039 5daf4a SecurityAlarm | State: 1, Setting: 5
[info] [client2] RESPONSE: [requester: client2] 1039 e31886 Thermostat | State: 0, Setting: 75
[info] [client2] Done IoT states streaming.

Terminal #4: gRPC Client3 — broadcast ON (broadcastYN = 1)

% ./sbt "runMain akkagrpc.IotStreamClient client3 1 1040 1059"
[info] welcome to sbt 1.9.6 (Oracle Corporation Java 11.0.19)
[info] loading global plugins from /Users/leo/.sbt/1.0/plugins
[info] loading settings for project akka-grpc-iot-stream-build from plugins.sbt ...
[info] loading project definition from /Users/leo/intellij/akka-grpc-iot-stream/project
[info] loading settings for project akka-grpc-iot-stream from build.sbt ...
[info] set current project to akka-grpc-iot-stream (in build file:/Users/leo/intellij/akka-grpc-iot-stream/)
[info] running (fork) akkagrpc.IotStreamClient client3 1 1040 1059
[info] [2023-10-31 11:53:40,842] [INFO] [akka.event.slf4j.Slf4jLogger] [IotStreamClient-akka.actor.default-dispatcher-3] [] - Slf4jLogger started
[info] Performing streaming requests from client3 ...
[info] [client3] REQUEST: 1040 f237b5 Lamp | State: 0, Setting: 3
[info] [client3] REQUEST: 1040 cbeb82 SecurityAlarm | State: 0, Setting: 2
[info] [client3] REQUEST: 1040 742dcd Thermostat | State: 1, Setting: 72
[info] [client3] REQUEST: 1041 337d19 Lamp | State: 1, Setting: 2
[info] [client3] REQUEST: 1041 6d19d6 Thermostat | State: 1, Setting: 65
[info] [client3] REQUEST: 1041 144297 Thermostat | State: 2, Setting: 68
[info] [client3] REQUEST: 1041 663271 SecurityAlarm | State: 1, Setting: 3
[info] [client3] REQUEST: 1042 83807a Lamp | State: 1, Setting: 2
[info] [client3] REQUEST: 1042 5ce343 Thermostat | State: 0, Setting: 60
[info] [client3] REQUEST: 1042 7ab082 Lamp | State: 1, Setting: 1
[info] [client3] REQUEST: 1042 eae803 Thermostat | State: 0, Setting: 65
[info] [client3] REQUEST: 1043 88ee61 Lamp | State: 1, Setting: 3
[info] [client3] RESPONSE: [requester: client3] 1040 f237b5 Lamp | State: 1, Setting: 3
[info] [client3] RESPONSE: [requester: client3] 1040 cbeb82 SecurityAlarm | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1040 742dcd Thermostat | State: 1, Setting: 70
[info] [client3] RESPONSE: [requester: client3] 1041 337d19 Lamp | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1041 6d19d6 Thermostat | State: 1, Setting: 64
[info] [client3] RESPONSE: [requester: client3] 1041 144297 Thermostat | State: 0, Setting: 69
[info] [client3] RESPONSE: [requester: client3] 1041 663271 SecurityAlarm | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1042 83807a Lamp | State: 1, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1042 5ce343 Thermostat | State: 2, Setting: 60
[info] [client3] RESPONSE: [requester: client3] 1042 7ab082 Lamp | State: 1, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1042 eae803 Thermostat | State: 2, Setting: 65
[info] [client3] RESPONSE: [requester: client1] 1007 2b67a7 SecurityAlarm | State: 0, Setting: 1
[info] [client3] REQUEST: 1043 440b5b Lamp | State: 0, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1043 88ee61 Lamp | State: 0, Setting: 2
[info] [client3] RESPONSE: [requester: client1] 1008 f0c753 Thermostat | State: 2, Setting: 65
[info] [client3] REQUEST: 1043 99c6e0 SecurityAlarm | State: 1, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1043 440b5b Lamp | State: 1, Setting: 3
[info] [client3] RESPONSE: [requester: client1] 1008 32ccb2 Lamp | State: 0, Setting: 2
[info] [client3] REQUEST: 1044 de863b Lamp | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1043 99c6e0 SecurityAlarm | State: 1, Setting: 5
[info] [client3] RESPONSE: [requester: client1] 1009 edf984 SecurityAlarm | State: 1, Setting: 5
[info] [client3] REQUEST: 1044 04dff8 Lamp | State: 0, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1044 de863b Lamp | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client1] 1009 274bca Thermostat | State: 2, Setting: 68
[info] [client3] REQUEST: 1044 86ed37 SecurityAlarm | State: 1, Setting: 1
[info] [client3] RESPONSE: [requester: client3] 1044 04dff8 Lamp | State: 0, Setting: 1
[info] [client3] RESPONSE: [requester: client1] 1009 50e02f Thermostat | State: 1, Setting: 66
[info] [client3] REQUEST: 1044 fd5984 SecurityAlarm | State: 0, Setting: 3
[info] [client3] RESPONSE: [requester: client3] 1044 86ed37 SecurityAlarm | State: 1, Setting: 5
[info] [client3] RESPONSE: [requester: client1] 1009 c6160b SecurityAlarm | State: 1, Setting: 5
[info] [client3] REQUEST: 1045 a6b326 Thermostat | State: 0, Setting: 60
[info] [client3] RESPONSE: [requester: client3] 1044 fd5984 SecurityAlarm | State: 1, Setting: 1
[info] [client3] RESPONSE: [requester: client1] 1010 eb624d SecurityAlarm | State: 0, Setting: 4
. . .
. . .
[info] [client3] REQUEST: 1057 c4abf5 Thermostat | State: 2, Setting: 73
[info] [client3] RESPONSE: [requester: client3] 1056 cb5d9f Lamp | State: 0, Setting: 1
[info] [client3] REQUEST: 1058 013a30 SecurityAlarm | State: 1, Setting: 5
[info] [client3] RESPONSE: [requester: client3] 1057 c4abf5 Thermostat | State: 0, Setting: 73
[info] [client3] REQUEST: 1058 681c43 Lamp | State: 1, Setting: 3
[info] [client3] RESPONSE: [requester: client3] 1058 013a30 SecurityAlarm | State: 1, Setting: 5
[info] [client3] REQUEST: 1058 0f1673 Thermostat | State: 1, Setting: 64
[info] [client3] RESPONSE: [requester: client3] 1058 681c43 Lamp | State: 1, Setting: 2
[info] [client3] REQUEST: 1058 0c97dd Lamp | State: 1, Setting: 3
[info] [client3] RESPONSE: [requester: client3] 1058 0f1673 Thermostat | State: 1, Setting: 65
[info] [client3] REQUEST: 1059 e9834d Lamp | State: 0, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1058 0c97dd Lamp | State: 1, Setting: 3
[info] [client3] REQUEST: 1059 93166b Thermostat | State: 0, Setting: 71
[info] [client3] RESPONSE: [requester: client3] 1059 e9834d Lamp | State: 1, Setting: 2
[info] [client3] RESPONSE: [requester: client3] 1059 93166b Thermostat | State: 1, Setting: 73