diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 50acbbd6..92e81396 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,13 +13,33 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v2 + uses: actions/checkout@v5 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make gradlew executable + run: chmod +x ./gradlew - name: Build the app run: ./gradlew build - name: Sign debug apk with debug key - uses: ilharp/sign-android-release@v1 + if: ${{ env.KEGBOT_DEBUG_KEYSTORE != '' }} + uses: ilharp/sign-android-release@v2 id: sign_debug_apk with: releaseDir: kegtab/build/outputs/apk/debug @@ -27,15 +47,34 @@ jobs: keyAlias: kegbot-debug keyStorePassword: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + ANDROID_SIGNING_KEY: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + ANDROID_KEY_ALIAS: kegbot-debug + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} + ANDROID_KEY_PASSWORD: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} - - name: Archive debug APK - uses: actions/upload-artifact@v3 + - name: Archive signed debug APK + if: ${{ env.KEGBOT_DEBUG_KEYSTORE != '' }} + uses: actions/upload-artifact@v4 + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} with: - name: kegtab-debug + name: kegtab-debug-signed path: ${{ steps.sign_debug_apk.outputs.signedFile }} + - name: Archive unsigned debug APK + if: ${{ env.KEGBOT_DEBUG_KEYSTORE == '' }} + uses: actions/upload-artifact@v4 + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + with: + name: kegtab-debug-unsigned + path: kegtab/build/outputs/apk/debug/*.apk + - name: Sign release apk with debug key - uses: ilharp/sign-android-release@v1 + if: ${{ env.KEGBOT_DEBUG_KEYSTORE != '' }} + uses: ilharp/sign-android-release@v2 id: sign_release_apk with: releaseDir: kegtab/build/outputs/apk/release @@ -43,9 +82,27 @@ jobs: keyAlias: kegbot-debug keyStorePassword: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + ANDROID_SIGNING_KEY: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + ANDROID_KEY_ALIAS: kegbot-debug + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} + ANDROID_KEY_PASSWORD: ${{ secrets.KEGBOT_DEBUG_KEYSTORE_PASSWORD }} - - name: Archive release APK - uses: actions/upload-artifact@v3 + - name: Archive signed release APK + if: ${{ env.KEGBOT_DEBUG_KEYSTORE != '' }} + uses: actions/upload-artifact@v4 + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} with: - name: kegtab-release + name: kegtab-release-signed path: ${{ steps.sign_release_apk.outputs.signedFile }} + + - name: Archive unsigned release APK + if: ${{ env.KEGBOT_DEBUG_KEYSTORE == '' }} + uses: actions/upload-artifact@v4 + env: + KEGBOT_DEBUG_KEYSTORE: ${{ secrets.KEGBOT_DEBUG_KEYSTORE }} + with: + name: kegtab-release-unsigned + path: kegtab/build/outputs/apk/release/*.apk diff --git a/.gitignore b/.gitignore index 8f54b0cb..5023c8a6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ local.properties proguard/ kegtab/crashlytics.properties +.vscode diff --git a/README.md b/README.md index 1a875caf..40f50b69 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,29 @@ Home page: http://kegbot.org/ Developers: Quick Setup Instructions ------------------------------------ -Bear with us as better develop documentation is coming! - -In the meantime, here are quick and dirty steps: - -- Clone the kegbot-android repo +### Prerequisites + +- Java 17 or higher (required for Android Gradle Plugin 8.x) +- Android SDK +- Android Studio or compatible IDE + +### Setup Steps + +1. Clone the kegbot-android repo +2. Copy `local.properties.template` to `local.properties` +3. Edit `local.properties` to set your Android SDK path: + ``` + sdk.dir=/path/to/your/android/sdk + ``` +4. Set up Java 17: + - **Option A**: Set `JAVA_HOME` environment variable to your Java 17 installation + - **Option B**: Add `org.gradle.java.home=/path/to/your/java17` to `local.properties` +5. Build the project: + ```bash + ./gradlew build + ``` + +### For Eclipse Users (Legacy) - Eclipse: Import -> Existing Projects into Workspace. - Import the projects (Kegtab, KegtabTest) @@ -39,4 +57,3 @@ LICENSE.txt for the full license. The Kegbot name and logo are trademarks of the Kegbot project; please don't reuse them without our permission. - diff --git a/build.gradle b/build.gradle index 48c9d35d..4b0278f8 100644 --- a/build.gradle +++ b/build.gradle @@ -3,9 +3,12 @@ buildscript { repositories { mavenCentral() google() + maven { + url "https://repo.eclipse.org/content/repositories/paho-snapshots/" + } } dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' + classpath 'com.android.tools.build:gradle:8.1.0' } } diff --git a/gradle.properties b/gradle.properties index 6108990a..b088e4b5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,6 @@ android.enableJetifier=true android.useAndroidX=true +# Set Gradle to use Java 17 for Android Gradle Plugin 8.x compatibility +# Configure JAVA_HOME environment variable or set org.gradle.java.home in your local gradle.properties +# Example: org.gradle.java.home=/path/to/your/java17/installation diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bbe38325..44f822ba 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip diff --git a/kegtab/build.gradle b/kegtab/build.gradle index 70af6bd3..8fc3153f 100644 --- a/kegtab/build.gradle +++ b/kegtab/build.gradle @@ -24,7 +24,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' + classpath 'com.android.tools.build:gradle:8.1.0' } } @@ -40,7 +40,8 @@ repositories { } android { - compileSdkVersion 30 + namespace 'org.kegbot.app' + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -48,13 +49,17 @@ android { defaultConfig { minSdkVersion 17 - targetSdkVersion 30 + targetSdkVersion 33 multiDexEnabled true testApplicationId "org.kegbot.app.test" testInstrumentationRunner "android.test.InstrumentationTestRunner" } + buildFeatures { + buildConfig = true + } + lintOptions { checkReleaseBuilds true abortOnError false @@ -96,8 +101,8 @@ dependencies { implementation 'com.squareup.okhttp:okhttp:2.0.0' implementation 'com.squareup:otto:1.3.4' implementation 'com.google.guava:guava:31.1-android' - implementation 'com.jakewharton:butterknife:4.0.1' - annotationProcessor 'com.jakewharton:butterknife:4.0.1' + implementation 'com.jakewharton:butterknife:6.1.0' + annotationProcessor 'com.jakewharton:butterknife:6.1.0' implementation 'com.github.kevinsawicki:http-request:5.6' implementation 'org.codehaus.jackson:jackson-mapper-asl:1.7.4' implementation 'com.google.code.findbugs:jsr305:3.0.2' @@ -105,6 +110,7 @@ dependencies { implementation 'joda-time:joda-time:2.3' implementation 'androidx.multidex:multidex:2.0.0' implementation 'commons-codec:commons-codec:1.13' + implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.0' androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.1' androidTestImplementation 'org.mockito:mockito-core:1.9.5' diff --git a/kegtab/gradle/wrapper/gradle-wrapper.properties b/kegtab/gradle/wrapper/gradle-wrapper.properties index e548cdac..fe923e34 100644 --- a/kegtab/gradle/wrapper/gradle-wrapper.properties +++ b/kegtab/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip diff --git a/kegtab/proguard-project.txt b/kegtab/proguard-project.txt index a061d29e..4612a0af 100644 --- a/kegtab/proguard-project.txt +++ b/kegtab/proguard-project.txt @@ -28,6 +28,9 @@ -dontwarn org.codehaus.jackson.** # jackson -dontwarn android.net.http.** -dontwarn org.joda.** +-dontwarn javax.lang.model.** +-dontwarn javax.annotation.processing.** +-dontwarn com.google.errorprone.annotations.** -dontwarn butterknife.internal.** -dontwarn com.squareup.okhttp.** @@ -45,4 +48,3 @@ -keep class * extends java.util.ListResourceBundle { protected Object[][] getContents(); } - diff --git a/kegtab/src/main/java/org/kegbot/app/CoreActivity.java b/kegtab/src/main/java/org/kegbot/app/CoreActivity.java index bfc47fb1..135c875a 100644 --- a/kegtab/src/main/java/org/kegbot/app/CoreActivity.java +++ b/kegtab/src/main/java/org/kegbot/app/CoreActivity.java @@ -163,22 +163,22 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - Intent intent = new Intent(this, HomeActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(intent); - return true; - case R.id.alertUpdate: - Intent marketIntent = new Intent(Intent.ACTION_VIEW); - marketIntent.setData(Uri.parse("market://details?id=org.kegbot.app")); - PinActivity.startThroughPinActivity(this, marketIntent); - return true; - case R.id.alertGeneral: - AlertActivity.showDialogs(this); - return true; - default: - return super.onOptionsItemSelected(item); + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + Intent intent = new Intent(this, HomeActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + return true; + } else if (itemId == R.id.alertUpdate) { + Intent marketIntent = new Intent(Intent.ACTION_VIEW); + marketIntent.setData(Uri.parse("market://details?id=org.kegbot.app")); + PinActivity.startThroughPinActivity(this, marketIntent); + return true; + } else if (itemId == R.id.alertGeneral) { + AlertActivity.showDialogs(this); + return true; + } else { + return super.onOptionsItemSelected(item); } } diff --git a/kegtab/src/main/java/org/kegbot/app/HomeActivity.java b/kegtab/src/main/java/org/kegbot/app/HomeActivity.java index bd5d53ba..5adaea41 100644 --- a/kegtab/src/main/java/org/kegbot/app/HomeActivity.java +++ b/kegtab/src/main/java/org/kegbot/app/HomeActivity.java @@ -169,20 +169,19 @@ protected void onPause() { @Override public boolean onOptionsItemSelected(MenuItem item) { final int itemId = item.getItemId(); - switch (itemId) { - case R.id.settings: - SettingsActivity.startSettingsActivity(this); - return true; - case R.id.manageTaps: - TapListActivity.startActivity(this); - return true; - case R.id.bugreport: - BugreportActivity.startBugreportActivity(this); - return true; - case android.R.id.home: - return true; - default: - return super.onOptionsItemSelected(item); + if (itemId == R.id.settings) { + SettingsActivity.startSettingsActivity(this); + return true; + } else if (itemId == R.id.manageTaps) { + TapListActivity.startActivity(this); + return true; + } else if (itemId == R.id.bugreport) { + BugreportActivity.startBugreportActivity(this); + return true; + } else if (itemId == android.R.id.home) { + return true; + } else { + return super.onOptionsItemSelected(item); } } diff --git a/kegtab/src/main/java/org/kegbot/app/config/AppConfiguration.java b/kegtab/src/main/java/org/kegbot/app/config/AppConfiguration.java index cbabb0dd..ea8f15c8 100644 --- a/kegtab/src/main/java/org/kegbot/app/config/AppConfiguration.java +++ b/kegtab/src/main/java/org/kegbot/app/config/AppConfiguration.java @@ -294,4 +294,23 @@ public int getNetworkControllerPort() { return Integer.valueOf(get(ConfigKey.NETWORK_CONTROLLER_PORT)).intValue(); } + public String getMqttServer() { + return get(ConfigKey.MQTT_SERVER); + } + + public int getMqttPort() { + return Integer.valueOf(get(ConfigKey.MQTT_PORT)).intValue(); + } + + public String getMqttUsername() { + return get(ConfigKey.MQTT_USERNAME); + } + + public String getMqttPassword() { + return get(ConfigKey.MQTT_PASSWORD); + } + + public String getMqttTopicPrefix() { + return get(ConfigKey.MQTT_TOPIC_PREFIX); + } } diff --git a/kegtab/src/main/java/org/kegbot/app/config/ConfigKey.java b/kegtab/src/main/java/org/kegbot/app/config/ConfigKey.java index b97d0502..fb2c1340 100644 --- a/kegtab/src/main/java/org/kegbot/app/config/ConfigKey.java +++ b/kegtab/src/main/java/org/kegbot/app/config/ConfigKey.java @@ -67,8 +67,13 @@ enum ConfigKey { LAST_USED_KEG_SIZE(""), NETWORK_CONTROLLER_HOST(""), - NETWORK_CONTROLLER_PORT("8321"); + NETWORK_CONTROLLER_PORT("8321"), + MQTT_SERVER(""), + MQTT_PORT("1883"), + MQTT_USERNAME(""), + MQTT_PASSWORD(""), + MQTT_TOPIC_PREFIX("kegbot"); private final String mDefaultValue; @@ -90,4 +95,3 @@ String getDefault() { } } - diff --git a/kegtab/src/main/java/org/kegbot/app/setup/SetupSelectBackendFragment.java b/kegtab/src/main/java/org/kegbot/app/setup/SetupSelectBackendFragment.java index 967804e7..3cb4489f 100644 --- a/kegtab/src/main/java/org/kegbot/app/setup/SetupSelectBackendFragment.java +++ b/kegtab/src/main/java/org/kegbot/app/setup/SetupSelectBackendFragment.java @@ -82,15 +82,12 @@ public String validate() { final RadioGroup group = ButterKnife.findById(mView, R.id.backend_group); final int checkedId = group.getCheckedRadioButtonId(); - switch (checkedId) { - case R.id.radio_backend_local: - prefs.setIsLocalBackend(true); - break; - case R.id.radio_backend_server: - prefs.setIsLocalBackend(false); - break; - default: - return "Please select one of the backend modes."; + if (checkedId == R.id.radio_backend_local) { + prefs.setIsLocalBackend(true); + } else if (checkedId == R.id.radio_backend_server) { + prefs.setIsLocalBackend(false); + } else { + return "Please select one of the backend modes."; } return ""; diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java b/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java index 2027d8af..e66df36b 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java @@ -63,6 +63,9 @@ public class HardwareManager extends Manager { /** All controllers, by operational status. */ private final Map mControllers = Maps.newLinkedHashMap(); + + /** Track which controller names have been notified to prevent duplicate events. */ + private final Set mNotifiedControllerNames = Sets.newHashSet(); private final Set mManagers = Sets.newLinkedHashSet(); private KegboardManager mKegboardManager; @@ -91,6 +94,7 @@ public HardwareManager(Bus bus, Context context, AppConfiguration config) { mManagers.add(mKegboardManager); mManagers.add(new FakeControllerManager(getBus(), mListener)); mManagers.add(new NetworkControllerManager(getBus(), mListener, config)); + mManagers.add(new MQTTControllerManager(getBus(), mListener, config)); } @Override @@ -108,17 +112,58 @@ public void refreshSoon() { } private synchronized void onControllerAttached(Controller controller) { - Log.d(TAG, "Controller attached: " + controller); - if (mControllers.containsKey(controller)) { - Log.w(TAG, "Controller already attached!"); + Log.d(TAG, "Controller attached: " + controller + " with name: " + controller.getName()); + + // Check if a controller with the same name already exists + Controller existingController = null; + for (Controller c : mControllers.keySet()) { + if (c.getName().equals(controller.getName())) { + existingController = c; + break; + } + } + + if (existingController != null) { + // Check if it's actually the same controller instance + if (existingController == controller) { + Log.w(TAG, "Same controller instance '" + controller.getName() + "' attached again! Ignoring duplicate."); + return; + } + + Log.w(TAG, "Controller with name '" + controller.getName() + "' already attached! This appears to be a reconnection, updating controller instance."); + // Remove the old controller instance and replace with new one (for reconnection scenarios) + Boolean wasEnabled = mControllers.get(existingController); + mControllers.remove(existingController); + mControllers.put(controller, wasEnabled); // Preserve the enabled state + // Don't post ControllerAttachedEvent for reconnections to avoid duplicate notifications + return; + } + + // Check if we've already notified about this controller name + if (mNotifiedControllerNames.contains(controller.getName())) { + Log.w(TAG, "Controller with name '" + controller.getName() + "' has already been notified, skipping duplicate notification."); + mControllers.put(controller, Boolean.FALSE); return; } + + // This is a truly new controller + Log.d(TAG, "Adding new controller: " + controller.getName()); mControllers.put(controller, Boolean.FALSE); + mNotifiedControllerNames.add(controller.getName()); postOnMainThread(new ControllerAttachedEvent(controller)); } private synchronized void onControllerEvent(Controller controller, Event event) { - if (!mControllers.containsKey(controller)) { + // Check if a controller with the same name exists + Controller existingController = null; + for (Controller c : mControllers.keySet()) { + if (c.getName().equals(controller.getName())) { + existingController = c; + break; + } + } + + if (existingController == null) { Log.w(TAG, "Received event from unknown controller: " + controller); return; } @@ -131,11 +176,21 @@ private synchronized void onControllerEvent(Controller controller, Event event) } private synchronized void onControllerRemoved(Controller controller) { - if (!mControllers.containsKey(controller)) { + // Find and remove controller with the same name + Controller controllerToRemove = null; + for (Controller c : mControllers.keySet()) { + if (c.getName().equals(controller.getName())) { + controllerToRemove = c; + break; + } + } + + if (controllerToRemove == null) { Log.w(TAG, "Unknown controller was detached: " + controller); return; } - mControllers.remove(controller); + mControllers.remove(controllerToRemove); + mNotifiedControllerNames.remove(controller.getName()); // Clear notification tracking postOnMainThread(new ControllerDetachedEvent(controller)); postAlert(AlertCore.newBuilder("Controller Removed") diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java new file mode 100644 index 00000000..c6596a84 --- /dev/null +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java @@ -0,0 +1,364 @@ +/* + * Copyright 2003-2020 The Kegbot Project contributors + * + * This file is part of the Kegtab package from the Kegbot project. For + * more information on Kegtab or Kegbot, see . + * + * Kegtab is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, version 2. + * + * Kegtab is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with Kegtab. If not, see . + */ + +package org.kegbot.core.hardware; + +import android.os.SystemClock; +import android.util.Log; + +import com.google.common.base.Preconditions; + +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.kegbot.core.FlowMeter; +import org.kegbot.core.ThermoSensor; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * MQTT-based controller implementation that connects to a MQTT broker + * and subscribes to topics for kegboard messages. + */ +public class MQTTController implements Controller, MqttCallback { + + private static final String TAG = MQTTController.class.getSimpleName(); + + // MQTT topic constants (base topics, prefix will be applied dynamically) + private static final String TOPIC_METER = "meter"; + private static final String TOPIC_TEMP = "temp"; + + private final String mBrokerUrl; + private final String mClientId; + private final String mTopicPrefix; + private final String mUsername; + private final String mPassword; + private final ControllerManager.Listener mListener; + + private String mStatus = Controller.STATUS_UNKNOWN; + private String mSerialNumber = ""; + + private MqttClient mMqttClient; + private ExecutorService mExecutor; + private volatile boolean mConnected; + + private AtomicBoolean mStopped = new AtomicBoolean(true); + private final Map mFlowMeters = new ConcurrentHashMap<>(); + private final Map mThermoSensors = new ConcurrentHashMap<>(); + + public MQTTController(String brokerUrl, String clientId, String topicPrefix, + String username, String password, ControllerManager.Listener listener) { + mBrokerUrl = brokerUrl; + mClientId = clientId; + mTopicPrefix = topicPrefix != null ? topicPrefix : ""; + mUsername = username; + mPassword = password; + mListener = listener; + mConnected = false; + } + + void start() { + Log.d(TAG, "Starting MQTT Controller!"); + Preconditions.checkState(mStopped.compareAndSet(true, false)); + + mExecutor = Executors.newSingleThreadExecutor(); + mExecutor.execute(this::mqttWorker); + } + + void stop() { + Preconditions.checkState(mStopped.compareAndSet(false, true)); + Log.d(TAG, "MQTT Controller stopping"); + disconnect(); + if (mExecutor != null && !mExecutor.isShutdown()) { + mExecutor.shutdown(); + } + } + + private void mqttWorker() { + Log.d(TAG, "MQTT Worker starting."); + + while (!mStopped.get()) { + if (!mConnected) { + try { + connect(); + } catch (MqttException e) { + Log.w(TAG, "MQTT connection failed: " + e.getMessage()); + disconnect(); + SystemClock.sleep(5000); // Wait 5 seconds before retry + continue; + } + } + + // Keep the worker alive to maintain connection + // The actual message handling is done via callback + SystemClock.sleep(1000); + } + Log.d(TAG, "MQTT Worker exiting ..."); + } + + private synchronized void connect() throws MqttException { + if (mConnected) { + Log.d(TAG, "Already connected to MQTT broker"); + return; + } + + Log.d(TAG, "Connecting to MQTT broker: " + mBrokerUrl); + + try { + mMqttClient = new MqttClient(mBrokerUrl, mClientId, new MemoryPersistence()); + mMqttClient.setCallback(this); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setCleanSession(true); + options.setConnectionTimeout(30); + options.setKeepAliveInterval(60); + options.setAutomaticReconnect(false); // Handle reconnection manually + + // Set username and password if provided + if (mUsername != null && !mUsername.isEmpty()) { + options.setUserName(mUsername); + if (mPassword != null && !mPassword.isEmpty()) { + options.setPassword(mPassword.toCharArray()); + } + } + + mMqttClient.connect(options); + Log.d(TAG, "MQTT client connected successfully"); + + // Subscribe to topics + subscribeToTopics(); + + // Set device as connected immediately since we don't have an info topic + mSerialNumber = mClientId; // Use client ID as serial number + mStatus = Controller.STATUS_OK; + mConnected = true; + Log.d(TAG, "Setting mConnected = true"); + + // Always notify about attachment - let HardwareManager handle duplicate prevention + Log.d(TAG, "MQTT Controller connected, notifying attachment with name: " + getName()); + Log.d(TAG, "Controller instance: " + this); + mListener.onControllerAttached(this); + Log.d(TAG, "Successfully connected to MQTT broker"); + + } catch (MqttException e) { + Log.e(TAG, "Failed to connect to MQTT broker: " + e.getMessage()); + mConnected = false; + throw e; + } + } + + private void subscribeToTopics() throws MqttException { + if (mMqttClient != null && mMqttClient.isConnected()) { + // Subscribe to all numbered meter and temp topics using wildcards + String meterTopic = mTopicPrefix.isEmpty() ? TOPIC_METER + "/+" : mTopicPrefix + "/" + TOPIC_METER + "/+"; + String tempTopic = mTopicPrefix.isEmpty() ? TOPIC_TEMP + "/+" : mTopicPrefix + "/" + TOPIC_TEMP + "/+"; + + mMqttClient.subscribe(meterTopic, 1); + mMqttClient.subscribe(tempTopic, 1); + + Log.d(TAG, "Subscribed to topics: " + meterTopic + ", " + tempTopic); + } + } + + private synchronized void disconnect() { + Log.d(TAG, "Disconnect called, mConnected = " + mConnected); + if (!mConnected) { + return; + } + mConnected = false; + Log.d(TAG, "Setting mConnected = false"); + + if (mMqttClient != null) { + try { + if (mMqttClient.isConnected()) { + mMqttClient.disconnect(); + } + mMqttClient.close(); + } catch (MqttException e) { + Log.w(TAG, "Error disconnecting MQTT client: " + e.getMessage()); + } finally { + mMqttClient = null; + } + } + + mStatus = Controller.STATUS_UNKNOWN; + mListener.onControllerRemoved(this); + } + + // MqttCallback implementation + @Override + public void connectionLost(Throwable cause) { + Log.w(TAG, "MQTT connection lost: " + (cause != null ? cause.getMessage() : "Unknown")); + mConnected = false; + mStatus = Controller.STATUS_UNRESPONSIVE; + mListener.onControllerRemoved(this); + // Worker thread will detect mConnected == false and attempt to reconnect. + } + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + String payload = new String(message.getPayload()); + Log.d(TAG, "Received message on topic '" + topic + "': " + payload); + + // Parse the message based on topic structure (e.g., kegbot/meter/0, kegbot/temp/1) + if (topic.contains("/" + TOPIC_METER + "/")) { + String meterIndex = extractTopicIndex(topic, TOPIC_METER); + if (meterIndex != null) { + handleMeterMessage(meterIndex, payload); + } else { + Log.w(TAG, "Could not extract meter index from topic: " + topic); + } + } else if (topic.contains("/" + TOPIC_TEMP + "/")) { + String tempIndex = extractTopicIndex(topic, TOPIC_TEMP); + if (tempIndex != null) { + handleTempMessage(tempIndex, payload); + } else { + Log.w(TAG, "Could not extract temp index from topic: " + topic); + } + } + } + + private String extractTopicIndex(String topic, String topicType) { + // Extract the index from topics like "kegbot/meter/0" or "device/temp/1" + String[] parts = topic.split("/"); + for (int i = 0; i < parts.length - 1; i++) { + if (parts[i].equals(topicType)) { + return parts[i + 1]; + } + } + return null; + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // Not used for incoming messages + } + + private void handleMeterMessage(String meterIndex, String payload) { + Log.d(TAG, "Processing meter " + meterIndex + " message: " + payload); + // Each topic contains data for a single meter, payload is just the tick value + try { + final long ticks = Long.parseLong(payload.trim()); + final String meterName = buildMeterName(meterIndex); + + if (!mFlowMeters.containsKey(meterName)) { + mFlowMeters.put(meterName, new FlowMeter(meterName)); + } + final FlowMeter meter = mFlowMeters.get(meterName); + + updateMeterIfChanged(meter, ticks); + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid meter value for meter " + meterIndex + ": " + payload); + } + } + + private void handleTempMessage(String tempIndex, String payload) { + Log.d(TAG, "Processing temp " + tempIndex + " message: " + payload); + // Each topic contains data for a single temperature sensor, payload is just the temperature value + try { + final double temp = Double.parseDouble(payload.trim()); + final String tempName = buildThermoName(tempIndex); + + if (!mThermoSensors.containsKey(tempName)) { + mThermoSensors.put(tempName, new ThermoSensor(tempName)); + } + final ThermoSensor sensor = mThermoSensors.get(tempName); + + updateThermoIfChanged(sensor, temp); + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid temperature value for temp " + tempIndex + ": " + payload); + } + } + + private String buildMeterName(String meterIndex) { + // Build name from index: "0" -> "kegboard-mqtt-clientId.flow0" + return getName() + ".flow" + meterIndex; + } + + private String buildThermoName(String tempIndex) { + // Build name from index: "0" -> "kegboard-mqtt-clientId.thermo-0" + return getName() + ".thermo-" + tempIndex; + } + + private void updateMeterIfChanged(FlowMeter meter, long newTicks) { + final long existingTicks = meter.getTicks(); + if (newTicks != existingTicks) { + meter.setTicks(newTicks); + mListener.onControllerEvent(this, new MeterUpdateEvent(meter)); + } + } + + private void updateThermoIfChanged(ThermoSensor sensor, double newTemp) { + final double existingTemp = sensor.getTemperatureC(); + if (newTemp != existingTemp) { + sensor.setTemperatureC(newTemp); + mListener.onControllerEvent(this, new ThermoSensorUpdateEvent(sensor)); + } + } + + @Override + public String getStatus() { + return mStatus; + } + + @Override + public String getName() { + return "kegboard-mqtt-" + getSerialNumber(); + } + + @Override + public String getSerialNumber() { + return mSerialNumber; + } + + @Override + public String getDeviceType() { + return "MQTT Kegboard"; + } + + @Override + public Collection getFlowMeters() { + return mFlowMeters.values(); + } + + @Override + public FlowMeter getFlowMeter(String meterName) { + return mFlowMeters.get(meterName); + } + + @Override + public Collection getThermoSensors() { + return mThermoSensors.values(); + } + + @Override + public ThermoSensor getThermoSensor(String sensorName) { + return mThermoSensors.get(sensorName); + } + +} diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java new file mode 100644 index 00000000..8ac5e7fc --- /dev/null +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java @@ -0,0 +1,106 @@ +/* + * Copyright 2003-2020 The Kegbot Project contributors + * + * This file is part of the Kegtab package from the Kegbot project. For + * more information on Kegtab or Kegbot, see . + * + * Kegtab is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, version 2. + * + * Kegtab is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with Kegtab. If not, see . + */ + +package org.kegbot.core.hardware; + +import android.util.Log; + +import com.google.common.base.Strings; +import com.squareup.otto.Bus; + +import org.kegbot.app.config.AppConfiguration; +import org.kegbot.app.util.IndentingPrintWriter; + +public class MQTTControllerManager implements ControllerManager { + private static final String TAG = MQTTControllerManager.class.getSimpleName(); + + private final Bus mBus; + private final Listener mListener; + private final AppConfiguration mConfig; + private MQTTController mController; + + public MQTTControllerManager(Bus bus, Listener listener, AppConfiguration config) { + mBus = bus; + mListener = listener; + mConfig = config; + + mController = null; + } + + @Override + public void start() { + mBus.register(this); + + final String mqttServer = mConfig.getMqttServer(); + if (!Strings.isNullOrEmpty(mqttServer)) { + Log.i(TAG, "MQTT controller is configured."); + + // Build the broker URL + String brokerUrl = "tcp://" + mqttServer + ":" + mConfig.getMqttPort(); + + // Generate a consistent client ID based on server and port (instead of random UUID) + String clientId = "kegbot-android-" + Math.abs((mqttServer + ":" + mConfig.getMqttPort()).hashCode()); + + // Get configuration values + String topicPrefix = mConfig.getMqttTopicPrefix(); + String username = mConfig.getMqttUsername(); + String password = mConfig.getMqttPassword(); + + // Create and start the controller + mController = new MQTTController(brokerUrl, clientId, topicPrefix, username, password, mListener); + mController.start(); + + Log.i(TAG, "MQTT controller started with broker: " + brokerUrl + + ", topic prefix: " + topicPrefix + + ", client ID: " + clientId); + } else { + Log.i(TAG, "MQTT controller is NOT configured."); + mController = null; + } + } + + @Override + public void stop() { + if (mController != null) { + mController.stop(); + mController = null; + } + mBus.unregister(this); + } + + @Override + public void refreshSoon() { + // MQTT connections are persistent, no refresh needed + } + + @Override + public void dump(IndentingPrintWriter writer) { + writer.printPair("mqttController", mController); + if (mController != null) { + writer.printPair("mqttBroker", mConfig.getMqttServer() + ":" + mConfig.getMqttPort()); + writer.printPair("mqttTopicPrefix", mConfig.getMqttTopicPrefix()); + writer.printPair("mqttUsername", mConfig.getMqttUsername()); + writer.printPair("mqttStatus", mController.getStatus()); + writer.printPair("mqttSerialNumber", mController.getSerialNumber()); + writer.printPair("mqttFlowMeters", mController.getFlowMeters().size()); + writer.printPair("mqttThermoSensors", mController.getThermoSensors().size()); + } + } + +} diff --git a/kegtab/src/main/res/xml/settings_kegerator.xml b/kegtab/src/main/res/xml/settings_kegerator.xml index d0f475af..0dac4b2a 100644 --- a/kegtab/src/main/res/xml/settings_kegerator.xml +++ b/kegtab/src/main/res/xml/settings_kegerator.xml @@ -66,6 +66,43 @@ android:title="Kegboard Port"> + + + + + + + + + + + +