1
0
forked from Clones/Controlify

Battery level warning + update SDL with macOS ARM support

This commit is contained in:
isXander
2023-05-11 16:43:13 +01:00
parent 0e8bf0cc9b
commit 71c7e26587
19 changed files with 283 additions and 69 deletions

View File

@ -22,6 +22,7 @@ import dev.isxander.controlify.sound.ControlifySounds;
import dev.isxander.controlify.utils.DebugLog;
import dev.isxander.controlify.utils.ToastUtils;
import dev.isxander.controlify.virtualmouse.VirtualMouseHandler;
import dev.isxander.controlify.wireless.LowBatteryNotifier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.resource.ResourceManagerHelper;
import net.fabricmc.fabric.api.resource.ResourcePackActivationType;
@ -57,7 +58,7 @@ public class Controlify implements ControlifyApi {
private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE;
private ControllerHIDService controllerHIDService;
private CompletableFuture<Boolean> vibrationOnboardingFuture = null;
private CompletableFuture<Boolean> nativeOnboardingFuture = null;
private final ControlifyConfig config = new ControlifyConfig(this);
@ -77,13 +78,13 @@ public class Controlify implements ControlifyApi {
var controllersConnected = IntStream.range(0, GLFW.GLFW_JOYSTICK_LAST + 1).anyMatch(GLFW::glfwJoystickPresent);
if (controllersConnected) {
askVibrationNatives().whenComplete((loaded, th) -> discoverControllers());
askNatives().whenComplete((loaded, th) -> discoverControllers());
}
// listen for new controllers
GLFW.glfwSetJoystickCallback((jid, event) -> {
try {
this.askVibrationNatives().whenComplete((loaded, th) -> {
this.askNatives().whenComplete((loaded, th) -> {
if (event == GLFW.GLFW_CONNECTED) {
this.onControllerHotplugged(jid);
} else if (event == GLFW.GLFW_DISCONNECTED) {
@ -96,25 +97,25 @@ public class Controlify implements ControlifyApi {
});
}
private CompletableFuture<Boolean> askVibrationNatives() {
if (vibrationOnboardingFuture != null) return vibrationOnboardingFuture;
private CompletableFuture<Boolean> askNatives() {
if (nativeOnboardingFuture != null) return nativeOnboardingFuture;
if (config().globalSettings().vibrationOnboarded) {
return CompletableFuture.completedFuture(config().globalSettings().loadVibrationNatives);
}
vibrationOnboardingFuture = new CompletableFuture<>();
nativeOnboardingFuture = new CompletableFuture<>();
minecraft.setScreen(new VibrationOnboardingScreen(
minecraft.setScreen(new SDLOnboardingScreen(
minecraft.screen,
answer -> {
if (answer)
SDL2NativesManager.initialise();
vibrationOnboardingFuture.complete(answer);
nativeOnboardingFuture.complete(answer);
}
));
return vibrationOnboardingFuture;
return nativeOnboardingFuture;
}
private void discoverControllers() {
@ -229,6 +230,8 @@ public class Controlify implements ControlifyApi {
}
}
LowBatteryNotifier.tick();
getCurrentController().ifPresent(currentController -> {
wrapControllerError(
() -> tickController(currentController, outOfFocus),

View File

@ -19,4 +19,5 @@ public class GlobalSettings {
public boolean vibrationOnboarded = false;
public ReachAroundMode reachAround = ReachAroundMode.OFF;
public boolean uiSounds = false;
public boolean notifyLowBattery = true;
}

View File

@ -6,6 +6,6 @@ import com.terraformersmc.modmenu.api.ModMenuApi;
public class ModMenuIntegration implements ModMenuApi {
@Override
public ConfigScreenFactory<?> getModConfigScreenFactory() {
return YACLHelper::generateConfigScreen;
return YACLHelper::openConfigScreen;
}
}

View File

@ -46,7 +46,18 @@ public class YACLHelper {
private static final Function<Float, Component> percentFormatter = v -> Component.literal(String.format("%.0f%%", v*100));
private static final Function<Float, Component> percentOrOffFormatter = v -> v == 0 ? CommonComponents.OPTION_OFF : percentFormatter.apply(v);
public static Screen generateConfigScreen(Screen parent) {
public static Screen openConfigScreen(Screen parent) {
Screen configScreen = generateConfigScreen(parent);
if (!Controlify.instance().config().globalSettings().vibrationOnboarded)
configScreen = new SDLOnboardingScreen(configScreen, yes -> {
if (yes) {
SDL2NativesManager.initialise();
}
});
return configScreen;
}
private static Screen generateConfigScreen(Screen parent) {
var controlify = Controlify.instance();
var yacl = YetAnotherConfigLib.createBuilder()
@ -86,6 +97,12 @@ public class YACLHelper {
.binding(GlobalSettings.DEFAULT.uiSounds, () -> globalSettings.uiSounds, v -> globalSettings.uiSounds = v)
.controller(TickBoxController::new)
.build())
.option(Option.createBuilder(boolean.class)
.name(Component.translatable("controlify.gui.notify_low_battery"))
.tooltip(Component.translatable("controlify.gui.notify_low_battery.tooltip"))
.binding(GlobalSettings.DEFAULT.notifyLowBattery, () -> globalSettings.notifyLowBattery, v -> globalSettings.notifyLowBattery = v)
.controller(TickBoxController::new)
.build())
.option(Option.createBuilder(boolean.class)
.name(Component.translatable("controlify.gui.out_of_focus_input"))
.tooltip(Component.translatable("controlify.gui.out_of_focus_input.tooltip"))
@ -126,6 +143,10 @@ public class YACLHelper {
category.name(Component.literal(controller.name()));
if (controller.batteryLevel() != BatteryLevel.UNKNOWN) {
category.option(LabelOption.create(Component.translatable("controlify.gui.battery_level", controller.batteryLevel().getFriendlyName())));
}
var config = controller.config();
var def = controller.defaultConfig();

View File

@ -0,0 +1,21 @@
package dev.isxander.controlify.controller;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
public enum BatteryLevel {
EMPTY, LOW, MEDIUM, FULL, MAX,
WIRED, UNKNOWN;
public MutableComponent getFriendlyName() {
return Component.translatable("controlify.battery_level." + name().toLowerCase()).withStyle(
switch (this) {
case EMPTY, LOW -> ChatFormatting.RED;
case MEDIUM -> ChatFormatting.YELLOW;
case FULL, MAX -> ChatFormatting.GREEN;
default -> ChatFormatting.WHITE;
}
);
}
}

View File

@ -2,24 +2,11 @@ package dev.isxander.controlify.controller;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.bindings.ControllerBindings;
import dev.isxander.controlify.controller.gamepad.GamepadController;
import dev.isxander.controlify.controller.hid.ControllerHIDService;
import dev.isxander.controlify.controller.joystick.SingleJoystickController;
import dev.isxander.controlify.debug.DebugProperties;
import dev.isxander.controlify.rumble.RumbleCapable;
import dev.isxander.controlify.rumble.RumbleManager;
import dev.isxander.controlify.rumble.RumbleSource;
import dev.isxander.controlify.utils.DebugLog;
import net.minecraft.CrashReport;
import net.minecraft.CrashReportCategory;
import net.minecraft.ReportedException;
import org.hid4java.HidDevice;
import org.lwjgl.glfw.GLFW;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public interface Controller<S extends ControllerState, C extends ControllerConfig> {
@ -48,6 +35,10 @@ public interface Controller<S extends ControllerState, C extends ControllerConfi
RumbleManager rumbleManager();
boolean supportsRumble();
default BatteryLevel batteryLevel() {
return BatteryLevel.UNKNOWN;
}
Optional<ControllerHIDService.ControllerHIDInfo> hidInfo();
default boolean canBeUsed() {

View File

@ -17,6 +17,10 @@ public record ControllerType(String friendlyName, String mappingId, String theme
private static Map<HIDIdentifier, ControllerType> typeMap = null;
private static final ResourceLocation hidDbLocation = new ResourceLocation("controlify", "controllers/controller_identification.json5");
public static ControllerType getTypeForHID(HIDIdentifier hid) {
return getTypeMap().getOrDefault(hid, ControllerType.UNKNOWN);
}
public static void ensureTypeMapFilled() {
if (typeMap != null) return;

View File

@ -2,6 +2,7 @@ package dev.isxander.controlify.controller.gamepad;
import dev.isxander.controlify.bindings.ControllerBindings;
import dev.isxander.controlify.controller.AbstractController;
import dev.isxander.controlify.controller.BatteryLevel;
import dev.isxander.controlify.controller.hid.ControllerHIDService;
import dev.isxander.controlify.driver.*;
import dev.isxander.controlify.rumble.RumbleManager;
@ -103,6 +104,11 @@ public class GamepadController extends AbstractController<GamepadState, GamepadC
return this.rumbleManager;
}
@Override
public BatteryLevel batteryLevel() {
return drivers.batteryDriver().getBatteryLevel();
}
@Override
public void close() {
uniqueDrivers.forEach(Driver::close);

View File

@ -3,6 +3,9 @@ package dev.isxander.controlify.controller.hid;
import com.mojang.datafixers.util.Pair;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.controller.ControllerType;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.utils.ToastUtils;
import net.minecraft.network.chat.Component;
import org.hid4java.*;
import java.util.*;
@ -15,6 +18,7 @@ public class ControllerHIDService {
private final Queue<Pair<HidDevice, HIDIdentifier>> unconsumedControllerHIDs;
private final Map<String, HidDevice> attachedDevices = new HashMap<>();
private boolean disabled = false;
private boolean firstFetch = true;
// https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/hid-usages#usage-page
private static final Set<Integer> CONTROLLER_USAGE_IDS = Set.of(
0x04, // Joystick
@ -39,7 +43,29 @@ public class ControllerHIDService {
}
}
public ControllerHIDInfo fetchType() {
public ControllerHIDInfo fetchType(int jid) {
if (firstFetch) {
firstFetch = false;
if (isDisabled() && !SDL2NativesManager.isLoaded()) {
if (Controlify.instance().controllerHIDService().isDisabled() && !SDL2NativesManager.isLoaded()) {
ToastUtils.sendToast(
Component.translatable("controlify.error.hid"),
Component.translatable("controlify.error.hid.desc"),
true
);
}
}
}
// if (SDL2NativesManager.isLoaded()) {
// int vid = SDL.SDL_JoystickGetDeviceVendor(jid);
// int pid = SDL.SDL_JoystickGetDeviceProduct(jid);
//
// if (vid != 0 && pid != 0) {
// return new ControllerHIDInfo(ControllerType.getTypeForHID(new HIDIdentifier(vid, pid)), Optional.empty());
// }
// }
if (disabled) {
return new ControllerHIDInfo(ControllerType.UNKNOWN, Optional.empty());
}
@ -52,7 +78,7 @@ public class ControllerHIDService {
return new ControllerHIDInfo(ControllerType.UNKNOWN, Optional.empty());
}
ControllerType type = ControllerType.getTypeMap().getOrDefault(hid.getSecond(), ControllerType.UNKNOWN);
ControllerType type = ControllerType.getTypeForHID(hid.getSecond());
if (type == ControllerType.UNKNOWN)
Controlify.LOGGER.warn("Controller found via USB hardware scan, but it was not found in the controller identification database! (HID: {})", hid.getSecond());

View File

@ -6,6 +6,7 @@ import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.Util;
import org.libsdl.SDL;
import java.io.File;
import java.io.FileOutputStream;
import java.net.URL;
import java.nio.channels.Channels;
@ -13,6 +14,7 @@ import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Map;
import static org.libsdl.SDL_Hints.*;
@ -20,10 +22,11 @@ import static org.libsdl.SDL_Hints.*;
public class SDL2NativesManager {
private static final String SDL2_VERSION = "<SDL2_VERSION>";
private static final Map<Target, String> NATIVE_LIBRARIES = Map.of(
new Target(Util.OS.WINDOWS, true), "windows64.dll",
new Target(Util.OS.WINDOWS, false), "window32.dll",
new Target(Util.OS.LINUX, true), "linux64.so"
//new Target(Util.OS.OSX, true), "mac64.dylib"
new Target(Util.OS.WINDOWS, true, false), "windows64.dll",
new Target(Util.OS.WINDOWS, false, false), "window32.dll",
new Target(Util.OS.LINUX, true, false), "linux64.so",
new Target(Util.OS.OSX, true, false), "macosx64.dylib",
new Target(Util.OS.OSX, true, true), "macosxarm64.dylib"
);
private static final String NATIVE_LIBRARY_URL = "https://maven.isxander.dev/releases/dev/isxander/sdl2-jni-natives/%s/".formatted(SDL2_VERSION);
@ -41,6 +44,16 @@ public class SDL2NativesManager {
Path localLibraryPath = Target.CURRENT.getLocalNativePath();
if (Files.notExists(localLibraryPath)) {
if (Files.exists(localLibraryPath.getParent())) {
try(var walk = Files.walk(localLibraryPath.getParent())) {
walk.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEachOrdered(File::delete);
} catch (Exception e) {
Controlify.LOGGER.error("Failed to delete old SDL2 native library", e);
}
}
Controlify.LOGGER.info("Downloading SDL2 native library: " + Target.CURRENT.getArtifactName());
downloadLibrary(localLibraryPath);
}
@ -110,12 +123,13 @@ public class SDL2NativesManager {
return loaded;
}
private record Target(Util.OS os, boolean is64Bit) {
private record Target(Util.OS os, boolean is64Bit, boolean isARM) {
public static final Target CURRENT = Util.make(() -> {
Util.OS os = Util.getPlatform();
boolean is64bit = System.getProperty("os.arch").contains("64");
boolean isARM = System.getProperty("os.arch").contains("arm");
return new Target(os, is64bit);
return new Target(os, is64bit, isARM);
});
public boolean hasNativeLibrary() {

View File

@ -0,0 +1,25 @@
package dev.isxander.controlify.driver;
import dev.isxander.controlify.controller.BatteryLevel;
public interface BatteryDriver extends Driver {
BatteryLevel getBatteryLevel();
String getBatteryDriverDetails();
BatteryDriver UNSUPPORTED = new BatteryDriver() {
@Override
public void update() {
}
@Override
public BatteryLevel getBatteryLevel() {
return BatteryLevel.UNKNOWN;
}
@Override
public String getBatteryDriverDetails() {
return "Unsupported";
}
};
}

View File

@ -1,5 +1,6 @@
package dev.isxander.controlify.driver;
import com.google.common.collect.Sets;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.controller.sdl2.SDL2NativesManager;
import dev.isxander.controlify.debug.DebugProperties;
@ -7,19 +8,20 @@ import org.hid4java.HidDevice;
import java.util.*;
public record GamepadDrivers(BasicGamepadInputDriver basicGamepadInputDriver, GyroDriver gyroDriver, RumbleDriver rumbleDriver) {
public record GamepadDrivers(BasicGamepadInputDriver basicGamepadInputDriver, GyroDriver gyroDriver, RumbleDriver rumbleDriver, BatteryDriver batteryDriver) {
public Set<Driver> getUniqueDrivers() {
Set<Driver> drivers = Collections.newSetFromMap(new IdentityHashMap<>());
drivers.addAll(List.of(basicGamepadInputDriver, gyroDriver, rumbleDriver));
Set<Driver> drivers = Sets.newIdentityHashSet();
drivers.addAll(List.of(basicGamepadInputDriver, gyroDriver, rumbleDriver, batteryDriver));
return drivers;
}
public void printDrivers() {
if (DebugProperties.PRINT_DRIVER) {
Controlify.LOGGER.info("Drivers in use: Basic Input = '{}', Gyro = '{}', Rumble = '{}'",
Controlify.LOGGER.info("Drivers in use: Basic Input = '{}', Gyro = '{}', Rumble = '{}', Battery = '{}'",
basicGamepadInputDriver.getBasicGamepadDetails(),
gyroDriver.getGyroDetails(),
rumbleDriver.getRumbleDetails()
rumbleDriver.getRumbleDetails(),
batteryDriver.getBatteryDriverDetails()
);
}
}
@ -28,11 +30,13 @@ public record GamepadDrivers(BasicGamepadInputDriver basicGamepadInputDriver, Gy
BasicGamepadInputDriver basicGamepadInputDriver = new GLFWGamepadDriver(jid);
GyroDriver gyroDriver = GyroDriver.UNSUPPORTED;
RumbleDriver rumbleDriver = RumbleDriver.UNSUPPORTED;
BatteryDriver batteryDriver = BatteryDriver.UNSUPPORTED;
if (SDL2NativesManager.isLoaded()) {
SDL2GamepadDriver sdl2Driver = new SDL2GamepadDriver(jid);
gyroDriver = sdl2Driver;
rumbleDriver = sdl2Driver;
batteryDriver = sdl2Driver;
}
// broken
@ -40,6 +44,6 @@ public record GamepadDrivers(BasicGamepadInputDriver basicGamepadInputDriver, Gy
gyroDriver = new SteamDeckDriver(hid.get());
}
return new GamepadDrivers(basicGamepadInputDriver, gyroDriver, rumbleDriver);
return new GamepadDrivers(basicGamepadInputDriver, gyroDriver, rumbleDriver, batteryDriver);
}
}

View File

@ -1,11 +1,12 @@
package dev.isxander.controlify.driver;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.controller.BatteryLevel;
import dev.isxander.controlify.controller.gamepad.GamepadState;
import dev.isxander.controlify.debug.DebugProperties;
import org.libsdl.SDL;
public class SDL2GamepadDriver implements GyroDriver, RumbleDriver {
public class SDL2GamepadDriver implements GyroDriver, RumbleDriver, BatteryDriver {
private final long ptrGamepad;
private GamepadState.GyroState gyroDelta;
private final boolean isGyroSupported, isRumbleSupported;
@ -42,6 +43,20 @@ public class SDL2GamepadDriver implements GyroDriver, RumbleDriver {
return gyroDelta;
}
@Override
public BatteryLevel getBatteryLevel() {
return switch (SDL.SDL_JoystickCurrentPowerLevel(ptrGamepad)) {
case SDL.SDL_JOYSTICK_POWER_UNKNOWN -> BatteryLevel.UNKNOWN;
case SDL.SDL_JOYSTICK_POWER_EMPTY -> BatteryLevel.EMPTY;
case SDL.SDL_JOYSTICK_POWER_LOW -> BatteryLevel.LOW;
case SDL.SDL_JOYSTICK_POWER_MEDIUM -> BatteryLevel.MEDIUM;
case SDL.SDL_JOYSTICK_POWER_FULL -> BatteryLevel.FULL;
case SDL.SDL_JOYSTICK_POWER_WIRED -> BatteryLevel.WIRED;
case SDL.SDL_JOYSTICK_POWER_MAX -> BatteryLevel.MAX;
default -> throw new IllegalStateException("Unexpected value: " + SDL.SDL_JoystickCurrentPowerLevel(ptrGamepad));
};
}
@Override
public boolean isGyroSupported() {
return isGyroSupported;
@ -66,4 +81,9 @@ public class SDL2GamepadDriver implements GyroDriver, RumbleDriver {
public String getRumbleDetails() {
return "SDL2gp supported=" + isRumbleSupported();
}
@Override
public String getBatteryDriverDetails() {
return "SDL2gp";
}
}

View File

@ -0,0 +1,36 @@
package dev.isxander.controlify.gui.screen;
import dev.isxander.controlify.Controlify;
import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ConfirmScreen;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
public class SDLOnboardingScreen extends ConfirmScreen {
public SDLOnboardingScreen(Screen lastScreen, BooleanConsumer onAnswered) {
super(
yes -> {
Controlify.instance().config().globalSettings().loadVibrationNatives = yes;
Controlify.instance().config().globalSettings().vibrationOnboarded = true;
Controlify.instance().config().save();
Minecraft.getInstance().setScreen(lastScreen);
onAnswered.accept(yes);
},
Component.translatable("controlify.sdl2_onboarding.title").withStyle(ChatFormatting.BOLD),
Util.make(() -> {
var message = Component.translatable("controlify.sdl2_onboarding.message");
// if (Util.getPlatform() == Util.OS.OSX) {
// message.append("\n").append(Component.translatable("controlify.sdl2_onboarding.message_mac").withStyle(ChatFormatting.RED));
// }
message.append("\n\n").append(Component.translatable("controlify.sdl2_onboarding.question"));
return message;
})
);
}
}

View File

@ -1,25 +0,0 @@
package dev.isxander.controlify.gui.screen;
import dev.isxander.controlify.Controlify;
import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ConfirmScreen;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
public class VibrationOnboardingScreen extends ConfirmScreen {
public VibrationOnboardingScreen(Screen lastScreen, BooleanConsumer onAnswered) {
super(
yes -> {
Controlify.instance().config().globalSettings().loadVibrationNatives = yes;
Controlify.instance().config().globalSettings().vibrationOnboarded = true;
Controlify.instance().config().save();
Minecraft.getInstance().setScreen(lastScreen);
onAnswered.accept(yes);
},
Component.translatable("controlify.vibration_onboarding.title").withStyle(ChatFormatting.BOLD),
Component.translatable("controlify.vibration_onboarding.message")
);
}
}

View File

@ -22,7 +22,7 @@ public class ControlsScreenMixin extends OptionsSubScreen {
@Inject(method = "init", at = @At("RETURN"))
private void addControllerSettings(CallbackInfo ci, @Local(ordinal = 0) int leftX, @Local(ordinal = 1) int rightX, @Local(ordinal = 2) int currentY) {
addRenderableWidget(Button.builder(Component.translatable("controlify.gui.button"), btn -> minecraft.setScreen(YACLHelper.generateConfigScreen(this)))
addRenderableWidget(Button.builder(Component.translatable("controlify.gui.button"), btn -> minecraft.setScreen(YACLHelper.openConfigScreen(this)))
.pos(leftX, currentY)
.width(150)
.build());

View File

@ -0,0 +1,50 @@
package dev.isxander.controlify.wireless;
import dev.isxander.controlify.Controlify;
import dev.isxander.controlify.ControllerManager;
import dev.isxander.controlify.controller.BatteryLevel;
import dev.isxander.controlify.controller.Controller;
import dev.isxander.controlify.utils.ToastUtils;
import net.minecraft.network.chat.Component;
import java.util.HashMap;
import java.util.Map;
public class LowBatteryNotifier {
private static final Map<String, BatteryLevel> previousBatteryLevels = new HashMap<>();
private static int interval;
public static void tick() {
if (interval > 0) {
interval--;
return;
}
interval = 20 * 60; // 1 minute
if (!Controlify.instance().config().globalSettings().notifyLowBattery)
return;
for (Controller<?, ?> controller : ControllerManager.getConnectedControllers()) {
BatteryLevel batteryLevel = controller.batteryLevel();
if (batteryLevel == BatteryLevel.UNKNOWN) {
continue;
}
String uid = controller.uid();
if (previousBatteryLevels.containsKey(uid)) {
BatteryLevel previousBatteryLevel = previousBatteryLevels.get(uid);
if (batteryLevel.ordinal() < previousBatteryLevel.ordinal()) {
if (batteryLevel == BatteryLevel.LOW) {
ToastUtils.sendToast(
Component.translatable("controlify.toast.low_battery.title"),
Component.translatable("controlify.toast.low_battery.message", controller.name()),
true
);
}
}
}
previousBatteryLevels.put(uid, batteryLevel);
}
}
}