diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 0c3d0904bd..b2a4fc729f 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -195,6 +195,9 @@ tracker-settings-drift_compensation_section-edit = Allow drift compensation tracker-settings-name_section = Tracker name tracker-settings-name_section-description = Give it a cute nickname :) tracker-settings-name_section-placeholder = NightyBeast's left leg +tracker-settings-forget = Forget tracker +tracker-settings-forget-description = Removes the tracker from the SlimeVR Server and prevent it from connecting to it until the server is restarted. The configuration of the tracker won't be lost. +tracker-settings-forget-label = Forget tracker ## Tracker part card info tracker-part_card-no_name = No name @@ -889,3 +892,10 @@ tray_or_exit_modal-radio-exit = Exit on close tray_or_exit_modal-radio-tray = Minimize to system tray tray_or_exit_modal-submit = Save tray_or_exit_modal-cancel = Cancel + +## Unknown device modal +unknown_device-modal-title = A new tracker was found! +unknown_device-modal-description = There is a new tracker with MAC address {$deviceId}. + Do you want to connect it to SlimeVR? +unknown_device-modal-confirm = Sure! +unknown_device-modal-forget = Ignore it diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c4668d7aa4..4c6666b0e5 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -53,6 +53,7 @@ import { InterfaceSettings } from './components/settings/pages/InterfaceSettings import { error, log } from './utils/logging'; import { AppLayout } from './AppLayout'; import { Preload } from './components/Preload'; +import { UnknownDeviceModal } from './components/UnknownDeviceModal'; export const GH_REPO = 'SlimeVR/SlimeVR-Server'; export const VersionContext = createContext(''); @@ -66,6 +67,7 @@ function Layout() { <> + }> (null); + const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); + + useRPCPacket( + RpcMessage.UnknownDeviceHandshakeNotification, + ({ macAddress }: UnknownDeviceHandshakeNotificationT) => { + if ( + ['/onboarding/connect-trackers'].includes(pathname) || + state.ignoredTrackers.has(macAddress as string) || + (currentTracker !== null && currentTracker !== macAddress) + ) + return; + + setCurrentTracker(macAddress as string); + setOpen((old) => old + 1); + } + ); + + useDebouncedEffect( + () => { + setOpen(0); + setCurrentTracker(null); + }, + [open], + 3000 + ); + + const closeModal = () => { + setCurrentTracker(null); + setOpen(0); + }; + + return ( + + + + + + {l10n.getString('unknown_device-modal-title')} + + }} + vars={{ deviceId: currentTracker ?? 'ERROR' }} + > + + There is a new device in here! + + + + + + { + sendRPCPacket( + RpcMessage.AddUnknownDeviceRequest, + new AddUnknownDeviceRequestT(currentTracker) + ); + closeModal(); + }} + > + {l10n.getString('unknown_device-modal-confirm')} + + { + dispatch({ + type: 'ignoreTracker', + value: currentTracker as string, + }); + closeModal(); + }} + > + {l10n.getString('unknown_device-modal-forget')} + + + + ); +} diff --git a/gui/src/components/onboarding/pages/ConnectTracker.tsx b/gui/src/components/onboarding/pages/ConnectTracker.tsx index db547fc186..ad2e491a1a 100644 --- a/gui/src/components/onboarding/pages/ConnectTracker.tsx +++ b/gui/src/components/onboarding/pages/ConnectTracker.tsx @@ -3,9 +3,11 @@ import classNames from 'classnames'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { + AddUnknownDeviceRequestT, RpcMessage, StartWifiProvisioningRequestT, StopWifiProvisioningRequestT, + UnknownDeviceHandshakeNotificationT, WifiProvisioningStatus, WifiProvisioningStatusResponseT, } from 'solarxr-protocol'; @@ -97,6 +99,15 @@ export function ConnectTrackersPage() { } ); + useRPCPacket( + RpcMessage.UnknownDeviceHandshakeNotification, + ({ macAddress }: UnknownDeviceHandshakeNotificationT) => + sendRPCPacket( + RpcMessage.AddUnknownDeviceRequest, + new AddUnknownDeviceRequestT(macAddress) + ) + ); + const isError = provisioningStatus === WifiProvisioningStatus.CONNECTION_ERROR || provisioningStatus === WifiProvisioningStatus.COULD_NOT_FIND_SERVER; diff --git a/gui/src/components/tracker/TrackerSettings.tsx b/gui/src/components/tracker/TrackerSettings.tsx index c5dfefb7cc..9f3487b076 100644 --- a/gui/src/components/tracker/TrackerSettings.tsx +++ b/gui/src/components/tracker/TrackerSettings.tsx @@ -7,6 +7,7 @@ import { useParams } from 'react-router-dom'; import { AssignTrackerRequestT, BodyPart, + ForgetDeviceRequestT, ImuType, RpcMessage, } from 'solarxr-protocol'; @@ -31,6 +32,7 @@ import { IMUVisualizerWidget } from '@/components/widgets/IMUVisualizerWidget'; import { SingleTrackerBodyAssignmentMenu } from './SingleTrackerBodyAssignmentMenu'; import { TrackerCard } from './TrackerCard'; import { Quaternion } from 'three'; +import { useAppContext } from '@/hooks/app'; const rotationsLabels: [Quaternion, string][] = [ [rotationToQuatMap.BACK, 'tracker-rotation-back'], @@ -64,6 +66,7 @@ export function TrackerSettingsPage() { }, reValidateMode: 'onSubmit', }); + const { dispatch } = useAppContext(); const { trackerName, allowDriftCompensation } = watch(); const tracker = useTrackerFromId(trackernum, deviceid); @@ -124,13 +127,7 @@ export function TrackerSettingsPage() { updateTrackerSettings(); }; - useDebouncedEffect( - () => { - updateTrackerSettings(); - }, - [trackerName], - 1000 - ); + useDebouncedEffect(() => updateTrackerSettings(), [trackerName], 1000); useEffect(() => { updateTrackerSettings(); @@ -149,6 +146,18 @@ export function TrackerSettingsPage() { } }, [firstLoad]); + const macAddress = useMemo(() => { + if ( + /(?:[a-zA-Z\d]{2}:){5}[a-zA-Z\d]{2}/.test( + (tracker?.device?.hardwareInfo?.hardwareIdentifier as string | null) ?? + '' + ) + ) { + return tracker?.device?.hardwareInfo?.hardwareIdentifier as string; + } + return null; + }, [tracker?.device?.hardwareInfo?.hardwareIdentifier]); + return ( setSelectRotation(false)} onDirectionSelected={onDirectionSelected} > - + {tracker && ( + {macAddress && ( + + + {l10n.getString('tracker-settings-forget')} + + + {l10n.getString('tracker-settings-forget-description')} + + { + sendRPCPacket( + RpcMessage.ForgetDeviceRequest, + new ForgetDeviceRequestT(macAddress) + ); + dispatch({ type: 'ignoreTracker', value: macAddress }); + }} + > + {l10n.getString('tracker-settings-forget-label')} + + + )} diff --git a/gui/src/hooks/app.ts b/gui/src/hooks/app.ts index 68e2581aa3..a9f7758857 100644 --- a/gui/src/hooks/app.ts +++ b/gui/src/hooks/app.ts @@ -29,10 +29,13 @@ export interface FlatDeviceTracker { tracker: TrackerDataT; } -type AppStateAction = { type: 'datafeed'; value: DataFeedUpdateT }; +export type AppStateAction = + | { type: 'datafeed'; value: DataFeedUpdateT } + | { type: 'ignoreTracker'; value: string }; export interface AppState { datafeed?: DataFeedUpdateT; + ignoredTrackers: Set; } export interface AppContext { @@ -47,8 +50,13 @@ export function reducer(state: AppState, action: AppStateAction) { switch (action.type) { case 'datafeed': return { ...state, datafeed: action.value }; + case 'ignoreTracker': + return { + ...state, + ignoredTrackers: new Set([...state.ignoredTrackers, action.value]), + }; default: - throw new Error(`unhandled state action ${action.type}`); + throw new Error(`unhandled state action ${(action as AppStateAction).type}`); } } @@ -59,6 +67,7 @@ export function useProvideAppContext(): AppContext { const { dataFeedConfig } = useDataFeedConfig(); const [state, dispatch] = useReducer>(reducer, { datafeed: new DataFeedUpdateT(), + ignoredTrackers: new Set(), }); useEffect(() => { diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt index cfa689813d..6213e01b07 100644 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ b/server/core/src/main/java/dev/slimevr/VRServer.kt @@ -15,6 +15,7 @@ import dev.slimevr.reset.ResetHandler import dev.slimevr.serial.ProvisioningHandler import dev.slimevr.serial.SerialHandler import dev.slimevr.serial.SerialHandlerStub +import dev.slimevr.setup.HandshakeHandler import dev.slimevr.setup.TapSetupHandler import dev.slimevr.status.StatusSystem import dev.slimevr.tracking.processor.HumanPoseManager @@ -48,7 +49,6 @@ class VRServer @JvmOverloads constructor( driverBridgeProvider: SteamBridgeProvider = { _, _ -> null }, feederBridgeProvider: (VRServer) -> ISteamVRBridge? = { _ -> null }, serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() }, - // configPath is used by VRWorkout, do not remove! configPath: String, ) : Thread("VRServer") { @JvmField @@ -97,6 +97,9 @@ class VRServer @JvmOverloads constructor( @JvmField val statusSystem = StatusSystem() + @JvmField + val handshakeHandler = HandshakeHandler() + init { // UwU configManager = ConfigManager(configPath) diff --git a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java b/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java index ac1c739db1..479df1f6da 100644 --- a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java +++ b/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java @@ -8,6 +8,7 @@ import io.eiren.util.logging.LogManager; import java.util.Map; +import java.util.regex.Pattern; public class CurrentVRConfigConverter implements VersionedModelConverter { @@ -285,6 +286,24 @@ public ObjectNode convert( } } } + + if (version < 13) { + ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers"); + if (oldTrackersNode != null) { + var fieldNamesIter = oldTrackersNode.fieldNames(); + String trackerId; + final String macAddressRegex = "udp://((?:[a-zA-Z\\d]{2}:){5}[a-zA-Z\\d]{2})/0"; + final Pattern pattern = Pattern.compile(macAddressRegex); + while (fieldNamesIter.hasNext()) { + trackerId = fieldNamesIter.next(); + var matcher = pattern.matcher(trackerId); + if (!matcher.find()) + continue; + + modelData.withArray("knownDevices").add(matcher.group(1)); + } + } + } } catch (Exception e) { LogManager.severe("Error during config migration: " + e); } diff --git a/server/core/src/main/java/dev/slimevr/config/VRConfig.java b/server/core/src/main/java/dev/slimevr/config/VRConfig.java index 233d316707..de86a7ec42 100644 --- a/server/core/src/main/java/dev/slimevr/config/VRConfig.java +++ b/server/core/src/main/java/dev/slimevr/config/VRConfig.java @@ -10,11 +10,13 @@ import dev.slimevr.tracking.trackers.TrackerRole; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; @JsonVersionedModel( - currentVersion = "12", defaultDeserializeToVersion = "12", toCurrentConverterClass = CurrentVRConfigConverter.class + currentVersion = "13", defaultDeserializeToVersion = "13", toCurrentConverterClass = CurrentVRConfigConverter.class ) public class VRConfig { @@ -50,6 +52,8 @@ public class VRConfig { @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class) private final Map bridges = new HashMap<>(); + private final Set knownDevices = new HashSet<>(); + private final OverlayConfig overlay = new OverlayConfig(); public VRConfig() { @@ -142,6 +146,14 @@ public OverlayConfig getOverlay() { return overlay; } + public Set getKnownDevices() { + return knownDevices; + } + + public boolean hasTrackerByName(String name) { + return trackers.containsKey(name); + } + public TrackerConfig getTracker(Tracker tracker) { TrackerConfig config = trackers.get(tracker.getName()); if (config == null) { @@ -181,5 +193,17 @@ public BridgeConfig getBridge(String bridgeKey) { } return config; } + + public boolean isKnownDevice(String mac) { + return knownDevices.contains(mac); + } + + public boolean addKnownDevice(String mac) { + return knownDevices.add(mac); + } + + public boolean forgetKnownDevice(String mac) { + return knownDevices.remove(mac); + } } diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.java b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.java index 9cce05c427..555cb8a8fe 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.java +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.java @@ -11,6 +11,7 @@ import dev.slimevr.protocol.rpc.serial.RPCProvisioningHandler; import dev.slimevr.protocol.rpc.serial.RPCSerialHandler; import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler; +import dev.slimevr.protocol.rpc.setup.RPCHandshakeHandler; import dev.slimevr.protocol.rpc.setup.RPCTapSetupHandler; import dev.slimevr.protocol.rpc.setup.RPCUtil; import dev.slimevr.protocol.rpc.status.RPCStatusHandler; @@ -47,6 +48,7 @@ public RPCHandler(ProtocolAPI api) { new RPCTapSetupHandler(this, api); new RPCStatusHandler(this, api); new RPCAutoBoneHandler(this, api); + new RPCHandshakeHandler(this, api); new RPCTrackingPause(this, api); registerPacketListener(RpcMessage.ResetRequest, this::onResetRequest); diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt new file mode 100644 index 0000000000..8cfb4f7786 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt @@ -0,0 +1,86 @@ +package dev.slimevr.protocol.rpc.setup + +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.protocol.GenericConnection +import dev.slimevr.protocol.ProtocolAPI +import dev.slimevr.protocol.rpc.RPCHandler +import dev.slimevr.setup.HandshakeListener +import dev.slimevr.tracking.trackers.udp.UDPDevice +import solarxr_protocol.rpc.AddUnknownDeviceRequest +import solarxr_protocol.rpc.ForgetDeviceRequest +import solarxr_protocol.rpc.RpcMessage +import solarxr_protocol.rpc.RpcMessageHeader +import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification + +class RPCHandshakeHandler( + private val rpcHandler: RPCHandler, + val api: ProtocolAPI, +) : HandshakeListener { + init { + rpcHandler.registerPacketListener( + RpcMessage.AddUnknownDeviceRequest, + ::onAddUnknownDevice, + ) + + rpcHandler.registerPacketListener( + RpcMessage.ForgetDeviceRequest, + ::onForgetDevice, + ) + + this.api.server.handshakeHandler.addListener(this) + } + + override fun onUnknownHandshake(macAddress: String) { + val fbb = FlatBufferBuilder(32) + val string = fbb.createString(macAddress) + val update = + UnknownDeviceHandshakeNotification.createUnknownDeviceHandshakeNotification( + fbb, + string, + ) + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.UnknownDeviceHandshakeNotification, + update, + ) + fbb.finish(outbound) + + api.apiServers.forEach { apiServer -> + apiServer.apiConnections.forEach { + it.send(fbb.dataBuffer()) + } + } + } + + fun onAddUnknownDevice( + conn: GenericConnection, + messageHeader: RpcMessageHeader, + ) { + val req = + messageHeader.message(AddUnknownDeviceRequest()) as AddUnknownDeviceRequest? + ?: return + + this.api.server.configManager.vrConfig.addKnownDevice( + req.macAddress() ?: return, + ) + this.api.server.configManager.saveConfig() + } + + fun onForgetDevice( + conn: GenericConnection, + messageHeader: RpcMessageHeader, + ) { + val req = messageHeader.message(ForgetDeviceRequest()) as ForgetDeviceRequest? + ?: return + + this.api.server.configManager.vrConfig.forgetKnownDevice( + req.macAddress() ?: return, + ) + val device = + this.api.server.deviceManager.devices.find { it.hardwareIdentifier == req.macAddress() } + if (device != null && device is UDPDevice) { + this.api.server.trackersServer.disconnectDevice(device) + } + this.api.server.configManager.saveConfig() + } +} diff --git a/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt b/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt new file mode 100644 index 0000000000..31b9288e0c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt @@ -0,0 +1,23 @@ +package dev.slimevr.setup + +import java.util.concurrent.CopyOnWriteArrayList + +class HandshakeHandler { + private val listeners: MutableList = CopyOnWriteArrayList() + + fun addListener(listener: HandshakeListener) { + listeners.add(listener) + } + + fun removeListener(listener: HandshakeListener) { + listeners.remove(listener) + } + + fun sendUnknownHandshake(macAddress: String) { + listeners.forEach { it.onUnknownHandshake(macAddress) } + } +} + +interface HandshakeListener { + fun onUnknownHandshake(macAddress: String) +} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt index 85b4d0c9f3..80d7aaf8ff 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt @@ -58,6 +58,15 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker val addr = handshakePacket.address val socketAddr = handshakePacket.socketAddress + // Check if it's a known device + VRServer.instance.configManager.vrConfig.let { vrConfig -> + if (vrConfig.isKnownDevice(handshake.macString)) return@let + val mac = handshake.macString ?: return@let + + VRServer.instance.handshakeHandler.sendUnknownHandshake(mac) + return + } + // Get a connection either by an existing one, or by creating a new one val connection: UDPDevice = synchronized(connections) { connectionsByMAC[handshake.macString]?.apply { @@ -108,6 +117,7 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker } } ?: run { // No existing connection could be found, create a new one + val connection = UDPDevice( socketAddr, addr, @@ -456,6 +466,25 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker fun getConnections(): List = connections + // FIXME: for some reason it ends up disconnecting after 30 seconds have passed instead of immediately + fun disconnectDevice(device: UDPDevice) { + synchronized(connections) { + connections.remove(device) + } + synchronized(connectionsByAddress) { + connectionsByAddress.filter { (_, dev) -> dev.id == device.id }.keys.forEach( + connectionsByAddress::remove, + ) + } + device.trackers.forEach { (_, tracker) -> + tracker.status = TrackerStatus.DISCONNECTED + } + + LogManager.info( + "[TrackerServer] Forcefully disconnected ${device.hardwareIdentifier} device.", + ) + } + companion object { /** * Change between IMU axes and OpenGL/SteamVR axes diff --git a/solarxr-protocol b/solarxr-protocol index 42017052a7..0bcd19c0b3 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 42017052a74fe01e52c7f303767ad8a2055d171d +Subproject commit 0bcd19c0b32c4249f082cb610a4141142c493133