fix: apply beacon switches at runtime

This commit is contained in:
Marc Froehlich committed 2026-11-04 01:09:26 +01:00
1 parent 4d96822c0c
commit b8bb86977f
30 files changed
+903 -133

No files matched your search

@@ -0,0 +1,75 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
/** Keeps category failures and builds the single status shown for both beacons. */
final class BeaconStatus {
static final String SOURCE_NAME = ChatController.BEACON_STATUS_SOURCE;
private boolean mainInvalid;
private boolean secondInvalid;
private String activity = "Beacon configuration loaded.";
/** Records the result of one complete timer run. */
synchronized void recordRun(
boolean mainInvalid,
boolean secondInvalid,
String activity
) {
this.mainInvalid = mainInvalid;
this.secondInvalid = secondInvalid;
this.activity = activity;
}
/** Updates the explanation without hiding a still-relevant category failure. */
synchronized void recordActivity(String activity) {
this.activity = activity;
}
/** Builds a fresh aggregate from the current preferences and remembered failures. */
synchronized ThreadStateMessage message(ChatPreferences preferences) {
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean mainEnabled = preferences.isBcn_beaconsEnabledMainCat();
boolean secondEnabled = secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat();
int available = secondAvailable ? 2 : 1;
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
boolean critical = (mainInvalid && mainEnabled)
|| (secondInvalid && secondEnabled);
String description;
if (critical) {
description = "FAILED";
} else if (enabled == 0) {
description = "off";
} else if (enabled == available) {
description = "on";
} else {
description = enabled + "/" + available;
}
String details = "Main category beacon: " + onOff(mainEnabled)
+ "; second category beacon: "
+ (secondAvailable
? onOff(secondEnabled)
: "unavailable");
String information = activity == null || activity.isBlank()
? details
: activity + "\n" + details;
ThreadStateMessage message = new ThreadStateMessage(
SOURCE_NAME,
enabled > 0,
information,
critical
);
message.setRunningInformationTextDescription(description);
return message;
}
private static String onOff(boolean enabled) {
return enabled ? "on" : "off";
}
}
@@ -4,7 +4,6 @@ import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
/**
* Sends the configured public-chat beacons for both active chat categories.
@@ -20,10 +19,9 @@ import kst4contest.model.ThreadStateMessage;
*/
public class BeaconTask extends TimerTask {
private static final String THREAD_NICKNAME = "MyBeacon";
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
private final BeaconStatus beaconStatus;
/**
* Creates one execution of the shared beacon timer.
@@ -37,30 +35,34 @@ public class BeaconTask extends TimerTask {
) {
this.chatController = chatController;
this.callbackToController = callbackToController;
this.beaconStatus = chatController.beaconStatusState();
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
Attempt main = sendMainCategoryBeacon();
Attempt second = sendSecondCategoryBeacon();
String activity = "Main category: " + main.description
+ "; second category: " + second.description;
beaconStatus.recordRun(
main == Attempt.INVALID,
second == Attempt.INVALID,
activity
);
callbackToController.onThreadStatus(
BeaconStatus.SOURCE_NAME,
beaconStatus.message(chatController.getChatPreferences())
);
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon() {
private Attempt sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -72,13 +74,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -88,31 +84,19 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
return Attempt.SENT;
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
private Attempt sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) {
return Attempt.UNAVAILABLE;
}
if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -124,13 +108,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -140,13 +118,7 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
return Attempt.SENT;
}
/**
@@ -199,24 +171,16 @@ public class BeaconTask extends TimerTask {
}
}
/**
* Forwards one state update to the existing thread-status display.
*/
private void reportStatus(
String threadName,
boolean running,
String information,
boolean criticalState
) {
ThreadStateMessage stateMessage = new ThreadStateMessage(
threadName,
running,
information,
criticalState
);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
private enum Attempt {
SENT("sent"),
OFF("off"),
UNAVAILABLE("unavailable"),
INVALID("invalid text");
private final String description;
Attempt(String description) {
this.description = description;
}
}
}
}
@@ -64,9 +64,11 @@ public class ChatController implements ThreadStatusCallback {
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private final BeaconStatus beaconStatus = new BeaconStatus();
private Consumer<Double> viewRotorCallback;
@@ -3381,6 +3383,64 @@ private SimpleRoster<String>
this.dbHandler = dbHandler;
}
/** Applies the main-category beacon switch to the running session immediately. */
public void setMainBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/** Applies the second-category beacon switch to the running session immediately. */
public void setSecondBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/**
* Toggles every beacon usable in the current chat configuration.
*
* <p>The dormant second-category preference is deliberately left unchanged when
* the second chat is disabled. Enabling that chat later therefore restores the
* operator's previous category-specific choice.</p>
*/
public void toggleUsableBeacons() {
ChatPreferences preferences = getChatPreferences();
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat()
|| (secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat());
boolean enable = !anyEnabled;
preferences.setBcn_beaconsEnabledMainCat(enable);
if (secondAvailable) {
preferences.setBcn_beaconsEnabledSecondCat(enable);
}
publishBeaconStatus("Beacon status toggled from the main window.");
}
/** Publishes the loaded state without changing either category switch. */
public void refreshBeaconStatus() {
publishBeaconStatus("Beacon configuration loaded.");
}
/** Returns the shared state used by the timer and manual controls. */
BeaconStatus beaconStatusState() {
return beaconStatus;
}
/** Builds the current aggregate when the UI is ready to display it. */
public ThreadStateMessage currentBeaconStatus() {
return beaconStatus.message(getChatPreferences());
}
/** Publishes the aggregate state used by the single beacon status chip. */
private void publishBeaconStatus(String activity) {
beaconStatus.recordActivity(activity);
onThreadStatus(
BeaconStatus.SOURCE_NAME,
currentBeaconStatus()
);
}
/**
* Validates a configured beacon template before it is stored.
@@ -366,8 +366,8 @@ public class ChatPreferences {
* Beacon prefs
*/
boolean bcn_beaconsEnabledMainCat = true;
boolean bcn_beaconsEnabledSecondCat = false;
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
int bcn_beaconIntervalInMinutesMainCat = 20;
int bcn_beaconIntervalInMinutesSecondCat = 20;
String bcn_beaconTextMainCat = "Hi, pse call us";
@@ -737,11 +737,15 @@ public class ChatPreferences {
}
public boolean isBcn_beaconsEnabledSecondCat() {
return bcn_beaconsEnabledSecondCat;
return Boolean.TRUE.equals(bcn_beaconsEnabledSecondCat.get());
}
public void setBcn_beaconsEnabledSecondCat(boolean bcn_beaconsEnabledSecondCat) {
this.bcn_beaconsEnabledSecondCat = bcn_beaconsEnabledSecondCat;
this.bcn_beaconsEnabledSecondCat.set(bcn_beaconsEnabledSecondCat);
}
public MutableValue<Boolean> bcnBeaconsEnabledSecondCatProperty() {
return bcn_beaconsEnabledSecondCat;
}
public int getBcn_beaconIntervalInMinutesSecondCat() {
@@ -1579,6 +1583,10 @@ public class ChatPreferences {
// }
public boolean isBcn_beaconsEnabledMainCat() {
return Boolean.TRUE.equals(bcn_beaconsEnabledMainCat.get());
}
public MutableValue<Boolean> bcnBeaconsEnabledMainCatProperty() {
return bcn_beaconsEnabledMainCat;
}
@@ -1601,7 +1609,7 @@ public class ChatPreferences {
}
public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) {
this.bcn_beaconsEnabledMainCat = bcn_beaconsEnabledMainCat;
this.bcn_beaconsEnabledMainCat.set(bcn_beaconsEnabledMainCat);
}
public int getBcn_beaconIntervalInMinutesMainCat() {
@@ -3170,11 +3178,11 @@ public class ChatPreferences {
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
if (beaconCQEl != null) {
bcn_beaconsEnabledMainCat = getBoolean(beaconCQEl, bcn_beaconsEnabledMainCat, "bcn_beaconsEnabledMainCat", "beaconCQEnabled");
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes");
bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText");
bcn_beaconsEnabledSecondCat = getBoolean(beaconCQEl, bcn_beaconsEnabledSecondCat, "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat");
setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"));
bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat");
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
@@ -0,0 +1,97 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconControlTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private ChatController controller;
private StatusUpdateListener listener;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
controller = new ChatController();
controller.setChatPreferences(preferences);
listener = mock(StatusUpdateListener.class);
controller.setStatusListener(listener);
}
@Test
void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() {
preferences.setLoginToSecondChatEnabled(false);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat(),
"the dormant second-category choice must not be overwritten");
assertLastDescription("on");
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("on");
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void anIndividualCheckboxPublishesThePartialStateImmediately() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.setMainBeaconEnabled(true);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("1/2");
}
private void assertLastDescription(String expected) {
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
org.mockito.Mockito.verify(listener, org.mockito.Mockito.atLeastOnce())
.onThreadStatusChanged(key.capture(), message.capture());
assertEquals("MyBeacon", key.getValue());
assertEquals(expected, message.getValue().getRunningInformationTextDescription());
}
}
@@ -0,0 +1,123 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconTaskTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private LinkedBlockingQueue<ChatMessage> txBus;
private List<ThreadStateMessage> statuses;
private ChatController controller;
private BeaconTask task;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setLoginChatCategoryMain(new ChatCategory(ChatCategory.VUHF));
preferences.setLoginChatCategorySecond(new ChatCategory(ChatCategory.MICROWAVE));
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconTextMainCat("CQ MAIN");
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
statuses = new ArrayList<>();
controller = new ChatController();
controller.setChatPreferences(preferences);
txBus = new LinkedBlockingQueue<>();
controller.setMessageTXBus(txBus);
controller.setStatusListener(new StatusUpdateListener() {
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage message) {
statuses.add(message);
}
@Override
public void onUserListUpdated() {
// Not used by beacon tests.
}
@Override
public void onUserListUpdated(String reason) {
// Not used by beacon tests.
}
});
task = new BeaconTask(controller, (key, message) -> statuses.add(message));
}
@Test
void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
assertTrue(txBus.isEmpty());
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isRunning());
assertTrue(status.isCriticalState());
}
@Test
void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
statuses.clear();
controller.setSecondBeaconEnabled(true);
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isCriticalState());
}
@Test
void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() {
task.run();
assertTrue(txBus.isEmpty());
assertAggregateStatus("off", false);
statuses.clear();
preferences.setBcn_beaconsEnabledMainCat(true);
task.run();
assertEquals(1, txBus.size());
assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber());
assertAggregateStatus("1/2", true);
statuses.clear();
preferences.setBcn_beaconsEnabledSecondCat(true);
task.run();
assertEquals(3, txBus.size());
assertAggregateStatus("on", true);
}
private void assertAggregateStatus(String description, boolean running) {
assertEquals(1, statuses.size(), "one timer run must leave one authoritative chip state");
ThreadStateMessage status = statuses.get(0);
assertEquals(description, status.getRunningInformationTextDescription());
assertEquals(running, status.isRunning());
assertFalse(status.isCriticalState());
}
}
@@ -0,0 +1,57 @@
package kst4contest.model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChatPreferencesBeaconStateTest {
@TempDir
Path tempDir;
@Test
void beaconActivationChangesAreObservableAndIndependent() {
ChatPreferences preferences = preferences("observable.xml");
List<Boolean> mainChanges = new ArrayList<>();
List<Boolean> secondChanges = new ArrayList<>();
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertEquals(List.of(false), mainChanges);
assertEquals(List.of(true), secondChanges);
}
@Test
void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() {
ChatPreferences preferences = preferences("persistence.xml");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writePreferencesToXmlFile());
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(false);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
ChatPreferences restored = preferences("persistence.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
}
private ChatPreferences preferences(String fileName) {
return new ChatPreferences(tempDir.resolve(fileName));
}
}