From b3b71c71b2837d719a43b3e442a5884d5beeb304 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Tue, 23 Sep 2025 13:55:02 -0400 Subject: [PATCH 01/14] Added MQTT configuration values. --- .../kegbot/app/config/AppConfiguration.java | 19 ++++++++++ .../java/org/kegbot/app/config/ConfigKey.java | 8 +++- .../src/main/res/xml/settings_kegerator.xml | 37 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) 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/res/xml/settings_kegerator.xml b/kegtab/src/main/res/xml/settings_kegerator.xml index d0f475af..7e4610e1 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"> + + + + + + + + + + + + Date: Tue, 23 Sep 2025 13:55:13 -0400 Subject: [PATCH 02/14] Added MQTTController. --- build.gradle | 3 + kegtab/build.gradle | 5 + .../kegbot/core/hardware/MQTTController.java | 334 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java diff --git a/build.gradle b/build.gradle index 48c9d35d..7c529412 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,9 @@ buildscript { repositories { mavenCentral() google() + maven { + url "https://repo.eclipse.org/content/repositories/paho-snapshots/" + } } dependencies { classpath 'com.android.tools.build:gradle:7.2.1' diff --git a/kegtab/build.gradle b/kegtab/build.gradle index 70af6bd3..9f98ebd3 100644 --- a/kegtab/build.gradle +++ b/kegtab/build.gradle @@ -36,6 +36,9 @@ repositories { url 'https://maven.google.com/' name 'Google' } + maven { + url "https://repo.eclipse.org/content/repositories/paho-snapshots/" + } google() } @@ -105,6 +108,8 @@ 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' + implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1' androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.1' androidTestImplementation 'org.mockito:mockito-core:1.9.5' 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..d56219b5 --- /dev/null +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java @@ -0,0 +1,334 @@ +/* + * 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 androidx.annotation.Nullable; +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Maps; + +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.Collections; +import java.util.Map; +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 ControllerManager.Listener mListener; + + private String mStatus = Controller.STATUS_UNKNOWN; + private String mSerialNumber = ""; + + private MqttClient mMqttClient; + private ExecutorService mExecutor; + private boolean mConnected; + + private AtomicBoolean mStopped = new AtomicBoolean(true); + private final Map mFlowMeters = Maps.newLinkedHashMap(); + private final Map mThermoSensors = Maps.newLinkedHashMap(); + + public MQTTController(String brokerUrl, String clientId, String topicPrefix, + ControllerManager.Listener listener) { + mBrokerUrl = brokerUrl; + mClientId = clientId; + mTopicPrefix = topicPrefix != null ? topicPrefix : ""; + 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)); + 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 void connect() throws MqttException { + if (mConnected) { + 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(true); + + mMqttClient.connect(options); + + // Subscribe to topics + subscribeToTopics(); + + mConnected = true; + // 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; + 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 void disconnect() { + if (!mConnected) { + return; + } + 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; + } + + @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); + handleMeterMessage(meterIndex, payload); + } else if (topic.contains("/" + TOPIC_TEMP + "/")) { + String tempIndex = extractTopicIndex(topic, TOPIC_TEMP); + handleTempMessage(tempIndex, payload); + } + } + + 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 the index after the topic type + } + } + return "0"; // Default to 0 if index not found + } + + @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); + } + +} From 49b12d28fd66c8e27639c0439a715058ced22692 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Tue, 23 Sep 2025 14:32:19 -0400 Subject: [PATCH 03/14] Added MQTTControllerManager. --- .../kegbot/core/hardware/HardwareManager.java | 1 + .../kegbot/core/hardware/MQTTController.java | 14 ++- .../core/hardware/MQTTControllerManager.java | 118 ++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java 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..e7961324 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/HardwareManager.java @@ -91,6 +91,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 diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java index d56219b5..1889254f 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java @@ -58,6 +58,8 @@ public class MQTTController implements Controller, MqttCallback { 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; @@ -72,10 +74,12 @@ public class MQTTController implements Controller, MqttCallback { private final Map mThermoSensors = Maps.newLinkedHashMap(); public MQTTController(String brokerUrl, String clientId, String topicPrefix, - ControllerManager.Listener listener) { + String username, String password, ControllerManager.Listener listener) { mBrokerUrl = brokerUrl; mClientId = clientId; mTopicPrefix = topicPrefix != null ? topicPrefix : ""; + mUsername = username; + mPassword = password; mListener = listener; mConnected = false; } @@ -134,6 +138,14 @@ private void connect() throws MqttException { options.setConnectionTimeout(30); options.setKeepAliveInterval(60); options.setAutomaticReconnect(true); + + // 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); 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..5797347b --- /dev/null +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java @@ -0,0 +1,118 @@ +/* + * 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 com.squareup.otto.Subscribe; + +import org.kegbot.app.config.AppConfiguration; +import org.kegbot.app.util.IndentingPrintWriter; + +import java.util.UUID; + +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 unique client ID + String clientId = "kegbot-android-" + UUID.randomUUID().toString().substring(0, 8); + + // 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()); + } + } + + @Subscribe + public void onFakeControllerEvent(final FakeControllerEvent event) { + if (event.isAdded()) { + mListener.onControllerAttached(event.getController()); + } else { + mListener.onControllerRemoved(event.getController()); + } + } + +} From 57936f5dd410128631bd39d587e4927e8dd53757 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Tue, 23 Sep 2025 14:52:13 -0400 Subject: [PATCH 04/14] Upgrade to Gradle 8.9. --- build.gradle | 2 +- gradle.properties | 2 ++ gradle/wrapper/gradle-wrapper.properties | 2 +- kegtab/build.gradle | 3 ++- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 7c529412..4b0278f8 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { } } 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..b83a69bb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,5 @@ android.enableJetifier=true android.useAndroidX=true +# Set Gradle to use Java 17 for Android Gradle Plugin 8.x compatibility +org.gradle.java.home=/Users/rplankenhorn/.local/share/mise/installs/java/zulu-17.46.19.0/zulu-17.jdk/Contents/Home diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bbe38325..9bc41263 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.9-all.zip diff --git a/kegtab/build.gradle b/kegtab/build.gradle index 9f98ebd3..b371c922 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' } } @@ -43,6 +43,7 @@ repositories { } android { + namespace 'org.kegbot.app' compileSdkVersion 30 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 From 18074af756f784b53def871d8197ef25ffead26a Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Tue, 23 Sep 2025 21:15:03 -0400 Subject: [PATCH 05/14] Fixed build errors. --- kegtab/build.gradle | 8 +++-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- kegtab/proguard-project.txt | 4 ++- .../java/org/kegbot/app/CoreActivity.java | 32 +++++++++---------- .../java/org/kegbot/app/HomeActivity.java | 27 ++++++++-------- .../app/setup/SetupSelectBackendFragment.java | 15 ++++----- 6 files changed, 45 insertions(+), 43 deletions(-) diff --git a/kegtab/build.gradle b/kegtab/build.gradle index b371c922..96c28c72 100644 --- a/kegtab/build.gradle +++ b/kegtab/build.gradle @@ -59,6 +59,10 @@ android { testInstrumentationRunner "android.test.InstrumentationTestRunner" } + buildFeatures { + buildConfig = true + } + lintOptions { checkReleaseBuilds true abortOnError false @@ -100,8 +104,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' 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/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 ""; From 621ee0a404cd551e0e1b5b8174417061218d292a Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Tue, 23 Sep 2025 21:31:25 -0400 Subject: [PATCH 06/14] Ignoring vscode directory. --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From ad57dcebf4f95676ad5720e33615c9122c0438a8 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 19:58:17 -0400 Subject: [PATCH 07/14] Generate a consistent client ID based on server and port (instead of random UUID). --- .../org/kegbot/core/hardware/MQTTControllerManager.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java index 5797347b..9d99a03f 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java @@ -28,8 +28,6 @@ import org.kegbot.app.config.AppConfiguration; import org.kegbot.app.util.IndentingPrintWriter; -import java.util.UUID; - public class MQTTControllerManager implements ControllerManager { private static final String TAG = MQTTControllerManager.class.getSimpleName(); @@ -57,8 +55,8 @@ public void start() { // Build the broker URL String brokerUrl = "tcp://" + mqttServer + ":" + mConfig.getMqttPort(); - // Generate a unique client ID - String clientId = "kegbot-android-" + UUID.randomUUID().toString().substring(0, 8); + // Generate a consistent client ID based on server and port (instead of random UUID) + String clientId = "kegbot-android-" + mqttServer.replace(".", "-") + "-" + mConfig.getMqttPort(); // Get configuration values String topicPrefix = mConfig.getMqttTopicPrefix(); From 4f1c8d1c8970615386970e2b81b89a3dc10a44b8 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 20:04:31 -0400 Subject: [PATCH 08/14] Fixing issue with asking to add MQTT controller multiple times. --- .../kegbot/core/hardware/HardwareManager.java | 66 +++++++++++++++++-- .../kegbot/core/hardware/MQTTController.java | 22 +++++-- 2 files changed, 77 insertions(+), 11 deletions(-) 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 e7961324..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; @@ -109,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; } @@ -132,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 index 1889254f..4ae72f24 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java @@ -67,7 +67,7 @@ public class MQTTController implements Controller, MqttCallback { private MqttClient mMqttClient; private ExecutorService mExecutor; - private boolean mConnected; + private volatile boolean mConnected; private AtomicBoolean mStopped = new AtomicBoolean(true); private final Map mFlowMeters = Maps.newLinkedHashMap(); @@ -94,6 +94,7 @@ void start() { void stop() { Preconditions.checkState(mStopped.compareAndSet(false, true)); + Log.d(TAG, "MQTT Controller stopping"); disconnect(); if (mExecutor != null && !mExecutor.isShutdown()) { mExecutor.shutdown(); @@ -122,8 +123,9 @@ private void mqttWorker() { Log.d(TAG, "MQTT Worker exiting ..."); } - private void connect() throws MqttException { + private synchronized void connect() throws MqttException { if (mConnected) { + Log.d(TAG, "Already connected to MQTT broker"); return; } @@ -137,7 +139,7 @@ private void connect() throws MqttException { options.setCleanSession(true); options.setConnectionTimeout(30); options.setKeepAliveInterval(60); - options.setAutomaticReconnect(true); + options.setAutomaticReconnect(false); // Handle reconnection manually // Set username and password if provided if (mUsername != null && !mUsername.isEmpty()) { @@ -148,14 +150,20 @@ private void connect() throws MqttException { } mMqttClient.connect(options); + Log.d(TAG, "MQTT client connected successfully"); // Subscribe to topics subscribeToTopics(); - mConnected = true; // 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"); @@ -179,11 +187,13 @@ private void subscribeToTopics() throws MqttException { } } - private void disconnect() { + 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 { @@ -206,8 +216,10 @@ private void disconnect() { @Override public void connectionLost(Throwable cause) { Log.w(TAG, "MQTT connection lost: " + (cause != null ? cause.getMessage() : "Unknown")); + Log.d(TAG, "Setting mConnected = false due to connection lost"); mConnected = false; mStatus = Controller.STATUS_UNRESPONSIVE; + // Don't call disconnect() here to avoid race conditions - let the worker thread handle reconnection } @Override From 1c3f732ca0d77e327b85300f59927e54c2198a42 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 20:21:24 -0400 Subject: [PATCH 09/14] Using hash when creating client id instead of raw mqttServer and port. --- .../java/org/kegbot/core/hardware/MQTTControllerManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java index 9d99a03f..ad82a4ac 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java @@ -56,7 +56,7 @@ public void start() { String brokerUrl = "tcp://" + mqttServer + ":" + mConfig.getMqttPort(); // Generate a consistent client ID based on server and port (instead of random UUID) - String clientId = "kegbot-android-" + mqttServer.replace(".", "-") + "-" + mConfig.getMqttPort(); + String clientId = "kegbot-android-" + Math.abs((mqttServer + ":" + mConfig.getMqttPort()).hashCode()); // Get configuration values String topicPrefix = mConfig.getMqttTopicPrefix(); From c19c8de698d6873d135321350ab904946e91d60d Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 20:32:12 -0400 Subject: [PATCH 10/14] Updated gradle.properties to not hard code org.gradle.java.home. --- README.md | 29 +++++++++++++++++++++++------ gradle.properties | 3 ++- local.properties.template | 11 +++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 local.properties.template 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/gradle.properties b/gradle.properties index b83a69bb..b088e4b5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,5 @@ android.enableJetifier=true android.useAndroidX=true # Set Gradle to use Java 17 for Android Gradle Plugin 8.x compatibility -org.gradle.java.home=/Users/rplankenhorn/.local/share/mise/installs/java/zulu-17.46.19.0/zulu-17.jdk/Contents/Home +# 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/local.properties.template b/local.properties.template new file mode 100644 index 00000000..44cd2576 --- /dev/null +++ b/local.properties.template @@ -0,0 +1,11 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the Android SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +sdk.dir=/path/to/your/android/sdk + +# Optional: Set Java home for Gradle (if not using JAVA_HOME environment variable) +# This should point to a Java 17+ installation for Android Gradle Plugin 8.x compatibility +# org.gradle.java.home=/path/to/your/java17/installation From e6f3740926caf54f36f342bc072c821962fbeeec Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 20:38:54 -0400 Subject: [PATCH 11/14] Updated workflow to build. --- .github/workflows/build.yml | 75 ++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 50acbbd6..035810b6 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 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + 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 From 91d5ee2ce2b3d076d2ea1d97fdebd1492ecd798f Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 20:57:31 -0400 Subject: [PATCH 12/14] Using gradle 8.0 everywhere. --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9bc41263..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-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip From 81139d7abbfe6c59cd01d9371cf45504a9ec1ce8 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Wed, 24 Sep 2025 21:07:04 -0400 Subject: [PATCH 13/14] Setting up Java. --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 035810b6..92e81396 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,10 +15,10 @@ jobs: - name: Checkout the code uses: actions/checkout@v5 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Cache Gradle packages From bfc1c83c313f3fea98ca927f55d4962a5f28cef7 Mon Sep 17 00:00:00 2001 From: Robbie Plankenhorn Date: Mon, 23 Mar 2026 10:54:18 -0400 Subject: [PATCH 14/14] Fix MQTT controller bugs and thread-safety issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix connectionLost() to call mListener.onControllerRemoved() so HardwareManager is properly notified when the broker drops the connection - Replace LinkedHashMap with ConcurrentHashMap for mFlowMeters and mThermoSensors to prevent ConcurrentModificationException between the MQTT callback thread and readers - Fix extractTopicIndex() to return null instead of silently defaulting to "0", and log a warning in callers when index is missing - Remove onFakeControllerEvent() from MQTTControllerManager — it was copied from another manager and doesn't belong here - Remove unused org.eclipse.paho.android.service dependency and the Paho snapshots Maven repo (only mqttv3 is used directly) - Remove unused @Nullable and Collections imports from MQTTController - Add "Restart app to apply" note to MQTT Server settings summary Co-Authored-By: Claude Sonnet 4.6 --- kegtab/build.gradle | 8 ++---- .../kegbot/core/hardware/MQTTController.java | 28 +++++++++++-------- .../core/hardware/MQTTControllerManager.java | 10 ------- .../src/main/res/xml/settings_kegerator.xml | 2 +- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/kegtab/build.gradle b/kegtab/build.gradle index 96c28c72..8fc3153f 100644 --- a/kegtab/build.gradle +++ b/kegtab/build.gradle @@ -36,15 +36,12 @@ repositories { url 'https://maven.google.com/' name 'Google' } - maven { - url "https://repo.eclipse.org/content/repositories/paho-snapshots/" - } google() } android { namespace 'org.kegbot.app' - compileSdkVersion 30 + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -52,7 +49,7 @@ android { defaultConfig { minSdkVersion 17 - targetSdkVersion 30 + targetSdkVersion 33 multiDexEnabled true testApplicationId "org.kegbot.app.test" @@ -114,7 +111,6 @@ dependencies { 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' - implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1' androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.1' androidTestImplementation 'org.mockito:mockito-core:1.9.5' diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java index 4ae72f24..c6596a84 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTController.java @@ -20,11 +20,9 @@ package org.kegbot.core.hardware; import android.os.SystemClock; -import androidx.annotation.Nullable; import android.util.Log; import com.google.common.base.Preconditions; -import com.google.common.collect.Maps; import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; import org.eclipse.paho.client.mqttv3.MqttCallback; @@ -37,8 +35,8 @@ import org.kegbot.core.ThermoSensor; import java.util.Collection; -import java.util.Collections; 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; @@ -70,8 +68,8 @@ public class MQTTController implements Controller, MqttCallback { private volatile boolean mConnected; private AtomicBoolean mStopped = new AtomicBoolean(true); - private final Map mFlowMeters = Maps.newLinkedHashMap(); - private final Map mThermoSensors = Maps.newLinkedHashMap(); + 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) { @@ -216,10 +214,10 @@ private synchronized void disconnect() { @Override public void connectionLost(Throwable cause) { Log.w(TAG, "MQTT connection lost: " + (cause != null ? cause.getMessage() : "Unknown")); - Log.d(TAG, "Setting mConnected = false due to connection lost"); mConnected = false; mStatus = Controller.STATUS_UNRESPONSIVE; - // Don't call disconnect() here to avoid race conditions - let the worker thread handle reconnection + mListener.onControllerRemoved(this); + // Worker thread will detect mConnected == false and attempt to reconnect. } @Override @@ -230,10 +228,18 @@ public void messageArrived(String topic, MqttMessage message) throws Exception { // 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); - handleMeterMessage(meterIndex, payload); + 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); - handleTempMessage(tempIndex, payload); + if (tempIndex != null) { + handleTempMessage(tempIndex, payload); + } else { + Log.w(TAG, "Could not extract temp index from topic: " + topic); + } } } @@ -242,10 +248,10 @@ private String extractTopicIndex(String topic, String topicType) { String[] parts = topic.split("/"); for (int i = 0; i < parts.length - 1; i++) { if (parts[i].equals(topicType)) { - return parts[i + 1]; // Return the index after the topic type + return parts[i + 1]; } } - return "0"; // Default to 0 if index not found + return null; } @Override diff --git a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java index ad82a4ac..8ac5e7fc 100644 --- a/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java +++ b/kegtab/src/main/java/org/kegbot/core/hardware/MQTTControllerManager.java @@ -23,7 +23,6 @@ import com.google.common.base.Strings; import com.squareup.otto.Bus; -import com.squareup.otto.Subscribe; import org.kegbot.app.config.AppConfiguration; import org.kegbot.app.util.IndentingPrintWriter; @@ -104,13 +103,4 @@ public void dump(IndentingPrintWriter writer) { } } - @Subscribe - public void onFakeControllerEvent(final FakeControllerEvent event) { - if (event.isAdded()) { - mListener.onControllerAttached(event.getController()); - } else { - mListener.onControllerRemoved(event.getController()); - } - } - } diff --git a/kegtab/src/main/res/xml/settings_kegerator.xml b/kegtab/src/main/res/xml/settings_kegerator.xml index 7e4610e1..0dac4b2a 100644 --- a/kegtab/src/main/res/xml/settings_kegerator.xml +++ b/kegtab/src/main/res/xml/settings_kegerator.xml @@ -71,7 +71,7 @@ android:defaultValue="" android:inputType="text" android:key="config:MQTT_SERVER" - android:summary="IP or hostname of the MQTT server" + android:summary="IP or hostname of the MQTT server. Restart app to apply." android:title="MQTT Server">