From 41796f0dc45420a4e56d51f905e220c30dbf7451 Mon Sep 17 00:00:00 2001 From: Craig Gordon Date: Fri, 29 May 2026 17:20:22 -0400 Subject: [PATCH 01/22] Add Eversense E3/365 CGM plugin --- plugins/eversense/.gitignore | 1 + plugins/eversense/build.gradle.kts | 28 + plugins/eversense/consumer-rules.pro | 0 plugins/eversense/proguard-rules.pro | 21 + .../eversense/src/main/AndroidManifest.xml | 12 + .../eversense/EversenseCGMPlugin.kt | 395 +++++++++++ .../eversense/EversenseGattCallback.kt | 659 ++++++++++++++++++ .../callbacks/EversenseScanCallback.kt | 7 + .../eversense/callbacks/EversenseWatcher.kt | 15 + .../eversense/enums/BatteryLevel.kt | 38 + .../eversense/enums/CalibrationFlag.kt | 46 ++ .../eversense/enums/CalibrationMode.kt | 29 + .../eversense/enums/CalibrationPhase.kt | 65 ++ .../eversense/enums/CalibrationReadiness.kt | 69 ++ .../eversense/enums/CommandError.kt | 30 + .../eversense/enums/EversenseAlarm.kt | 82 +++ .../eversense/enums/EversenseE3Memory.kt | 55 ++ .../eversense/enums/EversenseSecurityType.kt | 10 + .../eversense/enums/EversenseTrendArrow.kt | 10 + .../eversense/enums/EversenseType.kt | 6 + .../eversense/enums/SignalStrength.kt | 39 ++ .../eversense/enums/TransmitterAlert.kt | 150 ++++ .../exceptions/EversenseWriteException.kt | 4 + .../eversense/models/ActiveAlarm.kt | 12 + .../eversense/models/EversenseCGMResult.kt | 11 + .../eversense/models/EversenseScanResult.kt | 5 + .../eversense/models/EversenseSecureState.kt | 13 + .../eversense/models/EversenseState.kt | 50 ++ .../eversense/models/GlucoseHistoryItem.kt | 10 + .../packets/Eversense365Communicator.kt | 234 +++++++ .../eversense/packets/EversenseBasePacket.kt | 106 +++ .../packets/EversenseE3Communicator.kt | 375 ++++++++++ .../eversense/packets/EversensePacket.kt | 17 + .../packets/e3/EnterDiagnosticModePacket.kt | 20 + .../packets/e3/EversenseE3Packets.kt | 131 ++++ .../packets/e3/ExitDiagnosticModePacket.kt | 20 + .../eversense/packets/e3/GetAlertLogPacket.kt | 42 ++ .../packets/e3/GetBatteryPercentagePacket.kt | 30 + .../packets/e3/GetBleDisconnectPacket.kt | 29 + .../packets/e3/GetCalibrationDailyPacket.kt | 27 + .../packets/e3/GetCalibrationLogPacket.kt | 45 ++ .../e3/GetCalibrationLogRangePacket.kt | 32 + .../packets/e3/GetCalibrationPhasePacket.kt | 31 + .../e3/GetCalibrationReadinessPacket.kt | 31 + .../e3/GetCompletedCalibrationsCountPacket.kt | 29 + .../packets/e3/GetCurrentDatetimePacket.kt | 49 ++ .../packets/e3/GetCurrentGlucosePacket.kt | 54 ++ .../e3/GetGlucoseAlertsAndStatusPacket.kt | 56 ++ .../packets/e3/GetGlucoseLogPacket.kt | 50 ++ .../packets/e3/GetGlucoseLogRangePacket.kt | 36 + .../e3/GetHighGlucoseRepeatIntervalPacket.kt | 21 + .../packets/e3/GetInsertionDatePacket.kt | 30 + .../packets/e3/GetInsertionTimePacket.kt | 30 + .../packets/e3/GetIsOneCalPhasePacket.kt | 26 + .../e3/GetLastCalibrationDatePacket.kt | 27 + .../e3/GetLastCalibrationTimePacket.kt | 27 + .../e3/GetLowGlucoseRepeatIntervalPacket.kt | 21 + .../packets/e3/GetMmaFeaturesPacket.kt | 21 + .../e3/GetNextCalibrationDatePacket.kt | 30 + .../e3/GetNextCalibrationTimePacket.kt | 30 + .../e3/GetSettingGlucoseHighEnabled.kt | 29 + .../GetSettingGlucoseHighThresholdPacket.kt | 32 + .../e3/GetSettingGlucoseLowThresholdPacket.kt | 30 + .../GetSettingPredictiveAlarmEnabledPacket.kt | 29 + .../GetSettingPredictiveHighEnabledPacket.kt | 29 + ...GetSettingPredictiveHighThresholdPacket.kt | 30 + .../e3/GetSettingPredictiveHighTimePacket.kt | 29 + .../GetSettingPredictiveLowEnabledPacket.kt | 29 + .../GetSettingPredictiveLowThresholdPacket.kt | 30 + .../e3/GetSettingPredictiveLowTimePacket.kt | 29 + .../packets/e3/GetSettingRateEnabledPacket.kt | 29 + .../e3/GetSettingRateFallingEnabledPacket.kt | 29 + .../GetSettingRateFallingThresholdPacket.kt | 29 + .../e3/GetSettingRateRisingEnabledPacket.kt | 29 + .../e3/GetSettingRateRisingThresholdPacket.kt | 29 + .../packets/e3/GetSettingVibratePacket.kt | 31 + .../packets/e3/GetSignalStrengthRawPacket.kt | 45 ++ .../packets/e3/GetVersionExtendedPacket.kt | 23 + .../eversense/packets/e3/GetVersionPacket.kt | 23 + .../eversense/packets/e3/PingPacket.kt | 23 + .../e3/SaveBondingInformationPacket.kt | 28 + .../packets/e3/SendCalibrationPacket.kt | 77 ++ .../packets/e3/SetAppVersionE3Packet.kt | 30 + .../packets/e3/SetBleDisconnectPacket.kt | 24 + .../packets/e3/SetBloodGlucosePointPacket.kt | 43 ++ .../packets/e3/SetCurrentDatetimePacket.kt | 32 + .../SetHighGlucoseRepeatIntervalDayPacket.kt | 27 + ...SetHighGlucoseRepeatIntervalNightPacket.kt | 27 + .../SetLowGlucoseRepeatIntervalDayPacket.kt | 27 + .../SetLowGlucoseRepeatIntervalNightPacket.kt | 27 + .../e3/SetSettingGlucoseHighEnablePacket.kt | 30 + .../SetSettingGlucoseHighThresholdPacket.kt | 30 + .../e3/SetSettingGlucoseLowThresholdPacket.kt | 30 + .../SetSettingPredictiveAlarmEnabledPacket.kt | 30 + ...SettingPredictiveHighAlarmEnabledPacket.kt | 30 + ...SetSettingPredictiveHighThresholdPacket.kt | 30 + .../e3/SetSettingPredictiveHighTimePacket.kt | 29 + ...tSettingPredictiveLowAlarmEnabledPacket.kt | 30 + .../SetSettingPredictiveLowThresholdPacket.kt | 30 + .../e3/SetSettingPredictiveLowTimePacket.kt | 29 + .../packets/e3/SetSettingRateEnabledPacket.kt | 30 + .../e3/SetSettingRateFallingEnabledPacket.kt | 30 + .../SetSettingRateFallingThresholdPacket.kt | 30 + .../e3/SetSettingRateRisingEnabledPacket.kt | 30 + .../e3/SetSettingRateRisingThresholdPacket.kt | 30 + .../packets/e3/SetSettingVibratePacket.kt | 30 + .../packets/e3/util/EversenseE3Parser.kt | 77 ++ .../packets/e3/util/EversenseE3Writer.kt | 60 ++ .../packets/e365/AuthIdentityPacket.kt | 28 + .../eversense/packets/e365/AuthStartPacket.kt | 32 + .../packets/e365/AuthWhoAmIPacket.kt | 36 + .../e365/EnterDiagnosticMode365Packet.kt | 17 + .../packets/e365/Eversense365Packets.kt | 75 ++ .../e365/ExitDiagnosticMode365Packet.kt | 17 + .../packets/e365/GetActiveAlarmsPacket.kt | 52 ++ .../packets/e365/GetAlertsLogValuesPacket.kt | 64 ++ .../packets/e365/GetCalibrationInfoPacket.kt | 61 ++ .../e365/GetCalibrationLogValuesPacket.kt | 72 ++ .../packets/e365/GetGlucoseDataPacket.kt | 84 +++ .../packets/e365/GetGlucoseLogValuesPacket.kt | 84 +++ .../packets/e365/GetLogRangePacket365.kt | 47 ++ .../packets/e365/GetPatientSettingsPacket.kt | 83 +++ .../e365/GetSensorInformationPacket.kt | 90 +++ .../packets/e365/GetSignalStrengthPacket.kt | 63 ++ .../eversense/packets/e365/KeepAlivePacket.kt | 30 + .../eversense/packets/e365/Ping365Packet.kt | 17 + .../packets/e365/PushAlarmWithDataPacket.kt | 48 ++ .../packets/e365/PushKeepAlive365Packet.kt | 50 ++ .../packets/e365/SetAppVersion365Packet.kt | 24 + .../packets/e365/SetBleDisconnect365Packet.kt | 25 + .../e365/SetBloodGlucosePointPacket365.kt | 32 + .../packets/e365/SetCurrentDateTimePacket.kt | 38 + .../e365/SetHighGlucoseAlarm365Packet.kt | 20 + .../SetHighGlucoseAlarmEnabled365Packet.kt | 17 + .../e365/SetLowGlucoseAlarm365Packet.kt | 20 + .../e365/SetPredictionHighEnabled365Packet.kt | 17 + .../SetPredictionHighThreshold365Packet.kt | 20 + .../e365/SetPredictionHighTime365Packet.kt | 20 + .../e365/SetPredictionLowEnabled365Packet.kt | 17 + .../SetPredictionLowThreshold365Packet.kt | 20 + .../e365/SetPredictionLowTime365Packet.kt | 20 + .../e365/SetRateFallingEnabled365Packet.kt | 17 + .../e365/SetRateFallingThreshold365Packet.kt | 23 + .../e365/SetRateRisingEnabled365Packet.kt | 17 + .../e365/SetRateRisingThreshold365Packet.kt | 23 + .../e365/SetRepeatHighGlucose365Packet.kt | 20 + .../e365/SetRepeatLowGlucose365Packet.kt | 20 + .../packets/e365/SetVibrateMode365Packet.kt | 17 + .../packets/e365/utils/ByteArrayUtil.kt | 70 ++ .../eversense/util/EversenseCrypto365Util.kt | 303 ++++++++ .../eversense/util/EversenseHttp365Util.kt | 500 +++++++++++++ .../eversense/util/EversenseHttpE3Util.kt | 282 ++++++++ .../eversense/util/EversenseLogger.kt | 118 ++++ .../eversense/util/EversenseScanner.kt | 35 + .../nightscout/eversense/util/MessageCoder.kt | 114 +++ .../eversense/util/RangeCalculator.kt | 21 + .../nightscout/eversense/util/StorageKeys.kt | 11 + .../packets/e3/CalibrationPacketTest.kt | 181 +++++ .../util/EversenseHttp365UtilTest.kt | 316 +++++++++ 159 files changed, 8550 insertions(+) create mode 100644 plugins/eversense/.gitignore create mode 100644 plugins/eversense/build.gradle.kts create mode 100644 plugins/eversense/consumer-rules.pro create mode 100644 plugins/eversense/proguard-rules.pro create mode 100644 plugins/eversense/src/main/AndroidManifest.xml create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseCGMPlugin.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseGattCallback.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseScanCallback.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseWatcher.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/BatteryLevel.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationFlag.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationMode.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationPhase.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationReadiness.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CommandError.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseAlarm.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseE3Memory.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseSecurityType.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseTrendArrow.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseType.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/SignalStrength.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/TransmitterAlert.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/exceptions/EversenseWriteException.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/ActiveAlarm.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseCGMResult.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseScanResult.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseSecureState.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseState.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/GlucoseHistoryItem.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/Eversense365Communicator.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseBasePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseE3Communicator.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversensePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EnterDiagnosticModePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EversenseE3Packets.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/ExitDiagnosticModePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetAlertLogPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBatteryPercentagePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBleDisconnectPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationDailyPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogRangePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationPhasePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationReadinessPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCompletedCalibrationsCountPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentDatetimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentGlucosePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseAlertsAndStatusPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogRangePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetHighGlucoseRepeatIntervalPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionDatePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetIsOneCalPhasePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationDatePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLowGlucoseRepeatIntervalPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetMmaFeaturesPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationDatePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighEnabled.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseLowThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveAlarmEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingVibratePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSignalStrengthRawPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionExtendedPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/PingPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SaveBondingInformationPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SendCalibrationPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetAppVersionE3Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBleDisconnectPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBloodGlucosePointPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetCurrentDatetimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalDayPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalNightPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalDayPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalNightPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighEnablePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseLowThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveAlarmEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighAlarmEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowAlarmEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingEnabledPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingThresholdPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingVibratePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Parser.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Writer.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthIdentityPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthStartPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthWhoAmIPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/EnterDiagnosticMode365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Eversense365Packets.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/ExitDiagnosticMode365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetActiveAlarmsPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetAlertsLogValuesPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationInfoPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationLogValuesPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseDataPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseLogValuesPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetLogRangePacket365.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetPatientSettingsPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSensorInformationPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSignalStrengthPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/KeepAlivePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Ping365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushAlarmWithDataPacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushKeepAlive365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetAppVersion365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBleDisconnect365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBloodGlucosePointPacket365.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetCurrentDateTimePacket.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarm365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarmEnabled365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetLowGlucoseAlarm365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighEnabled365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighThreshold365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighTime365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowEnabled365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowThreshold365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowTime365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingEnabled365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingThreshold365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingEnabled365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingThreshold365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatHighGlucose365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatLowGlucose365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetVibrateMode365Packet.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/utils/ByteArrayUtil.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseCrypto365Util.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttp365Util.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttpE3Util.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseLogger.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseScanner.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/MessageCoder.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/RangeCalculator.kt create mode 100644 plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/StorageKeys.kt create mode 100644 plugins/eversense/src/test/kotlin/com/nightscout/eversense/packets/e3/CalibrationPacketTest.kt create mode 100644 plugins/eversense/src/test/kotlin/com/nightscout/eversense/util/EversenseHttp365UtilTest.kt diff --git a/plugins/eversense/.gitignore b/plugins/eversense/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/plugins/eversense/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/plugins/eversense/build.gradle.kts b/plugins/eversense/build.gradle.kts new file mode 100644 index 000000000000..2db4c061d478 --- /dev/null +++ b/plugins/eversense/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + + id("kotlinx-serialization") + id("android-module-dependencies") + id("test-module-dependencies") +} + +android { + namespace = "com.nightscout.eversense" +} + +dependencies { + api(libs.androidx.core) + api(platform(libs.kotlinx.serialization.bom)) + api(libs.kotlinx.serialization.json) + + api(libs.org.slf4j.api) + api(libs.com.github.tony19.logback.android) + + implementation("org.bouncycastle:bcpkix-jdk18on:1.81") + implementation("org.bouncycastle:bcprov-jdk18on:1.81") + + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") +} + + diff --git a/plugins/eversense/consumer-rules.pro b/plugins/eversense/consumer-rules.pro new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/plugins/eversense/proguard-rules.pro b/plugins/eversense/proguard-rules.pro new file mode 100644 index 000000000000..481bb4348141 --- /dev/null +++ b/plugins/eversense/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/plugins/eversense/src/main/AndroidManifest.xml b/plugins/eversense/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..9189b499de4d --- /dev/null +++ b/plugins/eversense/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseCGMPlugin.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseCGMPlugin.kt new file mode 100644 index 000000000000..3b4b885edb7c --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseCGMPlugin.kt @@ -0,0 +1,395 @@ +package com.nightscout.eversense + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.SharedPreferences +import android.os.ParcelUuid +import androidx.core.content.edit +import com.nightscout.eversense.callbacks.EversenseScanCallback +import com.nightscout.eversense.callbacks.EversenseWatcher +import com.nightscout.eversense.models.EversenseState +import com.nightscout.eversense.models.EversenseTransmitterSettings +import com.nightscout.eversense.packets.Eversense365Communicator +import com.nightscout.eversense.packets.EversenseE3Communicator +import com.nightscout.eversense.packets.e3.EnterDiagnosticModePacket +import com.nightscout.eversense.packets.e3.ExitDiagnosticModePacket +import com.nightscout.eversense.packets.e365.EnterDiagnosticMode365Packet +import com.nightscout.eversense.packets.e365.ExitDiagnosticMode365Packet +import com.nightscout.eversense.packets.e3.GetCalibrationReadinessPacket +import com.nightscout.eversense.packets.e3.GetSignalStrengthRawPacket +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.util.EversenseScanner +import com.nightscout.eversense.util.StorageKeys +import kotlinx.serialization.json.Json + +class EversenseCGMPlugin { + + // FIX 1: Use ApplicationContext to avoid leaking Activity context. + private var context: Context? = null + + private var bluetoothManager: BluetoothManager? = null + private var preferences: SharedPreferences? = null + private var gattCallback: EversenseGattCallback? = null + + // FIX 2: Lock object for synchronized access to connection state. + private val connectionLock = Any() + + private var scanner: EversenseScanner? = null + var watchers: List = listOf() + + fun setContext(context: Context, loggingEnabled: Boolean) { + // FIX 1: Always store applicationContext. + this.context = context.applicationContext + EversenseLogger.instance.enableLogging(loggingEnabled) + + val preference = context.applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE) + bluetoothManager = context.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + preferences = preference + gattCallback = EversenseGattCallback(this, preference) + } + + + fun addWatcher(watcher: EversenseWatcher) { + this.watchers += watcher + } + + fun removeWatcher(watcher: EversenseWatcher) { + this.watchers -= watcher + } + + fun isConnected(): Boolean = gattCallback?.isConnected() ?: false + fun is365(): Boolean = gattCallback?.is365() ?: false + + fun getCurrentState(): EversenseState? { + val preferences = preferences ?: run { + EversenseLogger.error(TAG, "No preferences available. Make sure setContext has been called") + return null + } + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + return JSON.decodeFromString(stateJson) + } + + @SuppressLint("MissingPermission") + fun startScan(callback: EversenseScanCallback) { + val bluetoothScanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: run { + EversenseLogger.error(TAG, "No bluetooth manager available. Make sure setContext has been called") + return + } + scanner = EversenseScanner(callback) + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + bluetoothScanner.startScan(null, settings, scanner) + EversenseLogger.info(TAG, "BLE scan started") + } + + @SuppressLint("MissingPermission") + fun stopScan() { + val bluetoothScanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: run { + EversenseLogger.error(TAG, "No bluetooth scanner available when trying to stop scan") + return + } + scanner?.let { + bluetoothScanner.stopScan(it) + scanner = null + EversenseLogger.info(TAG, "Scan stopped") + } ?: EversenseLogger.info(TAG, "stopScan called but no active scan found") + } + + @SuppressLint("MissingPermission") + fun connect(device: BluetoothDevice? = null): Boolean { + val bluetoothManager = this.bluetoothManager ?: run { + EversenseLogger.error(TAG, "No bluetooth manager available. Make sure setContext has been called") + return false + } + val gattCallback = this.gattCallback ?: run { + EversenseLogger.error(TAG, "No gattCallback available. Make sure setContext has been called") + return false + } + + stopScan() + + synchronized(connectionLock) { + if (gattCallback.isConnected()) { + EversenseLogger.info(TAG, "Already connected, skipping reconnect") + return true + } + + gattCallback.cleanUp() + + return if (device != null) { + EversenseLogger.info(TAG, "Connecting to supplied device: ${device.name}") + preferences?.edit()?.putString(StorageKeys.REMOTE_DEVICE_KEY, device.address)?.apply() + EversenseLogger.info(TAG, "Saved device address for auto-reconnect: ${device.address}") + device.connectGatt(context, true, gattCallback, android.bluetooth.BluetoothDevice.TRANSPORT_LE) + true + } else { + val address = preferences?.getString(StorageKeys.REMOTE_DEVICE_KEY, null) ?: run { + EversenseLogger.error(TAG, "No device supplied and no stored device address found.") + return false + } + val remoteDevice = bluetoothManager.adapter.getRemoteDevice(address) ?: run { + EversenseLogger.error(TAG, "Could not retrieve remote device for address $address") + return false + } + EversenseLogger.info(TAG, "Reconnecting to stored device: $address") + remoteDevice.connectGatt(context, true, gattCallback, android.bluetooth.BluetoothDevice.TRANSPORT_LE) + true + } + } + } + + fun clearStoredDevice() { + preferences?.edit()?.remove(StorageKeys.REMOTE_DEVICE_KEY)?.apply() + EversenseLogger.info(TAG, "Cleared stored device address") + } + + fun disconnect() { + val gattCallback = this.gattCallback ?: run { + EversenseLogger.info(TAG, "disconnect() called but no gattCallback exists") + return + } + if (!gattCallback.isConnected()) { + EversenseLogger.info(TAG, "disconnect() called but not currently connected") + return + } + gattCallback.disconnect() + EversenseLogger.info(TAG, "Disconnected from transmitter") + } + + fun setDiagnosticMode(isEnabled: Boolean) { + if (gattCallback?.isConnected() != true) { + EversenseLogger.warning(TAG, "Cannot set diagnostic mode — not connected") + return + } + try { + if (gattCallback?.is365() == true) { + if (isEnabled) { + gattCallback!!.writePacket(EnterDiagnosticMode365Packet()) + } else { + gattCallback!!.writePacket(ExitDiagnosticMode365Packet()) + } + EversenseLogger.info(TAG, "Diagnostic mode set to $isEnabled (365)") + } else { + if (isEnabled) { + gattCallback!!.writePacket(EnterDiagnosticModePacket()) + } else { + gattCallback!!.writePacket(ExitDiagnosticModePacket()) + } + EversenseLogger.info(TAG, "Diagnostic mode set to $isEnabled (E3)") + } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "setDiagnosticMode failed: $e") + } + } + + fun writeSettings(settings: EversenseTransmitterSettings): Boolean { + val preferences = preferences ?: run { + EversenseLogger.error(TAG, "No preferences available. Make sure setContext has been called") + return false + } + val gattCallback = this.gattCallback ?: run { + EversenseLogger.error(TAG, "No gattCallback available. Make sure transmitter is connected before writing settings") + return false + } + if (!gattCallback.isConnected()) { + EversenseLogger.error(TAG, "Transmitter is not connected") + return false + } + return EversenseE3Communicator.writeSettings(gattCallback, preferences, settings) + } + + // Send a blood glucose calibration value to the transmitter. + // Requires CalibrationReadiness.READY state and an active connection. + // Returns true if the packet was sent successfully, false otherwise. + fun sendCalibration(glucoseMgDl: Int, timestampMs: Long = System.currentTimeMillis()): Boolean { + val gattCallback = this.gattCallback ?: run { + EversenseLogger.error(TAG, "No gattCallback available. Make sure transmitter is connected before calibrating") + return false + } + if (!gattCallback.isConnected()) { + EversenseLogger.error(TAG, "Transmitter is not connected") + return false + } + val state = getCurrentState() ?: run { + EversenseLogger.error(TAG, "Cannot calibrate: state is null") + return false + } + if (state.calibrationReadiness != com.nightscout.eversense.enums.CalibrationReadiness.READY) { + EversenseLogger.error(TAG, "Transmitter is not ready for calibration: ${state.calibrationReadiness}") + return false + } + return try { + // Submit calibration to bleExecutor so it runs on the same thread as BLE callbacks. + // Calling writePacket directly from a foreign thread races with Keep Alive cycles + // that overwrite currentPacket — the response notifyAll() would then be missed. + val future = gattCallback.submitToExecutor { + if (gattCallback.is365()) { + val packet = com.nightscout.eversense.packets.e365.SetBloodGlucosePointPacket365(glucoseMgDl, timestampMs) + gattCallback.writePacket(packet) + EversenseLogger.info(TAG, "365 calibration sent: $glucoseMgDl mg/dL") + } else { + EversenseE3Communicator.sendCalibration(gattCallback, glucoseMgDl) + } + } + future.get(20000, java.util.concurrent.TimeUnit.MILLISECONDS) + + // Update state immediately after successful calibration submission. + val prefs = preferences ?: return true + val stateJson = prefs.getString(com.nightscout.eversense.util.StorageKeys.STATE, null) ?: "{}" + val updatedState = JSON.decodeFromString(stateJson) + updatedState.lastCalibrationDate = timestampMs + updatedState.nextCalibrationDate = timestampMs + 24 * 60 * 60 * 1000L // +24 hours + updatedState.calibrationReadiness = com.nightscout.eversense.enums.CalibrationReadiness.WAITING_POST_CALIBRATION + prefs.edit(commit = true) { + putString(com.nightscout.eversense.util.StorageKeys.STATE, JSON.encodeToString(updatedState)) + } + EversenseLogger.info(TAG, "Updated calibration state: lastCalibrationDate=$timestampMs, readiness=WAITING_POST_CALIBRATION") + + // For E3: immediately re-read calibration readiness from the transmitter on the + // bleExecutor — matching the official app's postReadyForCalibration() call after + // calibration submission. The transmitter will have updated the register to + // WAITING_POST_CALIBRATION (id=8) by the time the read completes. + if (!gattCallback.is365()) { + gattCallback.submitToExecutor { + try { + val readinessResponse = gattCallback.writePacket(GetCalibrationReadinessPacket()) + val currentStateJson = prefs.getString(com.nightscout.eversense.util.StorageKeys.STATE, null) ?: "{}" + val currentState = JSON.decodeFromString(currentStateJson) + currentState.calibrationReadiness = readinessResponse.readiness + prefs.edit(commit = true) { + putString(com.nightscout.eversense.util.StorageKeys.STATE, JSON.encodeToString(currentState)) + } + EversenseLogger.info(TAG, "Post-calibration readiness re-read: ${readinessResponse.readiness}") + watchers.forEach { it.onStateChanged(currentState) } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Post-calibration readiness re-read failed (non-fatal): $e") + } + } + } + + true + } catch (e: Exception) { + EversenseLogger.error(TAG, "Failed to send calibration: $e") + false + } + } + + + // Triggers both a full sync and a glucose read on the connected transmitter. + // Submit fullSync to the bleExecutor so it runs on the same thread as BLE callbacks. + // This prevents races between fullSync and handleCharacteristicChanged/writePacket. + // Called from onConnectionChanged to run immediately on connect without waiting for Keep Alive. + fun submitToExecutorAndSync(force: Boolean = false) { + val gattCallback = this.gattCallback ?: run { + EversenseLogger.error(TAG, "Cannot sync — no gattCallback available") + return + } + val preferences = preferences ?: run { + EversenseLogger.error(TAG, "Cannot sync — no preferences available") + return + } + if (!gattCallback.isConnected()) { + EversenseLogger.error(TAG, "Cannot sync — not connected") + return + } + // For 365 transmitters, authV2flow already calls fullSync on connect — + // submitting another one here would race with it and cause disconnections. + // Only submit on-connect fullSync for E3 transmitters. + if (gattCallback.is365()) { + EversenseLogger.info(TAG, "365 transmitter — skipping submitToExecutorAndSync (authV2flow handles it)") + return + } + gattCallback.submitToExecutor { + EversenseLogger.info(TAG, "Running E3 fullSync on bleExecutor after connect") + EversenseE3Communicator.fullSync(gattCallback, preferences, watchers.toList(), force) + } + } + + fun triggerFullSync(force: Boolean = false) { + val gattCallback = this.gattCallback ?: run { + EversenseLogger.error(TAG, "Cannot sync — no gattCallback available") + return + } + val preferences = preferences ?: run { + EversenseLogger.error(TAG, "Cannot sync — no preferences available") + return + } + if (!gattCallback.isConnected()) { + EversenseLogger.error(TAG, "Cannot sync — not connected") + return + } + EversenseLogger.info(TAG, "Triggering full sync on user request") + if (gattCallback.is365()) { + Eversense365Communicator.fullSync(gattCallback, preferences, watchers.toList(), force) + Eversense365Communicator.readGlucose(gattCallback, preferences, watchers.toList()) + } else { + EversenseE3Communicator.fullSync(gattCallback, preferences, watchers.toList(), force) + EversenseE3Communicator.readGlucose(gattCallback, preferences, watchers.toList()) + } + gattCallback.readRssi() + } + + // Called by EversenseGattCallback when RSSI is read + fun onRssiRead(rssi: Int) { + val preferences = preferences ?: return + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + state.placementSignalRssi = rssi + state.sensorSignalStrength = rssiToStrength(rssi) + preferences.edit()?.putString(StorageKeys.STATE, JSON.encodeToString(state))?.apply() + EversenseLogger.debug(TAG, "RSSI updated: $rssi dBm") + watchers.forEach { it.onStateChanged(state) } + } + + fun readSignalStrength() { + val gattCallback = this.gattCallback ?: run { EversenseLogger.error(TAG, "Cannot read signal strength — no gattCallback"); return } + val preferences = this.preferences ?: run { EversenseLogger.error(TAG, "Cannot read signal strength — no preferences"); return } + if (!gattCallback.isConnected()) { EversenseLogger.warning(TAG, "Cannot read signal strength — not connected"); return } + try { + val signalStrength = if (gattCallback.is365()) { + val response = gattCallback.writePacket(com.nightscout.eversense.packets.e365.GetSignalStrengthPacket()) + response.signalStrength + } else { + val response = gattCallback.writePacket(GetSignalStrengthRawPacket()) + EversenseLogger.info(TAG, "E3 signal raw: ${response.rawValue} -> ${response.signalStrength}%") + response.signalStrength + } + val stateJson = preferences.getString(com.nightscout.eversense.util.StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + state.sensorSignalStrength = signalStrength + preferences.edit()?.putString(com.nightscout.eversense.util.StorageKeys.STATE, JSON.encodeToString(state))?.apply() + EversenseLogger.info(TAG, "Signal strength: $signalStrength%") + watchers.forEach { it.onStateChanged(state) } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "readSignalStrength failed: $e") + gattCallback.readRssi() + } + } + + private fun rssiToStrength(rssi: Int): Int = when { + rssi == 0 -> 0 + rssi >= -65 -> 100 + rssi >= -75 -> 80 + rssi >= -85 -> 60 + rssi >= -95 -> 40 + else -> 20 + } + + fun readRssi() { + gattCallback?.readRssi() + } + + companion object { + private const val TAG = "EversenseCGMManager" + + // ignoreUnknownKeys: tolerates firmware version differences between E3 and 365 transmitters. + private val JSON = Json { ignoreUnknownKeys = true } + + val instance: EversenseCGMPlugin by lazy { + EversenseCGMPlugin() + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseGattCallback.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseGattCallback.kt new file mode 100644 index 000000000000..cd73adbe8c6f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/EversenseGattCallback.kt @@ -0,0 +1,659 @@ +package com.nightscout.eversense + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothProfile +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import androidx.core.content.edit +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.exceptions.EversenseWriteException +import com.nightscout.eversense.packets.Eversense365Communicator +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversenseE3Communicator +import com.nightscout.eversense.packets.e365.AuthIdentityPacket +import com.nightscout.eversense.packets.e365.AuthStartPacket +import com.nightscout.eversense.packets.e365.AuthWhoAmIPacket +import com.nightscout.eversense.packets.e365.Eversense365Packets +import com.nightscout.eversense.packets.e365.KeepAlivePacket +import com.nightscout.eversense.packets.e3.EversenseE3Packets +import com.nightscout.eversense.packets.e3.SaveBondingInformationPacket +import com.nightscout.eversense.util.EversenseCrypto365Util +import com.nightscout.eversense.util.EversenseHttp365Util +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.util.StorageKeys +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.jvm.Throws + +class EversenseGattCallback( + private val plugin: EversenseCGMPlugin, + private val preferences: SharedPreferences +) : BluetoothGattCallback() { + + companion object { + private const val TAG = "EversenseGattCallback" + + const val serviceUUID = "c3230001-9308-47ae-ac12-3d030892a211" + + private const val requestUUID = "6eb0f021-a7ba-7e7d-66c9-6d813f01d273" + private const val requestSecureV2UUID = "c3230002-9308-47ae-ac12-3d030892a211" + + private const val responseUUID = "6eb0f024-bd60-7aaa-25a7-0029573f4f23" + private const val responseSecureV2UUID = "c3230003-9308-47ae-ac12-3d030892a211" + private const val magicDescriptorUUID = "00002902-0000-1000-8000-00805f9b34fb" + + private const val WRITE_TIMEOUT_MS = 5000L + private const val CALIBRATION_TIMEOUT_MS = 15000L + + // Number of consecutive authV2flow failures before abandoning the shortcut path + // and forcing a full re-auth (WhoAmI + fleet certificate). This handles the case + // where the BLE stack resets (e.g. charger plug-in) and the session key is lost. + // A value of 3 allows transient glitches to recover without internet, while still + // falling back to full auth after sustained failures. + private const val SHORTCUT_FAIL_THRESHOLD = 3 + } + + // FIX 1: Dedicated BLE executor for callbacks; separate network executor for HTTP calls + // so that network operations in authV2flow() cannot block BLE processing. + private var bleExecutor = Executors.newSingleThreadExecutor() + private val networkExecutor = Executors.newSingleThreadExecutor() + + private val handler = Handler(Looper.getMainLooper()) + private var bluetoothGatt: BluetoothGatt? = null + private var eversenseBluetoothService: BluetoothGattService? = null + private var requestCharacteristic: BluetoothGattCharacteristic? = null + private var responseCharacteristic: BluetoothGattCharacteristic? = null + + private var payloadSize: Int = 20 + private var security: EversenseSecurityType = EversenseSecurityType.None + private var cryptoUtil = EversenseCrypto365Util(preferences) + + // FIX 2: Use AtomicReference for currentPacket to avoid the race condition where a stale + // BLE notification could be processed against the wrong packet between assignment and write. + var currentPacket: AtomicReference = AtomicReference(null) + + // FIX 3: Track connection state with a dedicated flag rather than relying on bluetoothGatt + // being non-null, which is not a reliable indicator of actual connection state. + @Volatile + private var connected: Boolean = false + + // Tracks consecutive status-19 failures to detect transmitter placement issues + @Volatile + private var failedConnectionAttempts: Int = 0 + private val PLACEMENT_WARNING_THRESHOLD = 3 + + // Tracks consecutive general reconnect attempts (reset on successful connection). + // Used to compute exponential backoff so AAPS retries quickly after boot (when the + // official Eversense app temporarily holds the BLE connection) and backs off for + // sustained failures to avoid draining the battery. + @Volatile + private var reconnectAttempts: Int = 0 + + // FIX 12: Tracks consecutive authV2flow failures while using the shortcut path. + // After SHORTCUT_FAIL_THRESHOLD failures, disallowUseShortcut() is called to force + // a full re-auth on the next connection. This handles BLE stack resets (e.g. charger + // plug-in) that invalidate the session key without needing internet on every reconnect. + @Volatile + private var shortcutFailCount: Int = 0 + + fun isConnected(): Boolean = connected + fun is365(): Boolean = security == EversenseSecurityType.SecureV2 + + // Submit a task to the bleExecutor and return a Future so callers can block until complete. + // This ensures calibration and other ad-hoc BLE operations are serialised with Keep Alive + // cycles and do not race with currentPacket assignment. + fun submitToExecutor(task: () -> Unit): java.util.concurrent.Future<*> = + bleExecutor.submit(task) + + // FIX 4: Added disconnect() which calls both disconnect() and close() on the GATT client. + // Calling only disconnect() without close() leaks the underlying GATT client resource. + @SuppressLint("MissingPermission") + fun disconnect() { + bluetoothGatt?.disconnect() + bluetoothGatt?.close() + bluetoothGatt = null + connected = false + EversenseLogger.info(TAG, "GATT disconnected and closed") + } + @SuppressLint("MissingPermission") + fun cleanUp() { + bluetoothGatt?.disconnect() + bluetoothGatt?.close() + bluetoothGatt = null + connected = false + bleExecutor.shutdownNow() + bleExecutor = Executors.newSingleThreadExecutor() + EversenseLogger.info(TAG, "GATT cleaned up before reconnect") + } + @SuppressLint("MissingPermission") + fun readRssi() { + bluetoothGatt?.readRemoteRssi() ?: EversenseLogger.warning(TAG, "Cannot read RSSI — not connected") + } + + @SuppressLint("MissingPermission") + override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + EversenseLogger.debug(TAG, "RSSI: $rssi dBm") + plugin.onRssiRead(rssi) + } else { + EversenseLogger.warning(TAG, "Failed to read RSSI - status: $status") + } + } + + @SuppressLint("MissingPermission") + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + EversenseLogger.info(TAG, "Connection state changed - status: $status, newState: $newState, device: ${gatt.device.name}") + + if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) { + bluetoothGatt = gatt + // FIX 3: Set connected flag on confirmed STATE_CONNECTED. + connected = true + // Reset backoff counters on successful connection + reconnectAttempts = 0 + failedConnectionAttempts = 0 + + preferences.edit(commit = true) { + putString(StorageKeys.REMOTE_DEVICE_KEY, gatt.device.address) + } + + // FIX 5: Both connect and disconnect watcher notifications are now dispatched via + // handler.post() so they always arrive on the main thread, preventing UI thread crashes. + handler.post { + plugin.watchers.forEach { it.onConnectionChanged(true) } + } + + if (!gatt.requestMtu(512)) { + EversenseLogger.warning(TAG, "requestMtu returned false — skipping to discoverServices with default payload size") + payloadSize = 20 + gatt.discoverServices() + } + return + } + + if (newState == BluetoothProfile.STATE_DISCONNECTED || status != BluetoothGatt.GATT_SUCCESS) { + EversenseLogger.warning(TAG, "Disconnected or failed - status: $status, newState: $newState") + + // FIX 11: For E365 normal post-sync disconnect (status 19), reuse the existing + // GATT object and call gatt.connect() directly — exactly as the official app does. + // This preserves the BLE bond and session key so the shortcut auth path works + // without needing internet. For all other disconnects, close and reconnect fresh. + if (status == 19 && is365()) { + connected = false + handler.post { + plugin.watchers.forEach { it.onConnectionChanged(false) } + } + EversenseLogger.debug(TAG, "365 post-sync disconnect (status 19) — reusing GATT for reconnect") + gatt.connect() + return + } + + gatt.close() + bluetoothGatt = null + connected = false + + handler.post { + plugin.watchers.forEach { it.onConnectionChanged(false) } + } + + if (status == 19) { + // E3 only — E365 status 19 is handled above + failedConnectionAttempts++ + EversenseLogger.warning(TAG, "Connection terminated by transmitter (status 19) — attempt $failedConnectionAttempts") + if (failedConnectionAttempts >= PLACEMENT_WARNING_THRESHOLD) { + handler.post { plugin.watchers.forEach { it.onTransmitterNotPlaced() } } + } + } else { + failedConnectionAttempts = 0 + } + + val storedAddress = preferences.getString(StorageKeys.REMOTE_DEVICE_KEY, null) + if (storedAddress != null) { + // Exponential backoff so AAPS reclaims the transmitter quickly after boot + // (when the official Eversense app temporarily holds the BLE connection) + // and avoids battery drain during sustained unavailability. + // + // Status 19 = transmitter actively rejected us (placement issue, not competition) — + // use a fixed 30 s interval so we don't spam it. + // Status GATT_SUCCESS = clean disconnect (we or the transmitter closed cleanly) — + // reconnect quickly in 5 s. + // All other status codes (e.g. 133 = GATT_ERROR, device busy) = backoff: + // attempt 0 → 5 s, attempt 1 → 10 s, attempt 2 → 20 s, attempt 3 → 40 s, + // attempt 4+ → 60 s cap. + val delayMs: Long = when { + status == 19 -> 30_000L + status == BluetoothGatt.GATT_SUCCESS -> 5_000L + else -> { + val attempt = reconnectAttempts++ + minOf(5_000L * (1L shl minOf(attempt, 4)), 60_000L) + } + } + EversenseLogger.info(TAG, "Scheduling auto-reconnect in ${delayMs / 1000}s (status: $status, attempt: $reconnectAttempts)") + handler.postDelayed({ + EversenseLogger.info(TAG, "Attempting auto-reconnect (attempt $reconnectAttempts)...") + plugin.connect(null) + }, delayMs) + } else { + EversenseLogger.warning(TAG, "No stored device address — skipping auto-reconnect") + } + } + } + + @SuppressLint("MissingPermission") + override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { + if (status != 0) { + EversenseLogger.warning(TAG, "MTU negotiation failed (status: $status) — using default payload size of 20") + payloadSize = 20 + } else { + payloadSize = mtu - 3 + } + EversenseLogger.debug(TAG, "New payload size: $payloadSize") + + val success = gatt?.discoverServices() + EversenseLogger.info(TAG, "Trigger discover services - success: $success") + } + + @SuppressLint("MissingPermission") + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + EversenseLogger.info(TAG, "Discovered services - status: $status") + + if (gatt == null) { + EversenseLogger.error(TAG, "Gatt is null") + return + } + + // FIX 6: Use firstOrNull instead of first. The original code used .first {} which throws + // NoSuchElementException if the service is missing. The null check below it was dead code + // that could never be reached. firstOrNull correctly returns null on no match. + val service = gatt.services.firstOrNull { it.uuid.toString() == serviceUUID } + if (service == null) { + EversenseLogger.error(TAG, "Required service not found -> disconnecting") + gatt.disconnect() + return + } + + eversenseBluetoothService = service + if (service.characteristics.isEmpty()) { + EversenseLogger.error(TAG, "Service has no characteristics -> disconnecting") + gatt.disconnect() + return + } + + var requestChar = service.characteristics.find { it.uuid.toString() == requestUUID } + var responseChar = service.characteristics.find { it.uuid.toString() == responseUUID } + if (requestChar != null && responseChar != null) { + EversenseLogger.info(TAG, "Connected to Eversense E3!") + security = EversenseSecurityType.None + requestCharacteristic = requestChar + responseCharacteristic = responseChar + + gatt.setCharacteristicNotification(requestChar, true) + gatt.setCharacteristicNotification(responseChar, true) + enableNotify(gatt, responseChar) + return + } + + requestChar = service.characteristics.find { it.uuid.toString() == requestSecureV2UUID } + responseChar = service.characteristics.find { it.uuid.toString() == responseSecureV2UUID } + if (requestChar == null || responseChar == null) { + EversenseLogger.error(TAG, "No Eversense request/response characteristic found -> disconnecting") + gatt.disconnect() + return + } + + EversenseLogger.info(TAG, "Connected to Eversense 365!") + security = EversenseSecurityType.SecureV2 + requestCharacteristic = requestChar + responseCharacteristic = responseChar + + gatt.setCharacteristicNotification(requestChar, true) + gatt.setCharacteristicNotification(responseChar, true) + enableNotify(gatt, responseChar) + } + + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + EversenseLogger.debug(TAG, "onDescriptorWrite (${descriptor.uuid}) for characteristic (${descriptor.characteristic.uuid}) - status $status") + + if (status == BluetoothGatt.GATT_SUCCESS && descriptor.uuid.toString() == magicDescriptorUUID) { + if (descriptor.characteristic.uuid.toString() == responseUUID) { + bleExecutor.submit { authE3flow() } + } else if (descriptor.characteristic.uuid.toString() == responseSecureV2UUID) { + bleExecutor.submit { authV2flow() } + } + } + } + + // FIX 7: Override both the deprecated and current API 33+ signature of onCharacteristicChanged. + // On Android 13+ (API 33+) the old single-argument override is never called by the system — + // only the new three-argument version is. Without this override, glucose data would be silently + // dropped on API 33+ devices. Both delegates to a shared handler to avoid code duplication. + @SuppressLint("MissingPermission") + @OptIn(ExperimentalStdlibApi::class) + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + handleCharacteristicChanged(gatt, value) + } + + @Deprecated("Deprecated in API 33 — overridden for compatibility with Android < 13") + @SuppressLint("MissingPermission") + @OptIn(ExperimentalStdlibApi::class) + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + handleCharacteristicChanged(gatt, characteristic.value) + } + + @SuppressLint("MissingPermission") + @OptIn(ExperimentalStdlibApi::class) + private fun handleCharacteristicChanged(gatt: BluetoothGatt, rawData: ByteArray) { + EversenseLogger.debug(TAG, "Received data: ${rawData.toHexString()}") + + var data = rawData + if (security == EversenseSecurityType.SecureV2) { + data = data.drop(3).toByteArray() + + if (data[0] != Eversense365Packets.AuthenticateResponseId) { + data = cryptoUtil.decrypt(data) + EversenseLogger.debug(TAG, "Decrypted data -> ${data.toHexString()}") + if (data.isEmpty()) { + EversenseLogger.error(TAG, "Failed to decrypt data — disconnecting, will retry shortcut on next connection") + gatt.disconnect() + return + } + } + } + + if (!is365() && EversenseE3Packets.isPushPacket(data[0])) { + EversenseLogger.debug(TAG, "Keep Alive packet received (E3)!") + bleExecutor.submit { + // Sync transmitter clock before reading glucose so the glucose timestamp + // reflects phone time, not the drifted transmitter clock. The official app + // always calls postCurrentDateTimeRequest before postReadSensorGlucose. + try { + val currentDatetime = writePacket( + com.nightscout.eversense.packets.e3.GetCurrentDatetimePacket() + ) + if (currentDatetime.needsTimeSync) { + EversenseLogger.info(TAG, "Clock drift detected before glucose read — syncing transmitter clock") + writePacket( + com.nightscout.eversense.packets.e3.SetCurrentDatetimePacket() + ) + } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Pre-glucose clock sync failed (non-fatal): $e") + } + EversenseE3Communicator.readGlucose(this, preferences, plugin.watchers) + EversenseE3Communicator.fullSync(this, preferences, plugin.watchers) + } + return + } + + if (Eversense365Packets.isKeepAlivePacket(data[0], data[1])) { + EversenseLogger.debug(TAG, "Keep Alive packet received (365)!") + + val packet = KeepAlivePacket() + packet.appendData(data.toUByteArray()) + val response = packet.parseResponse() ?: return + + val fourHalfMinAgo = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(270) + bleExecutor.submit { + if (response.glucoseDatetime > fourHalfMinAgo) { + Eversense365Communicator.readGlucose(this, preferences, plugin.watchers) + Eversense365Communicator.fullSync(this, preferences, plugin.watchers) + } + } + return + } else if (data.size >= 4 && data[0] == Eversense365Packets.NotificationResponseId && data[1] == 0x03.toByte()) { + // Push alarm notification + val alarmCode = data[3].toInt() and 0xFF + val alarm = com.nightscout.eversense.models.ActiveAlarm( + code = com.nightscout.eversense.enums.EversenseAlarm.from(alarmCode), + codeRaw = alarmCode, + flag = 0, + priority = 0 + ) + EversenseLogger.info(TAG, "Push alarm received: ${alarm.code.title}") + handler.post { + plugin.watchers.forEach { it.onAlarmReceived(alarm) } + } + return + } else if (Eversense365Packets.isNotificationPacket(data[0])) { + EversenseLogger.warning(TAG, "Unknown notification packet received") + return + } + + val packet = currentPacket.get() ?: run { + EversenseLogger.warning(TAG, "currentPacket is null -> skipping packet") + return + } + + synchronized(packet) { + val packetAnnotation = packet.getAnnotation() ?: run { + EversenseLogger.warning(TAG, "annotation is null -> skipping packet") + return + } + + // Only treat 0x80 as an error if it is NOT the expected response ID for this packet. + // ReadSingleByteSerialFlashRegister responses legitimately use 0xAA (170) as their + // response ID — but earlier versions of this code checked 0x80 before checking the + // expected response ID, causing every battery/readiness/calibration read to fail. + if (EversenseE3Packets.isErrorPacket(data[0]) && packetAnnotation.responseId != data[0]) { + EversenseLogger.error(TAG, "Received error response - data: ${data.toHexString()}") + packet.isErrorResponse = true + packet.notifyAll() + return + } + + if (security == EversenseSecurityType.None) { + if (!packet.skipResponseIdValidation && packetAnnotation.responseId != data[0]) { + EversenseLogger.warning(TAG, "Incorrect responseId - expected: ${packetAnnotation.responseId}, got: ${data[0]}") + return + } + packet.appendData(data.toUByteArray()) + packet.notifyAll() + } else { + if (packetAnnotation.responseId != data[0]) { + EversenseLogger.warning(TAG, "Incorrect responseId - expected: ${packetAnnotation.responseId}, got: ${data[0]}") + return + } + if (packetAnnotation.typeId != data[1]) { + EversenseLogger.warning(TAG, "Incorrect responseType - expected: ${packetAnnotation.typeId}, got: ${data[1]}") + return + } + packet.appendData(data.toUByteArray()) + packet.notifyAll() + } + } + } + + @Suppress("UNCHECKED_CAST") + @SuppressLint("MissingPermission") + @OptIn(ExperimentalStdlibApi::class) + @Throws(EversenseWriteException::class) + fun writePacket(packet: EversenseBasePacket, timeoutMs: Long = WRITE_TIMEOUT_MS): T { + val gatt = bluetoothGatt ?: throw EversenseWriteException("Gatt is null — not connected") + + val requestCharacteristic = requestCharacteristic + ?: throw EversenseWriteException("requestCharacteristic is null") + + val requestData = packet.buildRequest(cryptoUtil, payloadSize) + ?: throw EversenseWriteException("Failed to build request data") + + // FIX 2: Use AtomicReference.set() for thread-safe assignment of currentPacket. + currentPacket.set(packet) + + EversenseLogger.debug(TAG, "Writing data: ${requestData.toHexString()}") + requestCharacteristic.setValue(requestData) + gatt.writeCharacteristic(requestCharacteristic) + + synchronized(packet) { + try { + // FIX 8: Explicitly detect timeout by comparing elapsed time after wait() returns. + // Previously, a timeout would fall through to parseResponse() silently, likely + // producing a confusing cast exception rather than a clear timeout error. + val start = System.currentTimeMillis() + packet.wait(timeoutMs) + val elapsed = System.currentTimeMillis() - start + if (elapsed >= timeoutMs) { + currentPacket.set(null) + throw EversenseWriteException("Timed out waiting for response after ${timeoutMs}ms — packet: ${packet.getAnnotation()?.responseId}") + } else if (packet.isErrorResponse) { + currentPacket.set(null) + throw EversenseWriteException("Transmitter returned error response — packet: ${packet.getAnnotation()?.responseId}") + } + } catch (e: EversenseWriteException) { + throw e + } catch (e: Exception) { + EversenseLogger.error(TAG, "Exception during packet wait: $e") + e.printStackTrace() + } + } + + return try { + val response = packet.parseResponse() + currentPacket.set(null) + response as? T + ?: throw EversenseWriteException("Unable to cast response — packet: ${packet.getAnnotation()?.responseId}") + } catch (e: EversenseWriteException) { + throw e + } catch (e: Exception) { + throw EversenseWriteException("Failed to parse response: $e") + } + } + + private fun authE3flow() { + EversenseLogger.info(TAG, "Starting auth flow E3...") + try { + writePacket(SaveBondingInformationPacket()) + } catch (exception: Exception) { + EversenseLogger.error(TAG, "Auth flow E3 failed: $exception") + return + } + + EversenseLogger.info(TAG, "E3 auth complete — notifying watchers") + // fullSync is triggered by onConnectionChanged via triggerFullSync on the bleExecutor. + // Do NOT call fullSync here — it would race with the triggerFullSync call. + handler.post { plugin.watchers.forEach { it.onTransmitterReady() } } + } + + @SuppressLint("MissingPermission") + private fun authV2flow() { + // FIX 9: Network calls (login, getFleetSecretV2) are dispatched to a separate networkExecutor + // so they do not block the bleExecutor, which must remain available for BLE callbacks. + try { + if (!cryptoUtil.generateKeyPairIfNotExists()) { + bluetoothGatt?.disconnect() + return + } + + if (!cryptoUtil.canUseShortcut()) { + val clientId = cryptoUtil.getClientId() + val whoAmI = writePacket(AuthWhoAmIPacket(clientId)) + + // Dispatch HTTP work to the network executor and block bleExecutor until complete. + val authSession = networkExecutor.submit { + EversenseHttp365Util.login(preferences) + }.get() ?: run { + bluetoothGatt?.disconnect() + return + } + + authSession as? EversenseHttp365Util.LoginResponseModel ?: run { + bluetoothGatt?.disconnect() + return + } + + // Cache access token so it can be used for cloud uploads without re-login + val expiryMs = System.currentTimeMillis() + (authSession.expires_in * 1000L) + preferences.edit().putString(StorageKeys.ACCESS_TOKEN, authSession.access_token) + .putLong(StorageKeys.ACCESS_TOKEN_EXPIRY, expiryMs).apply() + + val fleet = networkExecutor.submit { + EversenseHttp365Util.getFleetSecretV2( + accessToken = authSession.access_token, + serialNumber = whoAmI.serialNumber, + nonce = whoAmI.nonce, + flags = whoAmI.flags, + publicKey = cryptoUtil.getClientPublicKey() + ) + }.get() ?: run { + bluetoothGatt?.disconnect() + return + } + + val fleetResponse = fleet as? EversenseHttp365Util.FleetSecretV2ResponseModel ?: run { + bluetoothGatt?.disconnect() + return + } + + @OptIn(ExperimentalStdlibApi::class) + writePacket( + AuthIdentityPacket(fleetResponse.Result.Certificate?.hexToByteArray() ?: byteArrayOf()) + ) + + cryptoUtil.allowUseShortcut() + } + + val signature = cryptoUtil.generateEphem() ?: run { + bluetoothGatt?.disconnect() + return + } + + val session = writePacket(AuthStartPacket(cryptoUtil.getStartSecret(signature))) + cryptoUtil.generateSessionKey(session.sessionPublicKey) + + // Auth succeeded — reset the shortcut fail counter + shortcutFailCount = 0 + + EversenseLogger.info(TAG, "365 auth complete — ready for full sync") + Eversense365Communicator.fullSync(this, preferences, plugin.watchers, force = true) + EversenseLogger.info(TAG, "365 transmitter ready — notifying watchers") + handler.post { plugin.watchers.forEach { it.onTransmitterReady() } } + + } catch (exception: Exception) { + EversenseLogger.error(TAG, "[365] authV2 failed: $exception") + exception.printStackTrace() + + // FIX 12: Track consecutive shortcut failures. After SHORTCUT_FAIL_THRESHOLD + // failures, force a full re-auth on the next connection. This recovers from + // BLE stack resets (e.g. charger plug-in) that invalidate the session key. + // We do NOT immediately disallow on the first failure — transient BLE glitches + // often recover on the next attempt without needing internet. + if (cryptoUtil.canUseShortcut()) { + shortcutFailCount++ + EversenseLogger.warning(TAG, "Shortcut auth failed ($shortcutFailCount/$SHORTCUT_FAIL_THRESHOLD)") + if (shortcutFailCount >= SHORTCUT_FAIL_THRESHOLD) { + EversenseLogger.warning(TAG, "Shortcut fail threshold reached — forcing full re-auth on next connection") + cryptoUtil.disallowUseShortcut() + shortcutFailCount = 0 + } + } + + bluetoothGatt?.disconnect() + } + } + + // FIX 10: enableNotify uses the API 33+ writeDescriptor(descriptor, value) overload when + // available, falling back to the deprecated setValue approach on older API levels. + @SuppressLint("MissingPermission") + @Suppress("DEPRECATION") + private fun enableNotify(gatt: BluetoothGatt, responseCharacteristic: BluetoothGattCharacteristic) { + val descriptor = responseCharacteristic.getDescriptor(UUID.fromString(magicDescriptorUUID)) ?: return + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseScanCallback.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseScanCallback.kt new file mode 100644 index 000000000000..201c80d865e5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseScanCallback.kt @@ -0,0 +1,7 @@ +package com.nightscout.eversense.callbacks + +import com.nightscout.eversense.models.EversenseScanResult + +interface EversenseScanCallback { + fun onResult(var0: EversenseScanResult) +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseWatcher.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseWatcher.kt new file mode 100644 index 000000000000..af59a54d1d12 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/callbacks/EversenseWatcher.kt @@ -0,0 +1,15 @@ +package com.nightscout.eversense.callbacks + +import com.nightscout.eversense.enums.EversenseType +import com.nightscout.eversense.models.ActiveAlarm +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseState + +interface EversenseWatcher { + fun onCGMRead(type: EversenseType, readings: List) + fun onStateChanged(state: EversenseState) + fun onConnectionChanged(connected: Boolean) + fun onAlarmReceived(alarm: ActiveAlarm) {} + fun onTransmitterNotPlaced() {} + fun onTransmitterReady() {} +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/BatteryLevel.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/BatteryLevel.kt new file mode 100644 index 000000000000..12116e77795a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/BatteryLevel.kt @@ -0,0 +1,38 @@ +package com.nightscout.eversense.enums + +enum class BatteryLevel(val code: Int) { + PERCENTAGE_0(0), + PERCENTAGE_5(1), + PERCENTAGE_10(2), + PERCENTAGE_25(3), + PERCENTAGE_35(4), + PERCENTAGE_45(5), + PERCENTAGE_55(6), + PERCENTAGE_65(7), + PERCENTAGE_75(8), + PERCENTAGE_85(9), + PERCENTAGE_95(10), + PERCENTAGE_100(11), + UNKNOWN(255); + + fun toPercentage(): Int = when (this) { + PERCENTAGE_0 -> 0 + PERCENTAGE_5 -> 5 + PERCENTAGE_10 -> 10 + PERCENTAGE_25 -> 25 + PERCENTAGE_35 -> 35 + PERCENTAGE_45 -> 45 + PERCENTAGE_55 -> 55 + PERCENTAGE_65 -> 65 + PERCENTAGE_75 -> 75 + PERCENTAGE_85 -> 85 + PERCENTAGE_95 -> 95 + PERCENTAGE_100 -> 100 + UNKNOWN -> -1 + } + + companion object { + fun from(code: Int): BatteryLevel = + values().firstOrNull { it.code == code } ?: UNKNOWN + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationFlag.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationFlag.kt new file mode 100644 index 000000000000..4a406c2f791b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationFlag.kt @@ -0,0 +1,46 @@ +package com.nightscout.eversense.enums + +enum class CalibrationFlag(val code: Int) { + NOT_ENTERED_FOR_CALIBRATION(0), + ACTUALLY_USED_FOR_CALIBRATION(1), + MARKED_SUSPICIOUS(2), + GLUCOSE_TOO_LOW_TO_READ(3), + GLUCOSE_TOO_HIGH_TO_READ(4), + GLUCOSE_RAPID_CHANGE(5), + INVALID_TIME(6), + INSUFFICIENT_DATA(7), + SENSOR_EOL(8), + DROPOUT_PHASE(9), + AUTO_LINK_MODE_ACTIVE(10), + SENSOR_LED_DISCONNECT(11), + OTHER_FAILURE(12), + THIS_ONE_USED_PREVIOUS_ONE_DELETED(13), + THIS_SUSPICIOUS_PREVIOUS_DELETED(14), + INSUFFICIENT_DATA_POST_FS_ENTRY(15), + UNKNOWN_FAILURE(255); + + fun getTitle(): String = when (this) { + ACTUALLY_USED_FOR_CALIBRATION, + NOT_ENTERED_FOR_CALIBRATION -> "Calibration accepted" + MARKED_SUSPICIOUS -> "Suspicious" + GLUCOSE_TOO_LOW_TO_READ -> "Glucose too low" + GLUCOSE_TOO_HIGH_TO_READ -> "Glucose too high" + GLUCOSE_RAPID_CHANGE -> "Glucose changing too fast" + INVALID_TIME -> "Invalid time" + INSUFFICIENT_DATA, + INSUFFICIENT_DATA_POST_FS_ENTRY -> "Insufficient data" + SENSOR_EOL -> "Sensor End of Life" + DROPOUT_PHASE -> "Dropout phase" + AUTO_LINK_MODE_ACTIVE -> "Autolink" + SENSOR_LED_DISCONNECT -> "Sensor disconnected" + OTHER_FAILURE -> "Other failure" + THIS_ONE_USED_PREVIOUS_ONE_DELETED, + THIS_SUSPICIOUS_PREVIOUS_DELETED -> "Previous calibration deleted" + UNKNOWN_FAILURE -> "Unknown failure" + } + + companion object { + fun from(code: Int): CalibrationFlag = + values().firstOrNull { it.code == code } ?: UNKNOWN_FAILURE + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationMode.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationMode.kt new file mode 100644 index 000000000000..7056d0433bd5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationMode.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class CalibrationMode(private val value: Int) { + @SerialName("DAILY_SINGLE") + DAILY_SINGLE(0x01), + + @SerialName("DAILY_DUAL") + DAILY_DUAL(0x02), + + @SerialName("WEEKLY_SINGLE") + WEEKLY_SINGLE(0x03), + + @SerialName("DEFAULT") + DEFAULT(0x04); + + companion object { + fun from365(value: Int): CalibrationMode { + return when(value) { + 0 -> DAILY_SINGLE + 1 -> WEEKLY_SINGLE + else -> DEFAULT + } + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationPhase.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationPhase.kt new file mode 100644 index 000000000000..b2fe35c6897a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationPhase.kt @@ -0,0 +1,65 @@ +package com.nightscout.eversense.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class CalibrationPhase(private val value: Int) { + + @SerialName("WARMING_UP") + WARMING_UP(0x01), + + @SerialName("INITIALIZATION") + INITIALIZATION(0x02), + + @SerialName("DAILY_CALIBRATION") + DAILY_CALIBRATION(0x03), + + @SerialName("WEEKLY_CALIBRATION") + WEEKLY_CALIBRATION(0x08), + + @SerialName("SUSPICIOUS") + SUSPICIOUS(0x04), + + @SerialName("UNKNOWN") + UNKNOWN(0x05), + + @SerialName("DEBUG") + DEBUG(0x06), + + @SerialName("DROPOUT") + DROPOUT(0x07); + + companion object { + fun fromE3(value: Int): CalibrationPhase { + // Byte mapping from official app Utils$CAL_PHASE enum (decompiled from EU APK v7.1.1): + // 0=UNKNOWN, 1=WARM_UP, 2=INITIALIZATION, 3=DAILY_CALIBRATION, + // 4=SUSPICIOUS, 5=DROPOUT, 6=DEBUG, 7=UNDERTERMINED + return when(value) { + 1 -> WARMING_UP + 2 -> INITIALIZATION + 3 -> DAILY_CALIBRATION + 4 -> SUSPICIOUS + 5 -> DROPOUT + 6 -> DEBUG + 7 -> UNKNOWN + else -> UNKNOWN + } + } + + // phase = raw CalibrationPhase byte (ordinal from official app CAL_PHASE enum) + // calPerDay = raw NumberOfCalPerDay byte: 0 = daily, 1 = weekly + fun from365(phase: Int, calPerDay: Int = 0): CalibrationPhase { + return when(phase) { + 0 -> UNKNOWN + 1 -> WARMING_UP + 2 -> INITIALIZATION + 3 -> if (calPerDay == 1) WEEKLY_CALIBRATION else DAILY_CALIBRATION + 4 -> SUSPICIOUS + 5 -> DROPOUT + 6 -> DEBUG + else -> UNKNOWN + } + } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationReadiness.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationReadiness.kt new file mode 100644 index 000000000000..7af4183a9c8b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CalibrationReadiness.kt @@ -0,0 +1,69 @@ +package com.nightscout.eversense.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class CalibrationReadiness(private val value: Int) { + /** Calibration can be accepted */ + @SerialName("READY") + READY(0x00), + /** Transmitter does not have enough data to do a calibration */ + @SerialName("NOT_ENOUGH_DATA") + NOT_ENOUGH_DATA(0x01), + /** Glucose rate of change is too high to calibrate */ + @SerialName("GLUCOSE_RATE_TOO_HIGH") + GLUCOSE_RATE_TOO_HIGH(0x02), + /** A calibration has already been done in the past 2h */ + @SerialName("TOO_SOON") + TOO_SOON(0x03), + /** Transmitter is in Dropout phase */ + @SerialName("DROPOUT_PHASE") + DROPOUT_PHASE(0x04), + /** Implant is in EOL */ + @SerialName("SENSOR_EOL") + SENSOR_EOL(0x05), + /** No implant is linked to transmitter */ + @SerialName("NO_SENSOR_LINKED") + NO_SENSOR_LINKED(0x06), + /** Transmitter is in unsupported state */ + @SerialName("UNSUPPORTED_MODE") + UNSUPPORTED_MODE(0x07), + /** Transmitter is in post-calibration waiting period */ + @SerialName("WAITING_POST_CALIBRATION") + WAITING_POST_CALIBRATION(0x08), + /** Transmitter disconnect detected */ + @SerialName("LED_DISCONNECT_DETECTED") + LED_DISCONNECT_DETECTED(0x09), + /** Transmitter is in EOL */ + @SerialName("TRANSMITTER_EOL") + TRANSMITTER_EOL(0x0A), + @SerialName("REASON_UNKNOWN") + REASON_UNKNOWN(0xFF); + + companion object { + // E3 mapping — raw byte from register 0x040C + fun from(value: Int): CalibrationReadiness { + return when(value) { + 0 -> READY + 1 -> NOT_ENOUGH_DATA + 2 -> GLUCOSE_RATE_TOO_HIGH + 3 -> TOO_SOON + 4 -> DROPOUT_PHASE + 5 -> SENSOR_EOL + 6 -> NO_SENSOR_LINKED + 7 -> UNSUPPORTED_MODE + 8 -> WAITING_POST_CALIBRATION + 9 -> LED_DISCONNECT_DETECTED + 10 -> TRANSMITTER_EOL + else -> REASON_UNKNOWN + } + } + + // E365 mapping — the official E365 app does not expose calibration readiness. + // Always return READY so calibration submission is not blocked. + fun from365(value: Int): CalibrationReadiness { + return READY + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CommandError.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CommandError.kt new file mode 100644 index 000000000000..e1035b601bdc --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/CommandError.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.enums + +enum class CommandError(val code: Int) { + NOT_ALLOWED(1), + UNUSED(2), + INVALID_COMMAND_CODE(3), + INVALID_CRC(4), + INVALID_MESSAGE_LENGTH(5), + BUFFER_OVERFLOW(6), + INVALID_COMMAND_ARGUMENT(7), + SENSOR_READ_ERROR(8), + LOW_BATTERY_ERROR(9), + SENSOR_HARDWARE_FAILURE(10), + TRANSMITTER_HARDWARE_FAILURE(11), + SENSOR_UNABLE_TO_BE_LINKED(12), + TRANSMITTER_IS_BUSY(13), + INVALID_RECORD_NUMBER_RANGE(14), + INVALID_RECORD(15), + CORRUPT_RECORD(16), + CRITICAL_FAULT_ERROR(17), + CRC_ERROR_LOGICAL_BLOCK(18), + ACCESS_DENIED(19), + USB_ONLY(20), + NO_DATA_AVAILABLE(21), + GLUCOSE_BLINDED(22); + + companion object { + fun from(code: Int): CommandError? = values().firstOrNull { it.code == code } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseAlarm.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseAlarm.kt new file mode 100644 index 000000000000..7559f4d77334 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseAlarm.kt @@ -0,0 +1,82 @@ +package com.nightscout.eversense.enums + +enum class EversenseAlarm(val code: Int) { + CRITICAL_FAULT(0), SENSOR_RETIRED(1), EMPTY_BATTERY(2), + SENSOR_TEMPERATURE(3), SENSOR_LOW_TEMPERATURE(4), READER_TEMPERATURE(5), + SENSOR_AWOL(6), INVALID_SENSOR(8), CALIBRATION_REQUIRED(11), + SERIOUSLY_LOW(12), SERIOUSLY_HIGH(13), LOW_GLUCOSE(14), HIGH_GLUCOSE(15), + PREDICTIVE_LOW(18), PREDICTIVE_HIGH(19), RATE_FALLING(20), RATE_RISING(21), + CALIBRATION_GRACE_PERIOD(22), CALIBRATION_EXPIRED(23), + SENSOR_RETIRING_SOON_1(24), SENSOR_RETIRING_SOON_3(26), + SENSOR_RETIRING_SOON_4(27), SENSOR_RETIRING_SOON_5(28), + SENSOR_RETIRING_SOON_6(29), SENSOR_RETIRING_SOON_7(53), + VERY_LOW_BATTERY(31), INVALID_CLOCK(33), SENSOR_STABILITY(34), + TRANSMITTER_DISCONNECTED(35), VIBRATION_CURRENT(36), MSP_ALARM(45), + CALIBRATION_FAILED(47), CALIBRATION_SUSPICIOUS(48), CALIBRATION_NOW(49), + TRANSMITTER_EOL_396(50), TRANSMITTER_EOL_366(51), BATTERY_ERROR(52), + TRANSMITTER_EOL_330(55), TRANSMITTER_EOL_395(56), ONE_CAL(57), + CALIBRATION_SUSPICIOUS_2(59), BATTERY_STATUS(60), SENSOR_CONNECTION(62), + EARLY_SENSOR_RETIREMENT(64), GENERAL_GLUCOSE_SUSPENDED(65), + SENSOR_GRACE(66), SENSOR_SYNC_CONFIRMED(67), TX_DOCKED(68), + TX_UNDOCKED(69), TWO_CAL(90), UNKNOWN(255); + + val title: String get() = when (this) { + CRITICAL_FAULT -> "Transmitter Error" + SENSOR_RETIRED, SENSOR_GRACE, SENSOR_RETIRING_SOON_1, SENSOR_RETIRING_SOON_3, + SENSOR_RETIRING_SOON_4, SENSOR_RETIRING_SOON_5, SENSOR_RETIRING_SOON_6, + SENSOR_RETIRING_SOON_7 -> "Sensor Replacement" + EMPTY_BATTERY -> "Battery Empty" + SENSOR_TEMPERATURE -> "High Sensor Temperature" + SENSOR_LOW_TEMPERATURE -> "Low Sensor Temperature" + READER_TEMPERATURE -> "High Transmitter Temperature" + SENSOR_AWOL -> "No Sensor Detected" + INVALID_SENSOR -> "New Sensor Detected" + CALIBRATION_REQUIRED -> "Calibrate Now" + SERIOUSLY_LOW -> "Out of Range Low Glucose" + SERIOUSLY_HIGH -> "Out of Range High Glucose" + LOW_GLUCOSE -> "Low Glucose" + HIGH_GLUCOSE -> "High Glucose" + PREDICTIVE_LOW -> "Predicted Low Glucose" + PREDICTIVE_HIGH -> "Predicted High Glucose" + RATE_FALLING -> "Rate Falling" + RATE_RISING -> "Rate Rising" + CALIBRATION_GRACE_PERIOD -> "Calibration Past Due" + CALIBRATION_EXPIRED -> "Calibration Expired" + VERY_LOW_BATTERY -> "Low Battery" + INVALID_CLOCK -> "Invalid Transmitter Time" + TRANSMITTER_DISCONNECTED -> "Transmitter Disconnected" + VIBRATION_CURRENT -> "Vibration Motor" + MSP_ALARM -> "Sensor Replacement" + CALIBRATION_FAILED -> "Calibrate Again" + CALIBRATION_SUSPICIOUS -> "New Calibration Needed" + CALIBRATION_NOW, CALIBRATION_SUSPICIOUS_2 -> "Calibrate Now" + TRANSMITTER_EOL_330, TRANSMITTER_EOL_366, TRANSMITTER_EOL_395, + TRANSMITTER_EOL_396 -> "Transmitter Replacement" + BATTERY_ERROR -> "Battery Error" + ONE_CAL -> "1 Weekly Calibration Phase" + TWO_CAL -> "2 Daily Calibration Phase" + BATTERY_STATUS -> "Battery Status" + SENSOR_CONNECTION -> "Sensor Connection" + EARLY_SENSOR_RETIREMENT -> "Sensor Retirement Area" + GENERAL_GLUCOSE_SUSPENDED -> "Glucose Suspend" + SENSOR_SYNC_CONFIRMED -> "Sensor Sync Confirmed" + TX_DOCKED -> "Transmitter Inactive" + TX_UNDOCKED -> "Transmitter Active" + SENSOR_STABILITY -> "Sensor Stability" + UNKNOWN -> "Unknown Error" + } + + val isCritical: Boolean get() = this in listOf( + CALIBRATION_REQUIRED, CALIBRATION_EXPIRED, BATTERY_ERROR, + READER_TEMPERATURE, SENSOR_TEMPERATURE, SENSOR_LOW_TEMPERATURE + ) + + val isWarning: Boolean get() = this in listOf( + CALIBRATION_NOW, CALIBRATION_FAILED + ) + + companion object { + fun from(code: Int): EversenseAlarm = + values().firstOrNull { it.code == code } ?: UNKNOWN + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseE3Memory.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseE3Memory.kt new file mode 100644 index 000000000000..e543661c1b7f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseE3Memory.kt @@ -0,0 +1,55 @@ +package com.nightscout.eversense.enums + +enum class EversenseE3Memory(private val address: Long) { + BatteryPercentage(0x0000_0406), + CalibrationReadiness(0x0000_0137), + NextCalibrationDate(0x0000_0470), + NextCalibrationTime(0x0000_0472), + IsOneCalibration(0x0000_0496), + SensorInsertionDate(0x0000_0890), + CalibrationPhase(0x0000_089C), + SensorInsertionTime(0x0000_0892), + LastCalibrationDate(0x0000_08A3), + LastCalibrationTime(0x0000_08A5), + VibrateMode(0x0000_0902), + HighGlucoseAlarmEnabled(0x0000_1029), + HighGlucoseAlarmThreshold(0x0000_110C), + LowGlucoseAlarmThreshold(0x0000_110A), + PredictiveAlert(0x0000_1020), + PredictiveLowTime(0x0000_1021), + PredictiveHighTime(0x0000_1022), + PredictiveLowAlert(0x0000_1027), + PredictiveHighAlert(0x0000_1028), + PredictiveLowTarget(0x0000_1102), + PredictiveHighTarget(0x0000_1104), + RateAlert(0x0000_1010), + RateFallingAlert(0x0000_1025), + RateRisingAlert(0x0000_1026), + RateFallingThreshold(0x0000_1011), + RateRisingThreshold(0x0000_1012), + SensorFieldCurrentRaw(0x0000_0874), + TransmitterSoftwareVersion(0x0000_000A), + TransmitterSoftwareVersionExt(0x0000_00A2), + MmaFeatures(0x0000_040C), + AppVersion(0x0000_0B4B), + BleDisconnect(0x0000_08B2), + HighGlucoseAlarmRepeatIntervalDay(0x0000_1033), + LowGlucoseAlarmRepeatIntervalDay(0x0000_1032), + HighGlucoseAlarmRepeatIntervalNight(0x0000_110F), + LowGlucoseAlarmRepeatIntervalNight(0x0000_110E), + CalibrationsMadeInThisPhase(0x0000_08A1); + + fun getRequestData(): ByteArray { + // Official app sends address as little-endian (LSB first) with trailing 0x00. + // Confirmed from logcat: official app sends [96 04 00] for address 0x0496. + // Format: [addrLSB][addrMSB][0x00] + return byteArrayOf( + this.address.toByte(), // addr LSB + (this.address shr 8).toByte(), // addr MSB + 0x00.toByte(), // trailing zero + ) + } +} + + + diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseSecurityType.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseSecurityType.kt new file mode 100644 index 000000000000..8eee37eab414 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseSecurityType.kt @@ -0,0 +1,10 @@ +package com.nightscout.eversense.enums + +enum class EversenseSecurityType { + // Eversense E3 + None, + + // Eversense 365 -> generation 2 + // Eversense 365 -> generation 1 is deprecated and not available anymore + SecureV2 +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseTrendArrow.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseTrendArrow.kt new file mode 100644 index 000000000000..dff7b445be8d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseTrendArrow.kt @@ -0,0 +1,10 @@ +package com.nightscout.eversense.enums + +enum class EversenseTrendArrow(val type: String) { + NONE("NONE"), + SINGLE_UP("SingleUp"), + FORTY_FIVE_UP("FortyFiveUp"), + FLAT("Flat"), + FORTY_FIVE_DOWN("FortyFiveDown"), + SINGLE_DOWN("SingleDown") +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseType.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseType.kt new file mode 100644 index 000000000000..104f452afd12 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/EversenseType.kt @@ -0,0 +1,6 @@ +package com.nightscout.eversense.enums + +enum class EversenseType { + EVERSENSE_E3, + EVERSENSE_365 +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/SignalStrength.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/SignalStrength.kt new file mode 100644 index 000000000000..b9d50555c3b6 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/SignalStrength.kt @@ -0,0 +1,39 @@ +package com.nightscout.eversense.enums + +enum class SignalStrength(val rawThreshold: Int, val threshold: Int) { + NO_SIGNAL(0, 0), + POOR(350, 350), + VERY_LOW(500, 395), + LOW(800, 494), + GOOD(1300, 705), + EXCELLENT(1600, 903); + + val title: String get() = when (this) { + NO_SIGNAL -> "No signal" + POOR -> "Poor" + VERY_LOW -> "Very low" + LOW -> "Low" + GOOD -> "Good" + EXCELLENT -> "Excellent" + } + + companion object { + fun from365(value: Int): SignalStrength = when { + value >= 75 -> EXCELLENT + value >= 48 -> GOOD + value >= 30 -> LOW + value >= 28 -> VERY_LOW + value >= 25 -> POOR + else -> NO_SIGNAL + } + + fun fromRaw(value: Int): SignalStrength = when { + value >= 1600 -> EXCELLENT + value >= 1300 -> GOOD + value >= 800 -> LOW + value >= 500 -> VERY_LOW + value >= 350 -> POOR + else -> NO_SIGNAL + } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/TransmitterAlert.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/TransmitterAlert.kt new file mode 100644 index 000000000000..6af820fdb0da --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/enums/TransmitterAlert.kt @@ -0,0 +1,150 @@ +package com.nightscout.eversense.enums + +enum class TransmitterAlert(val code: Int) { + CRITICAL_FAULT_ALARM(0), + SENSOR_RETIRED_ALARM(1), + EMPTY_BATTERY_ALARM(2), + SENSOR_TEMPERATURE_ALARM(3), + SENSOR_LOW_TEMPERATURE_ALARM(4), + READER_TEMPERATURE_ALARM(5), + SENSOR_AWOL_ALARM(6), + SENSOR_ERROR_ALARM(7), + INVALID_SENSOR_ALARM(8), + HIGH_AMBIENT_LIGHT_ALARM(9), + RESERVED_1(10), + SERIOUSLY_LOW_ALARM(12), + SERIOUSLY_HIGH_ALARM(13), + LOW_GLUCOSE_ALARM(14), + HIGH_GLUCOSE_ALARM(15), + LOW_GLUCOSE_ALERT(16), + HIGH_GLUCOSE_ALERT(17), + PREDICTIVE_LOW_ALARM(18), + PREDICTIVE_HIGH_ALARM(19), + RATE_FALLING_ALARM(20), + RATE_RISING_ALARM(21), + CALIBRATION_GRACE_PERIOD_ALARM(22), + CALIBRATION_EXPIRED_ALARM(23), + SENSOR_RETIRING_SOON_1_ALARM(24), + SENSOR_RETIRING_SOON_2_ALARM(25), + SENSOR_RETIRING_SOON_3_ALARM(26), + SENSOR_RETIRING_SOON_4_ALARM(27), + SENSOR_RETIRING_SOON_5_ALARM(28), + SENSOR_RETIRING_SOON_6_ALARM(29), + SENSOR_PREMATURE_REPLACEMENT_ALARM(30), + VERY_LOW_BATTERY_ALARM(31), + LOW_BATTERY_ALARM(32), + INVALID_CLOCK_ALARM(33), + SENSOR_STABILITY(34), + TRANSMITTER_DISCONNECTED(35), + VIBRATION_CURRENT_ALARM(36), + SENSOR_AGED_OUT_ALARM(37), + SENSOR_ON_HOLD_ALARM(38), + MEP_ALARM(39), + EDR_ALARM_0(40), + EDR_ALARM_1(41), + EDR_ALARM_2(42), + EDR_ALARM_3(43), + EDR_ALARM_4(44), + MSP_ALARM(45), + RESERVED_2(46), + TRANSMITTER_EOL_396(50), + TRANSMITTER_EOL_366(51), + BATTERY_ERROR_ALARM(52), + SENSOR_RETIRING_SOON_7_ALARM(53), + RESERVED_3(54), + TRANSMITTER_EOL_330(55), + TRANSMITTER_EOL_395(56), + ONE_CAL(57), + TWO_CAL(58), + TRANSMITTER_RECONNECTED(60), + APP_RESERVED_1(63), + SYSTEM_TIME(64), + APP_RESERVED_2(65), + INCOMPATIBLE_TX(66), + SENSOR_FILE(67), + SENSOR_RELINK(68), + NEW_PASSWORD_DETECTED(69), + BATTERY_OPTIMIZATION(70), + NO_ALARM_ACTIVE(71), + NUMBER_OF_MESSAGES(72); + + val canBlindGlucose: Boolean get() = this in setOf( + HIGH_GLUCOSE_ALARM, HIGH_GLUCOSE_ALERT, + LOW_GLUCOSE_ALARM, LOW_GLUCOSE_ALERT, + PREDICTIVE_HIGH_ALARM, PREDICTIVE_LOW_ALARM, + RATE_FALLING_ALARM, RATE_RISING_ALARM + ) + + val title: String get() = when (this) { + CRITICAL_FAULT_ALARM -> "Critical Fault" + SENSOR_RETIRED_ALARM -> "Sensor Retired" + EMPTY_BATTERY_ALARM -> "Empty Battery" + SENSOR_TEMPERATURE_ALARM -> "Sensor High Temperature" + SENSOR_LOW_TEMPERATURE_ALARM -> "Sensor Low Temperature" + READER_TEMPERATURE_ALARM -> "Transmitter High Temperature" + SENSOR_AWOL_ALARM -> "No Sensor Detected" + SENSOR_ERROR_ALARM -> "Sensor Hardware Error" + INVALID_SENSOR_ALARM -> "Invalid Sensor" + HIGH_AMBIENT_LIGHT_ALARM -> "High Ambient Light" + RESERVED_1 -> "Reserved 1" + SERIOUSLY_LOW_ALARM -> "Seriously Low Glucose" + SERIOUSLY_HIGH_ALARM -> "Seriously High Glucose" + LOW_GLUCOSE_ALARM -> "Low Glucose" + HIGH_GLUCOSE_ALARM -> "High Glucose" + LOW_GLUCOSE_ALERT -> "Low Glucose Alert" + HIGH_GLUCOSE_ALERT -> "High Glucose Alert" + PREDICTIVE_LOW_ALARM -> "Predicted Low Glucose" + PREDICTIVE_HIGH_ALARM -> "Predicted High Glucose" + RATE_FALLING_ALARM -> "Rate Falling" + RATE_RISING_ALARM -> "Rate Rising" + CALIBRATION_GRACE_PERIOD_ALARM -> "Calibration Grace Period" + CALIBRATION_EXPIRED_ALARM -> "Calibration Expired" + SENSOR_RETIRING_SOON_1_ALARM -> "Sensor Retiring Soon 1" + SENSOR_RETIRING_SOON_2_ALARM -> "Sensor Retiring Soon 2" + SENSOR_RETIRING_SOON_3_ALARM -> "Sensor Retiring Soon 3" + SENSOR_RETIRING_SOON_4_ALARM -> "Sensor Retiring Soon 4" + SENSOR_RETIRING_SOON_5_ALARM -> "Sensor Retiring Soon 5" + SENSOR_RETIRING_SOON_6_ALARM -> "Sensor Retiring Soon 6" + SENSOR_PREMATURE_REPLACEMENT_ALARM -> "Sensor Premature Replacement" + VERY_LOW_BATTERY_ALARM -> "Very Low Battery" + LOW_BATTERY_ALARM -> "Low Battery" + INVALID_CLOCK_ALARM -> "Invalid Clock" + SENSOR_STABILITY -> "Sensor Instability" + TRANSMITTER_DISCONNECTED -> "Transmitter Disconnected" + VIBRATION_CURRENT_ALARM -> "Vibration Motor" + SENSOR_AGED_OUT_ALARM -> "Sensor Aged Out" + SENSOR_ON_HOLD_ALARM -> "Sensor Suspend" + MEP_ALARM -> "MEP Alarm" + EDR_ALARM_0 -> "EDR Alarm 0" + EDR_ALARM_1 -> "EDR Alarm 1" + EDR_ALARM_2 -> "EDR Alarm 2" + EDR_ALARM_3 -> "EDR Alarm 3" + EDR_ALARM_4 -> "EDR Alarm 4" + MSP_ALARM -> "MSP Alarm" + RESERVED_2 -> "Reserved 2" + TRANSMITTER_EOL_396 -> "Transmitter EOL 396" + TRANSMITTER_EOL_366 -> "Transmitter EOL 366" + BATTERY_ERROR_ALARM -> "Battery Error" + SENSOR_RETIRING_SOON_7_ALARM -> "Sensor Retiring Soon 7" + RESERVED_3 -> "Reserved 3" + TRANSMITTER_EOL_330 -> "Transmitter EOL 330" + TRANSMITTER_EOL_395 -> "Transmitter EOL 395" + ONE_CAL -> "1 Daily Calibration Phase" + TWO_CAL -> "2 Daily Calibrations Phase" + TRANSMITTER_RECONNECTED -> "Transmitter Reconnected" + APP_RESERVED_1 -> "App Reserved 1" + SYSTEM_TIME -> "System Time" + APP_RESERVED_2 -> "App Reserved 2" + INCOMPATIBLE_TX -> "Incompatible Transmitter" + SENSOR_FILE -> "Sensor File" + SENSOR_RELINK -> "Sensor Re-link" + NEW_PASSWORD_DETECTED -> "New Password Detected" + BATTERY_OPTIMIZATION -> "App Performance" + NO_ALARM_ACTIVE -> "No Alarm Active" + NUMBER_OF_MESSAGES -> "Number of Messages" + } + + companion object { + fun from(code: Int): TransmitterAlert? = values().firstOrNull { it.code == code } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/exceptions/EversenseWriteException.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/exceptions/EversenseWriteException.kt new file mode 100644 index 000000000000..604b3cbd50b6 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/exceptions/EversenseWriteException.kt @@ -0,0 +1,4 @@ +package com.nightscout.eversense.exceptions + +class EversenseWriteException(override val message: String) : Exception(message) { +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/ActiveAlarm.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/ActiveAlarm.kt new file mode 100644 index 000000000000..7af0f83e4b00 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/ActiveAlarm.kt @@ -0,0 +1,12 @@ +package com.nightscout.eversense.models + +import com.nightscout.eversense.enums.EversenseAlarm +import kotlinx.serialization.Serializable + +@Serializable +data class ActiveAlarm( + val code: EversenseAlarm, + val codeRaw: Int, + val flag: Int, + val priority: Int +) diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseCGMResult.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseCGMResult.kt new file mode 100644 index 000000000000..4ec97646598a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseCGMResult.kt @@ -0,0 +1,11 @@ +package com.nightscout.eversense.models + +import com.nightscout.eversense.enums.EversenseTrendArrow + +data class EversenseCGMResult( + val glucoseInMgDl: Int, + val datetime: Long, + val trend: EversenseTrendArrow, + val sensorId: String = "", + val rawResponseHex: String = "" +) diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseScanResult.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseScanResult.kt new file mode 100644 index 000000000000..b3a9bcffef7a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseScanResult.kt @@ -0,0 +1,5 @@ +package com.nightscout.eversense.models + +import android.bluetooth.BluetoothDevice + +data class EversenseScanResult(val name: String, val rssi: Int, val device: BluetoothDevice) diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseSecureState.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseSecureState.kt new file mode 100644 index 000000000000..197b1e5a5657 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseSecureState.kt @@ -0,0 +1,13 @@ +package com.nightscout.eversense.models + +import kotlinx.serialization.Serializable + +@Serializable +class EversenseSecureState { + var canUseShortcut: Boolean = false + var username: String = "" + var password: String = "" + var clientId: String = "" + var privateKey: String = "" + var publicKey: String = "" +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseState.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseState.kt new file mode 100644 index 000000000000..0f19a9c9fc48 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/EversenseState.kt @@ -0,0 +1,50 @@ +package com.nightscout.eversense.models + +import com.nightscout.eversense.enums.CalibrationMode +import com.nightscout.eversense.enums.CalibrationPhase +import com.nightscout.eversense.enums.CalibrationReadiness +import kotlinx.serialization.Serializable + +@Serializable +class EversenseState { + var lastSync: Long = 0 + var insertionDate: Long = 0 + var calibrationPhase: CalibrationPhase = CalibrationPhase.UNKNOWN + var calibrationReadiness: CalibrationReadiness = CalibrationReadiness.REASON_UNKNOWN + var calibrationMode: CalibrationMode = CalibrationMode.DEFAULT + var nextCalibrationDate: Long = 0 + var lastCalibrationDate: Long = 0 + var batteryPercentage: Int = 0 + var recentGlucoseDatetime: Long = 0 + var recentGlucoseValue: Int = 0 + var lastGlucoseRaw: Int = 0 + var placementSignalRssi: Int = 0 + var sensorSignalStrength: Int = 0 + var activeAlarms: List = emptyList() + var firmwareVersion: String = "" + var mmaFeatures: Int = 0 + var extFirmwareVersion: String = "" + var transmitterSerialNumber: String = "" + var transmitterName: String = "" + var sensorId: String = "" + var settings = EversenseTransmitterSettings() +} + +@Serializable +class EversenseTransmitterSettings { + var vibrateEnabled: Boolean = true + var glucoseHighAlarmEnabled: Boolean = true + var glucoseHighAlarmThreshold: Int = 250 + var glucoseLowAlarmThreshold: Int = 60 + var rateFallingAlarmEnabled: Boolean = true + var rateFallingAlarmThreshold: Double = 1.5 + var rateRisingAlarmEnabled: Boolean = true + var rateRisingAlarmThreshold: Double = 1.5 + var predictiveHighAlarmEnabled: Boolean = true + var predictiveHighAlarmThreshold: Int = 180 + var predictiveHighAlarmMinutes: Int = 5 + var predictiveLowAlarmEnabled: Boolean = true + var predictiveLowAlarmThreshold: Int = 70 + var predictiveLowAlarmMinutes: Int = 5 +} + diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/GlucoseHistoryItem.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/GlucoseHistoryItem.kt new file mode 100644 index 000000000000..f6c798d27fb2 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/models/GlucoseHistoryItem.kt @@ -0,0 +1,10 @@ +package com.nightscout.eversense.models + +import com.nightscout.eversense.enums.EversenseTrendArrow + +data class GlucoseHistoryItem( + val valueInMgDl: Int, + val datetime: Long, + val trend: EversenseTrendArrow, + val rawResponseHex: String = "" +) diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/Eversense365Communicator.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/Eversense365Communicator.kt new file mode 100644 index 000000000000..b905024b6631 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/Eversense365Communicator.kt @@ -0,0 +1,234 @@ +package com.nightscout.eversense.packets + +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import androidx.core.content.edit +import com.nightscout.eversense.EversenseGattCallback +import com.nightscout.eversense.callbacks.EversenseWatcher +import com.nightscout.eversense.enums.EversenseType +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseState +import com.nightscout.eversense.models.EversenseTransmitterSettings +import com.nightscout.eversense.packets.e365.GetActiveAlarmsPacket +import com.nightscout.eversense.packets.e365.Ping365Packet +import com.nightscout.eversense.packets.e365.SetAppVersion365Packet +import com.nightscout.eversense.packets.e365.SetBleDisconnect365Packet +import com.nightscout.eversense.packets.e365.GetGlucoseLogValuesPacket +import com.nightscout.eversense.packets.e365.GetLogRangePacket365 +import com.nightscout.eversense.packets.e365.SetHighGlucoseAlarm365Packet +import com.nightscout.eversense.packets.e365.SetHighGlucoseAlarmEnabled365Packet +import com.nightscout.eversense.packets.e365.SetLowGlucoseAlarm365Packet +import com.nightscout.eversense.packets.e365.SetPredictionHighEnabled365Packet +import com.nightscout.eversense.packets.e365.SetPredictionHighThreshold365Packet +import com.nightscout.eversense.packets.e365.SetPredictionHighTime365Packet +import com.nightscout.eversense.packets.e365.SetPredictionLowEnabled365Packet +import com.nightscout.eversense.packets.e365.SetPredictionLowThreshold365Packet +import com.nightscout.eversense.packets.e365.SetPredictionLowTime365Packet +import com.nightscout.eversense.packets.e365.SetRateFallingEnabled365Packet +import com.nightscout.eversense.packets.e365.SetRateFallingThreshold365Packet +import com.nightscout.eversense.packets.e365.SetRateRisingEnabled365Packet +import com.nightscout.eversense.packets.e365.SetRateRisingThreshold365Packet +import com.nightscout.eversense.packets.e365.SetRepeatHighGlucose365Packet +import com.nightscout.eversense.packets.e365.SetRepeatLowGlucose365Packet +import com.nightscout.eversense.packets.e365.SetVibrateMode365Packet +import com.nightscout.eversense.packets.e365.LogType +import com.nightscout.eversense.packets.e365.GetCalibrationInfoPacket +import com.nightscout.eversense.packets.e365.GetGlucoseDataPacket +import com.nightscout.eversense.packets.e365.GetPatientSettingsPacket +import com.nightscout.eversense.packets.e365.GetSensorInformationPacket +import com.nightscout.eversense.packets.e365.SetCurrentDateTimePacket +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.util.StorageKeys +import kotlinx.serialization.json.Json +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +class Eversense365Communicator { + companion object { + private const val TAG = "EversenseE3Communicator" + private val JSON = Json { ignoreUnknownKeys = true } + private val handler = Handler(Looper.getMainLooper()) + + private var sensorIdLength = 10 + + fun readGlucose(gatt: EversenseGattCallback, preferences: SharedPreferences, watchers: List) { + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + + val glucoseData = gatt.writePacket(GetGlucoseDataPacket(sensorIdLength)) + if (glucoseData.datetime <= state.recentGlucoseDatetime) { + EversenseLogger.warning(TAG, "Glucose data is still recent after reading - currentReading: ${glucoseData.datetime}, lastReading: ${state.recentGlucoseDatetime}") + return + } + + if (glucoseData.glucoseInMgDl > 1000) { + EversenseLogger.error(TAG, "recentGlucose exceeds range - received: ${glucoseData.glucoseInMgDl}") + return + } + + var currentGlucose = glucoseData.glucoseInMgDl + + val result = mutableListOf() + val previousGlucoseDatetime = state.recentGlucoseDatetime + state.recentGlucoseDatetime = glucoseData.datetime + state.recentGlucoseValue = currentGlucose + state.lastGlucoseRaw = glucoseData.glucoseInMgDl + state.sensorSignalStrength = glucoseData.signalStrength + EversenseLogger.info(TAG, "Sensor signal strength from glucose packet: ${glucoseData.signalStrength}") + + state.sensorId = glucoseData.sensorId + + result += EversenseCGMResult( + glucoseInMgDl = currentGlucose, + datetime = glucoseData.datetime, + trend = glucoseData.trend, + sensorId = glucoseData.sensorId, + rawResponseHex = glucoseData.rawResponseHex + ) + + // Read glucose history for backfill — use previousGlucoseDatetime so gap readings are included + try { + val logRange = gatt.writePacket(GetLogRangePacket365(LogType.GLUCOSE)) + val range = com.nightscout.eversense.util.RangeCalculator.calculateGlucoseRange( + logRange.rangeFrom, logRange.rangeTo, previousGlucoseDatetime + ) + val history = gatt.writePacket( + GetGlucoseLogValuesPacket(from = range.from, to = range.to, sensorIdLength = sensorIdLength) + ) + val backfill = history.glucoseHistory + .filter { it.datetime > previousGlucoseDatetime && it.datetime < glucoseData.datetime } + .map { item -> EversenseCGMResult(glucoseInMgDl = item.valueInMgDl, datetime = item.datetime, trend = item.trend, rawResponseHex = item.rawResponseHex) } + if (backfill.isNotEmpty()) { + result.addAll(0, backfill) + EversenseLogger.info(TAG, "Backfill: added ${backfill.size} historical readings") + } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Could not read glucose history: $e") + } + + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(state)) + } + + handler.post { + watchers.forEach { it.onCGMRead(EversenseType.EVERSENSE_365, result) } + watchers.forEach { it.onStateChanged(state) } + } + } + + fun fullSync(gatt: EversenseGattCallback, preferences: SharedPreferences, watchers: List, force: Boolean = false) { + val stateJsonCheck = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val stateCheck = JSON.decodeFromString(stateJsonCheck) + val fourMinAgo = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(240) + if (!force && stateCheck.lastSync > fourMinAgo) { + EversenseLogger.debug(TAG, "365 fullSync skipped — last sync was recent (${(System.currentTimeMillis() - stateCheck.lastSync) / 1000}s ago)") + return + } + try { + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + + var sensorInformation = gatt.writePacket(GetSensorInformationPacket()) + + // Ping transmitter first — matches iOS fullSync order + try { gatt.writePacket(Ping365Packet()) } catch (e: Exception) { EversenseLogger.warning(TAG, "Ping failed: $e") } + + if (abs(System.currentTimeMillis() - sensorInformation.transmitterDatetime) > 10_000) { + EversenseLogger.debug(TAG, "Updating transmitter datetime") + gatt.writePacket(SetCurrentDateTimePacket()) + sensorInformation = gatt.writePacket(GetSensorInformationPacket()) + } + + sensorIdLength = sensorInformation.sensorIdLength + state.insertionDate = sensorInformation.insertionDate + state.batteryPercentage = sensorInformation.batteryLevel + state.firmwareVersion = sensorInformation.version + state.extFirmwareVersion = sensorInformation.extVersion + state.transmitterSerialNumber = sensorInformation.serialNumber + state.transmitterName = sensorInformation.transmitterName + EversenseLogger.info(TAG, "Transmitter serialNumber='${sensorInformation.serialNumber}' transmitterName='${sensorInformation.transmitterName}'") + EversenseLogger.info(TAG, "Firmware version: ${sensorInformation.version} / ${sensorInformation.extVersion}") + + val calibrationInfo = gatt.writePacket(GetCalibrationInfoPacket()) + state.calibrationPhase = calibrationInfo.currentPhase + state.calibrationReadiness = calibrationInfo.calibrationReadiness + state.calibrationMode = calibrationInfo.calibrationMode + state.nextCalibrationDate = calibrationInfo.nextCalibration + state.lastCalibrationDate = calibrationInfo.lastCalibration + + val patientSettings = gatt.writePacket(GetPatientSettingsPacket()) + state.settings.vibrateEnabled = patientSettings.vibrateMode + state.settings.glucoseHighAlarmEnabled = patientSettings.highGlucoseEnabled + state.settings.glucoseHighAlarmThreshold = patientSettings.highGlucoseAlarmInMgDl + state.settings.glucoseLowAlarmThreshold = patientSettings.lowGlucoseAlarmInMgDl + state.settings.rateFallingAlarmEnabled = patientSettings.rateFallingEnabled + state.settings.rateFallingAlarmThreshold = patientSettings.rateFallingThreshold + state.settings.rateRisingAlarmEnabled = patientSettings.rateRisingEnabled + state.settings.rateRisingAlarmThreshold = patientSettings.rateRisingThreshold + state.settings.predictiveHighAlarmEnabled = patientSettings.predictionHighEnabled + state.settings.predictiveHighAlarmMinutes = patientSettings.predictionRisingInterval + state.settings.predictiveHighAlarmThreshold = patientSettings.predictionRisingThreshold + state.settings.predictiveLowAlarmEnabled = patientSettings.predictionLowEnabled + state.settings.predictiveLowAlarmMinutes = patientSettings.predictionFallingInterval + state.settings.predictiveLowAlarmThreshold = patientSettings.predictionFallingThreshold + + // Send app version — iOS sends "8.0.4" in every fullSync + try { gatt.writePacket(SetAppVersion365Packet()) } catch (e: Exception) { EversenseLogger.warning(TAG, "SetAppVersion failed: $e") } + + // Set BLE disconnect timeout to 5 minutes matching iOS default + try { gatt.writePacket(SetBleDisconnect365Packet(300)) } catch (e: Exception) { EversenseLogger.warning(TAG, "SetBleDisconnect failed: $e") } + + // Read active alarms + try { + val activeAlarms = gatt.writePacket(GetActiveAlarmsPacket()) + state.activeAlarms = activeAlarms.alarms + EversenseLogger.info(TAG, "Active alarms: ${activeAlarms.alarms.map { it.code.title }}") + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Could not read active alarms: $e") + } + + state.lastSync = System.currentTimeMillis() + EversenseLogger.info(TAG, "Completed full sync - datetime: ${state.lastSync}") + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(state)) + } + + handler.post { + watchers.forEach { it.onStateChanged(state) } + } + } catch (exception: Exception) { + EversenseLogger.error(TAG, "Failed to do full sync: $exception") + exception.printStackTrace() + } + } + + fun writeSettings(gatt: EversenseGattCallback, preferences: SharedPreferences, settings: EversenseTransmitterSettings): Boolean { + return try { + gatt.writePacket(SetVibrateMode365Packet(settings.vibrateEnabled)) + gatt.writePacket(SetHighGlucoseAlarmEnabled365Packet(settings.glucoseHighAlarmEnabled)) + gatt.writePacket(SetHighGlucoseAlarm365Packet(settings.glucoseHighAlarmThreshold)) + gatt.writePacket(SetLowGlucoseAlarm365Packet(settings.glucoseLowAlarmThreshold)) + gatt.writePacket(SetRateFallingEnabled365Packet(settings.rateFallingAlarmEnabled)) + gatt.writePacket(SetRateFallingThreshold365Packet(settings.rateFallingAlarmThreshold)) + gatt.writePacket(SetRateRisingEnabled365Packet(settings.rateRisingAlarmEnabled)) + gatt.writePacket(SetRateRisingThreshold365Packet(settings.rateRisingAlarmThreshold)) + gatt.writePacket(SetPredictionLowEnabled365Packet(settings.predictiveLowAlarmEnabled)) + gatt.writePacket(SetPredictionLowThreshold365Packet(settings.predictiveLowAlarmThreshold)) + gatt.writePacket(SetPredictionLowTime365Packet(settings.predictiveLowAlarmMinutes)) + gatt.writePacket(SetPredictionHighEnabled365Packet(settings.predictiveHighAlarmEnabled)) + gatt.writePacket(SetPredictionHighThreshold365Packet(settings.predictiveHighAlarmThreshold)) + gatt.writePacket(SetPredictionHighTime365Packet(settings.predictiveHighAlarmMinutes)) + EversenseLogger.info(TAG, "365 settings written successfully") + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(JSON.decodeFromString(preferences.getString(StorageKeys.STATE, null) ?: "{}").also { it.settings = settings })) + } + true + } catch (e: Exception) { + EversenseLogger.error(TAG, "Failed to write 365 settings: $e") + false + } + } + } +} + diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseBasePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseBasePacket.kt new file mode 100644 index 000000000000..404d1d66c900 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseBasePacket.kt @@ -0,0 +1,106 @@ +package com.nightscout.eversense.packets + +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.e3.EversenseE3Packets +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer +import com.nightscout.eversense.packets.e365.Eversense365Packets +import com.nightscout.eversense.util.EversenseCrypto365Util +import kotlin.math.min + +abstract class EversenseBasePacket : Object() { + abstract fun getRequestData(): ByteArray + abstract fun parseResponse(): Response? + + protected var receivedData = UByteArray(0) + @Volatile var isErrorResponse: Boolean = false + open val skipResponseIdValidation: Boolean = false + + fun getAnnotation(): EversensePacket? { + return this.javaClass.annotations.find { it.annotationClass == EversensePacket::class } as? EversensePacket + } + + protected fun getStartIndex(): Int { + val annotation = getAnnotation() ?:run { + EversenseLogger.error("EversenseBasePacket", this.javaClass.name + " does not have the EversensePacket annotation...") + return 0 + } + + return when(annotation.responseId) { + EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + EversenseE3Packets.ReadFourByteSerialFlashRegisterResponseId -> 4 + + else -> 1 + } + } + + fun appendData(data: UByteArray) { + receivedData += data + } + + fun buildRequest(cryptoUtil: EversenseCrypto365Util, payloadSize: Int): ByteArray? { + val annotation = getAnnotation() ?:run { + EversenseLogger.error("EversenseBasePacket", this.javaClass.name + " does not have the EversensePacket annotation...") + return null + } + + when(annotation.securityType) { + EversenseSecurityType.None -> { + var requestData = byteArrayOf(annotation.requestId) + requestData += this.getRequestData() + requestData += EversenseE3Writer.generateChecksumCRC16(requestData) + + return requestData + } + + EversenseSecurityType.SecureV2 -> { + var requestData = byteArrayOf(annotation.requestId, annotation.typeId) + requestData += this.getRequestData() + + if (annotation.requestId != Eversense365Packets.AuthenticateCommandId) { + requestData = cryptoUtil.encrypt(requestData) + } + + return encodeMessage(requestData, payloadSize) + } + } + } + + private fun encodeMessage(data: ByteArray = getRequestData(), chunkSize: Int = 20): ByteArray { + val adjustedChunkSize = chunkSize - 2 + val totalChunks = (data.size + adjustedChunkSize - 1) / adjustedChunkSize + + // Calculate total size needed for the result array + val totalHeaderSize = 3 + 2 * (totalChunks - 1) + val totalSize = totalHeaderSize + data.size + + val result = ByteArray(totalSize) + var currentIndex = 0 + var currentPos = 0 + + for (chunkIndex in 1..totalChunks) { + val header: ByteArray = if (chunkIndex == 1) { + byteArrayOf(1.toByte(), totalChunks.toByte(), 1.toByte()) + } else { + byteArrayOf(chunkIndex.toByte(), totalChunks.toByte()) + } + + // Copy the header into the result array + header.copyInto(result, currentPos) + currentPos += header.size + + // Determine the end index of the current chunk + val endIndex = min(currentIndex + adjustedChunkSize, data.size) + val chunk = data.copyOfRange(currentIndex, endIndex) + + chunk.copyInto(result, currentPos) + currentPos += chunk.size + currentIndex = endIndex + } + + return result + } + + abstract class Response {} +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseE3Communicator.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseE3Communicator.kt new file mode 100644 index 000000000000..ebca03ded63e --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversenseE3Communicator.kt @@ -0,0 +1,375 @@ +package com.nightscout.eversense.packets + +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import androidx.core.content.edit +import com.nightscout.eversense.EversenseGattCallback +import com.nightscout.eversense.callbacks.EversenseWatcher +import com.nightscout.eversense.enums.CalibrationMode +import com.nightscout.eversense.enums.CalibrationReadiness +import com.nightscout.eversense.enums.EversenseType +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseState +import com.nightscout.eversense.models.EversenseTransmitterSettings +import com.nightscout.eversense.packets.e3.GetBatteryPercentagePacket +import com.nightscout.eversense.packets.e3.GetVersionPacket +import com.nightscout.eversense.packets.e3.GetVersionExtendedPacket +import com.nightscout.eversense.packets.e3.GetMmaFeaturesPacket +import com.nightscout.eversense.packets.e3.GetHighGlucoseRepeatIntervalPacket +import com.nightscout.eversense.packets.e3.GetLowGlucoseRepeatIntervalPacket +import com.nightscout.eversense.packets.e3.SetBleDisconnectPacket +import com.nightscout.eversense.packets.e3.SetAppVersionE3Packet +import com.nightscout.eversense.packets.e3.GetCalibrationDailyPacket +import com.nightscout.eversense.packets.e3.GetCalibrationPhasePacket +import com.nightscout.eversense.packets.e3.GetCalibrationReadinessPacket +import com.nightscout.eversense.packets.e3.GetCurrentDatetimePacket +import com.nightscout.eversense.packets.e3.PingPacket +import com.nightscout.eversense.packets.e3.GetCurrentGlucosePacket +import com.nightscout.eversense.packets.e3.GetInsertionDatePacket +import com.nightscout.eversense.packets.e3.GetInsertionTimePacket +import com.nightscout.eversense.packets.e3.GetLastCalibrationDatePacket +import com.nightscout.eversense.packets.e3.GetLastCalibrationTimePacket +import com.nightscout.eversense.packets.e3.GetNextCalibrationDatePacket +import com.nightscout.eversense.packets.e3.GetNextCalibrationTimePacket +import com.nightscout.eversense.packets.e3.GetSettingGlucoseHighEnabled +import com.nightscout.eversense.packets.e3.GetSettingGlucoseHighThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingGlucoseLowThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveHighEnabledPacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveHighThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveHighTimePacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveLowEnabledPacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveLowThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingPredictiveLowTimePacket +import com.nightscout.eversense.packets.e3.GetSettingRateFallingEnabledPacket +import com.nightscout.eversense.packets.e3.GetSettingRateFallingThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingRateRisingEnabledPacket +import com.nightscout.eversense.packets.e3.GetSettingRateRisingThresholdPacket +import com.nightscout.eversense.packets.e3.GetSettingVibratePacket +import com.nightscout.eversense.packets.e3.SendCalibrationPacket +import com.nightscout.eversense.packets.e3.SetCurrentDatetimePacket +import com.nightscout.eversense.packets.e3.SetSettingGlucoseHighEnablePacket +import com.nightscout.eversense.packets.e3.SetSettingGlucoseHighThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingGlucoseLowThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveHighAlarmEnabledPacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveHighThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveHighTimePacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveLowAlarmEnabledPacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveLowThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingPredictiveLowTimePacket +import com.nightscout.eversense.packets.e3.SetSettingRateFallingEnabledPacket +import com.nightscout.eversense.packets.e3.SetSettingRateFallingThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingRateRisingEnabledPacket +import com.nightscout.eversense.packets.e3.SetSettingRateRisingThresholdPacket +import com.nightscout.eversense.packets.e3.SetSettingVibratePacket +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.util.StorageKeys +import kotlinx.serialization.json.Json +import java.util.concurrent.TimeUnit + +class EversenseE3Communicator { + companion object { + private const val TAG = "EversenseE3Communicator" + private val JSON = Json { ignoreUnknownKeys = true } + private val handler = Handler(Looper.getMainLooper()) + + fun readGlucose(gatt: EversenseGattCallback, preferences: SharedPreferences, watchers: List) { + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + val fourHalfMinAgo = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(270) + + if (fourHalfMinAgo < state.recentGlucoseDatetime) { + EversenseLogger.warning(TAG, "Glucose data is still recent - lastReading: ${state.recentGlucoseDatetime}") + return + } + + try { + EversenseLogger.debug(TAG, "Reading current glucose...") + val glucoseData = gatt.writePacket(GetCurrentGlucosePacket()) + if (glucoseData.datetime <= state.recentGlucoseDatetime) { + EversenseLogger.warning(TAG, "Glucose data is still recent after reading - currentReading: ${glucoseData.datetime}, lastReading: ${state.recentGlucoseDatetime}") + return + } + + if (glucoseData.glucoseInMgDl > 1000) { + EversenseLogger.error(TAG, "recentGlucose exceeds range - received: ${glucoseData.glucoseInMgDl}") + return + } + + var currentGlucose = glucoseData.glucoseInMgDl + + val result = mutableListOf() + state.recentGlucoseDatetime = glucoseData.datetime + state.recentGlucoseValue = currentGlucose + state.lastGlucoseRaw = glucoseData.glucoseInMgDl + result += EversenseCGMResult( + glucoseInMgDl = currentGlucose, + datetime = glucoseData.datetime, + trend = glucoseData.trend, + sensorId = state.sensorId, + rawResponseHex = glucoseData.rawResponseHex + ) + + // TODO: read history for backfill + + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(state)) + } + + // Read RSSI to update placement signal after each glucose reading + try { + EversenseLogger.debug(TAG, "Reading RSSI for placement signal...") + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Failed to read RSSI: $e") + } + + handler.post { + watchers.forEach { + it.onCGMRead(EversenseType.EVERSENSE_E3, result) + } + } + } catch (exception: Exception) { + EversenseLogger.error(TAG, "Got exception during readGlucose - exception $exception") + } + } + + fun fullSync(gatt: EversenseGattCallback, preferences: SharedPreferences, watchers: List, force: Boolean = false) { + try { + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + val prevLastCalibrationDate = state.lastCalibrationDate + + val freshnessThreshold = if (state.calibrationReadiness == CalibrationReadiness.WAITING_POST_CALIBRATION) + TimeUnit.SECONDS.toMillis(60) else TimeUnit.SECONDS.toMillis(270) + val freshnessCutoff = System.currentTimeMillis() - freshnessThreshold + + if (!force && freshnessCutoff < state.lastSync) { + EversenseLogger.warning(TAG, "State is still fresh - lastSync: ${state.lastSync}") + return + } + + // Send ping first ΓÇö the official app calls postPingRequest() before any + // ReadSingleByte commands. Without it the transmitter rejects 0x2A with + // InvalidMessageLength (error 5) for every address. + EversenseLogger.debug(TAG, "Pinging transmitter...") + try { + gatt.writePacket(PingPacket()) + EversenseLogger.info(TAG, "Ping successful") + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Ping failed (non-fatal): $e") + } + + EversenseLogger.debug(TAG, "Reading current datetime...") + val currentDatetime = gatt.writePacket(GetCurrentDatetimePacket()) + if (currentDatetime.needsTimeSync) { + EversenseLogger.debug(TAG, "Send SetCurrentDatetimePacket...") + gatt.writePacket(SetCurrentDatetimePacket()) + } + + // The E3 battery register returns an enum index (0-11) mapped to display percentages. + // Mapping sourced from official Eversense app BATTERY_LEVEL enum (fromStrength). + try { + EversenseLogger.debug(TAG, "Reading battery percentage...") + val batteryRaw = gatt.writePacket(GetBatteryPercentagePacket()) + EversenseLogger.info(TAG, "Battery raw register value: ${batteryRaw.percentage}") + state.batteryPercentage = when (batteryRaw.percentage) { + 0 -> 0 + 1 -> 5 + 2 -> 10 + 3 -> 25 + 4 -> 35 + 5 -> 45 + 6 -> 55 + 7 -> 65 + 8 -> 75 + 9 -> 85 + 10 -> 95 + 11 -> 100 + else -> batteryRaw.percentage + } + EversenseLogger.info(TAG, "Battery percentage mapped: ${state.batteryPercentage}%") + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Battery read failed (non-fatal): $e") + } + + // All flash register reads below are wrapped in try/catch. + // Paolo's E3 firmware 6.04 rejects ReadTwoByte/ReadFourByte commands + // with InvalidMessageLength (error 5) ΓÇö these must be non-fatal so + // fullSync completes and lastSync updates even if reads fail. + + try { + EversenseLogger.debug(TAG, "Reading insertion datetime...") + val insertionDate = gatt.writePacket(GetInsertionDatePacket()) + val insertionTime = gatt.writePacket(GetInsertionTimePacket()) + val combined = insertionDate.date + insertionTime.time + val minDate = 1577836800000L // 2020-01-01 + val maxDate = 1893456000000L // 2030-01-01 + if (combined in minDate..maxDate) { + state.insertionDate = combined + EversenseLogger.info(TAG, "Insertion date accepted: $combined") + } else { + EversenseLogger.warning(TAG, "Insertion date out of plausible range, ignoring: $combined") + } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Insertion datetime read failed (non-fatal): $e") + } + + try { + EversenseLogger.debug(TAG, "Reading calibration info...") + val calibrationPhase = gatt.writePacket(GetCalibrationPhasePacket()) + val calibrationReadiness = gatt.writePacket(GetCalibrationReadinessPacket()) + val nextCalibrationDate = gatt.writePacket(GetNextCalibrationDatePacket()) + val nextCalibrationTime = gatt.writePacket(GetNextCalibrationTimePacket()) + val lastCalibrationDate = gatt.writePacket(GetLastCalibrationDatePacket()) + val lastCalibrationTime = gatt.writePacket(GetLastCalibrationTimePacket()) + state.calibrationPhase = calibrationPhase.phase + state.calibrationReadiness = calibrationReadiness.readiness + val minCalDate = 1577836800000L // 2020-01-01 + val maxCalDate = 1893456000000L // 2030-01-01 + val newNextCal = nextCalibrationDate.date + nextCalibrationTime.time + val newLastCal = lastCalibrationDate.date + lastCalibrationTime.time + if (newNextCal in minCalDate..maxCalDate) { + state.nextCalibrationDate = newNextCal + EversenseLogger.info(TAG, "nextCalibrationDate accepted: $newNextCal") + } else { + EversenseLogger.warning(TAG, "nextCalibrationDate out of plausible range, ignoring: $newNextCal") + } + if (newLastCal in minCalDate..maxCalDate) { + if (newLastCal != prevLastCalibrationDate && prevLastCalibrationDate != 0L) { + EversenseLogger.info(TAG, "Calibration detected from external source: lastCalibrationDate changed from $prevLastCalibrationDate to $newLastCal") + } + state.lastCalibrationDate = newLastCal + EversenseLogger.info(TAG, "lastCalibrationDate accepted: $newLastCal") + } else { + EversenseLogger.warning(TAG, "lastCalibrationDate out of plausible range, ignoring: $newLastCal") + } + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Calibration info read failed (non-fatal): $e") + } + + try { + val isDailyCalibration = gatt.writePacket(GetCalibrationDailyPacket()) + state.calibrationMode = if (isDailyCalibration.isDaily) CalibrationMode.DAILY_SINGLE else CalibrationMode.DAILY_DUAL + } catch (e: Exception) { + state.calibrationMode = CalibrationMode.DEFAULT + } + + // Transmitter settings ΓÇö all non-fatal + try { + EversenseLogger.debug(TAG, "Reading transmitter settings...") + val vibrateEnabled = gatt.writePacket(GetSettingVibratePacket()) + val glucoseHighEnabled = gatt.writePacket(GetSettingGlucoseHighEnabled()) + val glucoseHighThreshold = gatt.writePacket(GetSettingGlucoseHighThresholdPacket()) + val glucoseLowThreshold = gatt.writePacket(GetSettingGlucoseLowThresholdPacket()) + val rateFallingEnabled = gatt.writePacket(GetSettingRateFallingEnabledPacket()) + val rateFallingThreshold = gatt.writePacket(GetSettingRateFallingThresholdPacket()) + val rateRisingEnabled = gatt.writePacket(GetSettingRateRisingEnabledPacket()) + val rateRisingThreshold = gatt.writePacket(GetSettingRateRisingThresholdPacket()) + val predictiveHighEnabled = gatt.writePacket(GetSettingPredictiveHighEnabledPacket()) + val predictiveHighTime = gatt.writePacket(GetSettingPredictiveHighTimePacket()) + val predictiveHighThreshold = gatt.writePacket(GetSettingPredictiveHighThresholdPacket()) + val predictiveLowEnabled = gatt.writePacket(GetSettingPredictiveLowEnabledPacket()) + val predictiveLowTime = gatt.writePacket(GetSettingPredictiveLowTimePacket()) + val predictiveLowThreshold = gatt.writePacket(GetSettingPredictiveLowThresholdPacket()) + state.settings.vibrateEnabled = vibrateEnabled.enabled + state.settings.glucoseHighAlarmEnabled = glucoseHighEnabled.enabled + state.settings.glucoseHighAlarmThreshold = glucoseHighThreshold.threshold + state.settings.glucoseLowAlarmThreshold = glucoseLowThreshold.threshold + state.settings.rateFallingAlarmEnabled = rateFallingEnabled.enabled + state.settings.rateFallingAlarmThreshold = rateFallingThreshold.threshold + state.settings.rateRisingAlarmEnabled = rateRisingEnabled.enabled + state.settings.rateRisingAlarmThreshold = rateRisingThreshold.threshold + state.settings.predictiveHighAlarmEnabled = predictiveHighEnabled.enabled + state.settings.predictiveHighAlarmMinutes = predictiveHighTime.minutes + state.settings.predictiveHighAlarmThreshold = predictiveHighThreshold.threshold + state.settings.predictiveLowAlarmEnabled = predictiveLowEnabled.enabled + state.settings.predictiveLowAlarmMinutes = predictiveLowTime.minutes + state.settings.predictiveLowAlarmThreshold = predictiveLowThreshold.threshold + } catch (e: Exception) { + EversenseLogger.warning(TAG, "Settings read failed (non-fatal): $e") + } + + // Get firmware version ΓÇö aligns with iOS GetVersionPacket + try { + val version = gatt.writePacket(GetVersionPacket()) + if (version != null) state.firmwareVersion = version.version + } catch (e: Exception) { EversenseLogger.warning(TAG, "GetVersion failed: $e") } + + // Get extended firmware version + try { + val extVersion = gatt.writePacket(GetVersionExtendedPacket()) + if (extVersion != null) state.extFirmwareVersion = extVersion.extVersion + } catch (e: Exception) { EversenseLogger.warning(TAG, "GetVersionExtended failed: $e") } + + // Get MMA features + try { + val mma = gatt.writePacket(GetMmaFeaturesPacket()) + if (mma != null) state.mmaFeatures = mma.value + } catch (e: Exception) { EversenseLogger.warning(TAG, "GetMmaFeatures failed: $e") } + + // Set app version ΓÇö iOS sends 8.0.4 in every fullSync + try { gatt.writePacket(SetAppVersionE3Packet()) } catch (e: Exception) { EversenseLogger.warning(TAG, "SetAppVersionE3 failed: $e") } + + // Set BLE disconnect timeout ΓÇö 300s matching iOS default + try { gatt.writePacket(SetBleDisconnectPacket(300)) } catch (e: Exception) { EversenseLogger.warning(TAG, "SetBleDisconnect E3 failed: $e") } + + state.lastSync = System.currentTimeMillis() + EversenseLogger.info(TAG, "Completed full sync - datetime: ${state.lastSync}") + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(state)) + } + + handler.post { + watchers.forEach { + it.onStateChanged(state) + } + } + } catch (exception: Exception) { + EversenseLogger.error(TAG, "Failed to do full sync: $exception") + } + } + + fun writeSettings(gatt: EversenseGattCallback, preferences: SharedPreferences, settings: EversenseTransmitterSettings): Boolean { + try { + gatt.writePacket(SetSettingVibratePacket(settings.vibrateEnabled)) + + gatt.writePacket(SetSettingGlucoseHighEnablePacket(settings.glucoseHighAlarmEnabled)) + gatt.writePacket(SetSettingGlucoseHighThresholdPacket(settings.glucoseHighAlarmThreshold)) + gatt.writePacket(SetSettingGlucoseLowThresholdPacket(settings.glucoseLowAlarmThreshold)) + + gatt.writePacket(SetSettingRateFallingEnabledPacket(settings.rateFallingAlarmEnabled)) + gatt.writePacket(SetSettingRateFallingThresholdPacket(settings.rateFallingAlarmThreshold)) + gatt.writePacket(SetSettingRateRisingEnabledPacket(settings.rateRisingAlarmEnabled)) + gatt.writePacket(SetSettingRateRisingThresholdPacket(settings.rateRisingAlarmThreshold)) + + gatt.writePacket(SetSettingPredictiveHighAlarmEnabledPacket(settings.predictiveHighAlarmEnabled)) + gatt.writePacket(SetSettingPredictiveHighTimePacket(settings.predictiveHighAlarmMinutes)) + gatt.writePacket(SetSettingPredictiveHighThresholdPacket(settings.predictiveHighAlarmThreshold)) + gatt.writePacket(SetSettingPredictiveLowAlarmEnabledPacket(settings.predictiveLowAlarmEnabled)) + gatt.writePacket(SetSettingPredictiveLowTimePacket(settings.predictiveLowAlarmMinutes)) + gatt.writePacket(SetSettingPredictiveLowThresholdPacket(settings.predictiveLowAlarmThreshold)) + + val stateJson = preferences.getString(StorageKeys.STATE, null) ?: "{}" + val state = JSON.decodeFromString(stateJson) + state.settings = settings + preferences.edit(commit = true) { + putString(StorageKeys.STATE, JSON.encodeToString(state)) + } + + return true + } catch (exception: Exception) { + EversenseLogger.error(TAG, "Failed to write settings: $exception") + return false + } + } + + // Send a blood glucose calibration value to the E3 transmitter. + // The transmitter must be in CalibrationReadiness.READY state. + // Throws EversenseWriteException if the packet fails. + fun sendCalibration(gatt: EversenseGattCallback, glucoseMgDl: Int) { + EversenseLogger.info(TAG, "Sending calibration value: $glucoseMgDl mg/dL") + gatt.writePacket(SendCalibrationPacket(glucoseMgDl), 15000L) + EversenseLogger.info(TAG, "Calibration sent successfully") + } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversensePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversensePacket.kt new file mode 100644 index 000000000000..263a3e2bac55 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/EversensePacket.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets + +import com.nightscout.eversense.enums.EversenseSecurityType + +annotation class EversensePacket( + /** The request id for the packet */ + val requestId: Byte, + + /** The expected response id for this packet */ + val responseId: Byte, + + /** The expected response id for this packet. Only relevant for 365 packets */ + val typeId: Byte, + + /** The required security protocol */ + val securityType: EversenseSecurityType +) diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EnterDiagnosticModePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EnterDiagnosticModePacket.kt new file mode 100644 index 000000000000..42cbb458eb10 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EnterDiagnosticModePacket.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.EnterDiagnosticModeCommandId, + responseId = EversenseE3Packets.EnterDiagnosticModeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class EnterDiagnosticModePacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = ByteArray(0) + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EversenseE3Packets.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EversenseE3Packets.kt new file mode 100644 index 000000000000..0ee35d933a87 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/EversenseE3Packets.kt @@ -0,0 +1,131 @@ +package com.nightscout.eversense.packets.e3 + +class EversenseE3Packets { + companion object { + const val AssertSnoozeAgainsAlarmCommandId = 20.toByte() + const val AssertSnoozeAgainsAlarmResponseId = 148.toByte() + const val CalibrationAlertPush = 77.toByte() + const val CalibrationPush = 67.toByte() + const val CalibrationSwitchPush = 76.toByte() + const val ChangeTimingParametersCommandId = 117.toByte() + const val ChangeTimingParametersResponseId = 245.toByte() + const val ClearErrorFlagsCommandId = 4.toByte() + const val ClearErrorFlagsResponseId = 132.toByte() + const val DisconnectBLESavingBondingInformationCommandId = 116.toByte() + const val DisconnectBLESavingBondingInformationResponseId = 244.toByte() + const val EnterDiagnosticModeCommandId = 118.toByte() + const val EnterDiagnosticModeResponseId = 246.toByte() + const val ErrorResponseId = 128.toByte() + const val ExerciseVibrationCommandId = 106.toByte() + const val ExerciseVibrationResponseId = 234.toByte() + const val ExitDiagnosticModeCommandId = 119.toByte() + const val ExitDiagnosticModeResponseId = 247.toByte() + const val GlucoseLevelAlarmPush = 64.toByte() + const val GlucoseLevelAlertPush = 65.toByte() + const val HardwareStatusPush = 69.toByte() + const val KeepAlivePush = 80.toByte() + const val LinkTransmitterWithSensorCommandId = 2.toByte() + const val LinkTransmitterWithSensorResponseId = 130.toByte() + const val MarkPatientEventRecordAsDeletedCommandId = 29.toByte() + const val MarkPatientEventRecordAsDeletedResponseId = 157.toByte() + const val PingCommandId = 1.toByte() + const val PingResponseId = 129.toByte() + const val RateAndPredictiveAlertPush = 66.toByte() + const val ReadAllAvailableSensorsResponseId = 134.toByte() + const val ReadAllSensorGlucoseAlertsInSpecifiedRangeCommandId = 113.toByte() + const val ReadAllSensorGlucoseAlertsInSpecifiedRangeResponseId = 241.toByte() + const val ReadAllSensorGlucoseDataInSpecifiedRangeCommandId = 112.toByte() + const val ReadAllSensorGlucoseDataInSpecifiedRangeResponseId = 240.toByte() + const val ReadCurrentTransmitterDateAndTimeCommandId = 25.toByte() + const val ReadCurrentTransmitterDateAndTimeResponseId = 153.toByte() + const val ReadFirstAndLastBloodGlucoseDataRecordNumbersCommandId = 23.toByte() + const val ReadFirstAndLastBloodGlucoseDataRecordNumbersResponseId = 151.toByte() + const val ReadFirstAndLastErrorLogRecordNumbersCommandId = 39.toByte() + const val ReadFirstAndLastErrorLogRecordNumbersResponseId = 167.toByte() + const val ReadFirstAndLastMiscEventLogRecordNumbersCommandId = 35.toByte() + const val ReadFirstAndLastMiscEventLogRecordNumbersResponseId = 163.toByte() + const val ReadFirstAndLastPatientEventRecordNumbersCommandId = 28.toByte() + const val ReadFirstAndLastPatientEventRecordNumbersResponseId = 156.toByte() + const val ReadFirstAndLastSensorGlucoseAlertRecordNumbersCommandId = 18.toByte() + const val ReadFirstAndLastSensorGlucoseAlertRecordNumbersResponseId = 146.toByte() + const val ReadFirstAndLastSensorGlucoseRecordNumbersCommandId = 14.toByte() + const val ReadFirstAndLastSensorGlucoseRecordNumbersResponseId = 142.toByte() + const val ReadFourByteSerialFlashRegisterCommandId = 46.toByte() + const val ReadFourByteSerialFlashRegisterResponseId = 174.toByte() + const val ReadLogOfBloodGlucoseDataInSpecifiedRangeCommandId = 114.toByte() + const val ReadLogOfBloodGlucoseDataInSpecifiedRangeResponseId = 242.toByte() + const val ReadLogOfPatientEventsInSpecifiedRangeCommandId = 115.toByte() + const val ReadLogOfPatientEventsInSpecifiedRangeResponseId = 243.toByte() + const val ReadNByteSerialFlashRegisterCommandId = 48.toByte() + const val ReadNByteSerialFlashRegisterResponseId = 176.toByte() + const val ReadSensorGlucoseAlertsAndStatusCommandId = 16.toByte() + const val ReadSensorGlucoseAlertsAndStatusResponseId = 144.toByte() + const val ReadSensorGlucoseCommandId = 8.toByte() + const val ReadSensorGlucoseResponseId = 136.toByte() + const val ReadSingleBloodGlucoseDataRecordCommandId = 22.toByte() + const val ReadSingleBloodGlucoseDataRecordResponseId = 150.toByte() + const val ReadSingleByteSerialFlashRegisterCommandId = 42.toByte() + const val ReadSingleByteSerialFlashRegisterResponseId = 170.toByte() + const val ReadSingleMiscEventLogCommandId = 34.toByte() + const val ReadSingleMiscEventLogResponseId = 162.toByte() + const val ReadSinglePatientEventCommandId = 27.toByte() + const val ReadSinglePatientEventResponseId = 155.toByte() + const val ReadSingleSensorGlucoseAlertRecordCommandId = 17.toByte() + const val ReadSingleSensorGlucoseAlertRecordResponseId = 145.toByte() + const val ReadSingleSensorGlucoseDataRecordResponseId = 137.toByte() + const val ReadTwoByteSerialFlashRegisterCommandId = 44.toByte() + const val ReadTwoByteSerialFlashRegisterResponseId = 172.toByte() + const val ResetTransmitterCommandId = 3.toByte() + const val ResetTransmitterResponseId = 131.toByte() + const val SaveBLEBondingInformationCommandId = 105.toByte() + const val SaveBLEBondingInformationResponseId = 233.toByte() + const val SendBloodGlucoseDataCommandId = 21.toByte() + const val SendBloodGlucoseDataResponseId = 149.toByte() + // E3 uses a different calibration command with two timestamps (sample + current) + const val SendBloodGlucoseDataWithTwoTimestampsCommandId = 60.toByte() // 0x3C + const val SendBloodGlucoseDataWithTwoTimestampsResponseId = 188.toByte() // 0xBC + + const val SensorReadAlertPush = 73.toByte() + const val SensorReplacement2Push = 75.toByte() + const val SensorReplacementPush = 68.toByte() + const val SetCurrentTransmitterDateAndTimeCommandId = 7.toByte() + const val SetCurrentTransmitterDateAndTimeResponseId = 135.toByte() + const val StartSelfTestSequenceCommandId = 5.toByte() + const val StartSelfTestSequenceResponseId = 133.toByte() + const val TestResponseId = 224.toByte() + const val TransmitterBatteryPush = 71.toByte() + const val TransmitterEOLPush = 74.toByte() + const val WriteFourByteSerialFlashRegisterCommandId = 47.toByte() + const val WriteFourByteSerialFlashRegisterResponseId = 175.toByte() + const val WriteNByteSerialFlashRegisterCommandId = 49.toByte() + const val WriteNByteSerialFlashRegisterResponseId = 177.toByte() + const val WritePatientEventCommandId = 26.toByte() + const val WritePatientEventResponseId = 154.toByte() + const val WriteSingleByteSerialFlashRegisterCommandId = 43.toByte() + const val WriteSingleByteSerialFlashRegisterResponseId = 171.toByte() + const val WriteSingleMiscEventLogRecordCommandId = 36.toByte() + const val WriteSingleMiscEventLogRecordResponseId = 164.toByte() + const val WriteTwoByteSerialFlashRegisterCommandId = 45.toByte() + const val WriteTwoByteSerialFlashRegisterResponseId = 173.toByte() + + fun isPushPacket(data: Byte): Boolean { + // KeepAlivePush (0x50) triggers normal glucose + sync cycle. + // GlucoseLevelAlarmPush (0x40), GlucoseLevelAlertPush (0x41), + // RateAndPredictiveAlertPush (0x42) are glucose push notifications + // that the transmitter sends unsolicited after calibration or alarm events. + // TransmitterBatteryPush (0x47) and SensorReadAlertPush (0x49) are also + // push packets — without them AAPS would wait up to 100s for KeepAlive + // instead of reacting immediately to the real-time glucose push. + return data == KeepAlivePush || + data == GlucoseLevelAlarmPush || + data == GlucoseLevelAlertPush || + data == RateAndPredictiveAlertPush || + data == TransmitterBatteryPush || + data == SensorReadAlertPush + } + + fun isErrorPacket(data: Byte): Boolean { + return data == ErrorResponseId + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/ExitDiagnosticModePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/ExitDiagnosticModePacket.kt new file mode 100644 index 000000000000..78113ba0fbcc --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/ExitDiagnosticModePacket.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ExitDiagnosticModeCommandId, + responseId = EversenseE3Packets.ExitDiagnosticModeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class ExitDiagnosticModePacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = ByteArray(0) + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetAlertLogPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetAlertLogPacket.kt new file mode 100644 index 000000000000..8d6f27c5e6cf --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetAlertLogPacket.kt @@ -0,0 +1,42 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseAlarm +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.ReadAllSensorGlucoseAlertsInSpecifiedRangeCommandId, + responseId = EversenseE3Packets.ReadAllSensorGlucoseAlertsInSpecifiedRangeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetAlertLogPacket(private val index: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Writer.writeInt16(index) + EversenseE3Writer.writeInt16(index) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val recordIndex = (receivedData[s].toInt() and 0xFF) or ((receivedData[s + 1].toInt() and 0xFF) shl 8) + val datetime = EversenseE3Parser.readDate(receivedData, s + 2) + EversenseE3Parser.readTime(receivedData, s + 4) + val alarmCode = receivedData[s + 7].toInt() and 0xFF + + return Response( + index = recordIndex, + datetime = datetime, + alarm = EversenseAlarm.from(alarmCode) + ) + } + + data class Response( + val index: Int, + val datetime: Long, + val alarm: EversenseAlarm + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBatteryPercentagePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBatteryPercentagePacket.kt new file mode 100644 index 000000000000..53f40e9cb42e --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBatteryPercentagePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetBatteryPercentagePacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + return EversenseE3Memory.BatteryPercentage.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + // The E3 battery register returns an enum index (0-11). Clamp to valid range. + val raw = receivedData[getStartIndex()].toInt() and 0xFF + val percentage = raw.coerceIn(0, 11) + return Response(percentage = percentage) + } + + data class Response(val percentage: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBleDisconnectPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBleDisconnectPacket.kt new file mode 100644 index 000000000000..74737b01ec4b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetBleDisconnectPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetBleDisconnectPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.BleDisconnect.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val intervalSeconds = (receivedData[s].toInt() and 0xFF) or ((receivedData[s + 1].toInt() and 0xFF) shl 8) + return Response(intervalSeconds = intervalSeconds) + } + + data class Response(val intervalSeconds: Int) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationDailyPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationDailyPacket.kt new file mode 100644 index 000000000000..225508c8e448 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationDailyPacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCalibrationDailyPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.IsOneCalibration.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + // Official app: IsOneCalibration register value 0x01 = one calibration per day (daily single) + return Response(isDaily = receivedData[getStartIndex()].toInt() and 0xFF == 0x01) + } + + data class Response(val isDaily: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogPacket.kt new file mode 100644 index 000000000000..347d360b2855 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogPacket.kt @@ -0,0 +1,45 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.CalibrationFlag +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.ReadLogOfBloodGlucoseDataInSpecifiedRangeCommandId, + responseId = EversenseE3Packets.ReadLogOfBloodGlucoseDataInSpecifiedRangeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCalibrationLogPacket(private val index: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Writer.writeInt16(index) + EversenseE3Writer.writeInt16(index) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val recordIndex = (receivedData[s].toInt() and 0xFF) or ((receivedData[s + 1].toInt() and 0xFF) shl 8) + val datetime = EversenseE3Parser.readDate(receivedData, s + 2) + EversenseE3Parser.readTime(receivedData, s + 4) + val glucoseInMgDl = (receivedData[s + 6].toInt() and 0xFF) or ((receivedData[s + 8].toInt() and 0xFF) shl 7) + val flagCode = receivedData[s + 9].toInt() and 0xFF + + return Response( + index = recordIndex, + datetime = datetime, + glucoseInMgDl = glucoseInMgDl, + flag = CalibrationFlag.from(flagCode) + ) + } + + data class Response( + val index: Int, + val datetime: Long, + val glucoseInMgDl: Int, + val flag: CalibrationFlag + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogRangePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogRangePacket.kt new file mode 100644 index 000000000000..df9005e2c210 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationLogRangePacket.kt @@ -0,0 +1,32 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +/** + * Reads the first and last record numbers for the blood glucose (calibration) log (16-bit indices). + * Use [GetGlucoseLogRangePacket] for sensor glucose log range. + */ +@EversensePacket( + requestId = EversenseE3Packets.ReadFirstAndLastBloodGlucoseDataRecordNumbersCommandId, + responseId = EversenseE3Packets.ReadFirstAndLastBloodGlucoseDataRecordNumbersResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCalibrationLogRangePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val from = (receivedData[s].toInt() and 0xFF) or ((receivedData[s + 1].toInt() and 0xFF) shl 8) + val to = (receivedData[s + 2].toInt() and 0xFF) or ((receivedData[s + 3].toInt() and 0xFF) shl 8) + + return Response(rangeFrom = from, rangeTo = to) + } + + data class Response(val rangeFrom: Int, val rangeTo: Int) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationPhasePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationPhasePacket.kt new file mode 100644 index 000000000000..add48d5425a4 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationPhasePacket.kt @@ -0,0 +1,31 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.CalibrationPhase +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCalibrationPhasePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.CalibrationPhase.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(phase = CalibrationPhase.fromE3(receivedData[getStartIndex()].toInt())) + } + + data class Response(val phase: CalibrationPhase) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationReadinessPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationReadinessPacket.kt new file mode 100644 index 000000000000..b85b5648695f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCalibrationReadinessPacket.kt @@ -0,0 +1,31 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.CalibrationReadiness +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCalibrationReadinessPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.CalibrationReadiness.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(readiness = CalibrationReadiness.from(receivedData[getStartIndex()].toInt())) + } + + data class Response(val readiness: CalibrationReadiness) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCompletedCalibrationsCountPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCompletedCalibrationsCountPacket.kt new file mode 100644 index 000000000000..e4bcc105e365 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCompletedCalibrationsCountPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCompletedCalibrationsCountPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.CalibrationsMadeInThisPhase.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val count = (receivedData[s].toInt() and 0xFF) or ((receivedData[s + 1].toInt() and 0xFF) shl 8) + return Response(count = count) + } + + data class Response(val count: Int) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentDatetimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentDatetimePacket.kt new file mode 100644 index 000000000000..4fbe01fe74c2 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentDatetimePacket.kt @@ -0,0 +1,49 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser +import java.util.TimeZone +import kotlin.math.abs + +@EversensePacket( + requestId = EversenseE3Packets.ReadCurrentTransmitterDateAndTimeCommandId, + responseId = EversenseE3Packets.ReadCurrentTransmitterDateAndTimeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCurrentDatetimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return ByteArray(0) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + val start = getStartIndex() + val date = EversenseE3Parser.readDate(receivedData, start) + val time = EversenseE3Parser.readTime(receivedData, start + 2) + val timeZoneOffset = EversenseE3Parser.readTimezone(receivedData, start + 4) + + var needsTimeSync = false + val actualTimeZoneOffset = TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() + + // Allow time drift <10s + if (abs(System.currentTimeMillis() - (date + time)) > 10_000) { + EversenseLogger.warning("GetCurrentDatetimePacket", "time drift detected... drift: ${abs(System.currentTimeMillis() - (date + time))} ms") + needsTimeSync = true + } else if (actualTimeZoneOffset != timeZoneOffset) { + EversenseLogger.warning("GetCurrentDatetimePacket", "timezone mismatch - received: $timeZoneOffset, actual: $actualTimeZoneOffset") + needsTimeSync = true + } + + return Response(date + time, timeZoneOffset, needsTimeSync) + } + + data class Response(val datetime: Long, val timezoneOffset: Long, val needsTimeSync: Boolean): EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentGlucosePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentGlucosePacket.kt new file mode 100644 index 000000000000..58df32361ee2 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetCurrentGlucosePacket.kt @@ -0,0 +1,54 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadSensorGlucoseCommandId, + responseId = EversenseE3Packets.ReadSensorGlucoseResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetCurrentGlucosePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return ByteArray(0) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + val rawHex = receivedData.toByteArray().joinToString("") { "%02x".format(it) } + val glucoseInMgDl = EversenseE3Parser.readGlucose(receivedData, 9) + // Reject implausible glucose values — the transmitter occasionally sends + // unsolicited 0x88 packets after calibration with different byte layout + // that produce out-of-range values when parsed at offset 9. + if (glucoseInMgDl < 20 || glucoseInMgDl > 600) { + return null + } + return Response( + datetime = EversenseE3Parser.readDate(receivedData, 4) + EversenseE3Parser.readTime(receivedData, 6), + glucoseInMgDl = glucoseInMgDl, + trend = parseTrend(receivedData[13].toInt()), + rawResponseHex = rawHex + ) + } + + private fun parseTrend(value: Int): EversenseTrendArrow { + return when(value) { + 1 -> EversenseTrendArrow.SINGLE_DOWN + 2 -> EversenseTrendArrow.FORTY_FIVE_DOWN + 4 -> EversenseTrendArrow.FLAT + 8 -> EversenseTrendArrow.FORTY_FIVE_UP + 16 -> EversenseTrendArrow.SINGLE_UP + else -> EversenseTrendArrow.NONE + } + } + + data class Response(val datetime: Long, val glucoseInMgDl: Int, val trend: EversenseTrendArrow, val rawResponseHex: String = "") : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseAlertsAndStatusPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseAlertsAndStatusPacket.kt new file mode 100644 index 000000000000..3bd8ff13564e --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseAlertsAndStatusPacket.kt @@ -0,0 +1,56 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.models.ActiveAlarm +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.util.MessageCoder + +@EversensePacket( + requestId = EversenseE3Packets.ReadSensorGlucoseAlertsAndStatusCommandId, + responseId = EversenseE3Packets.ReadSensorGlucoseAlertsAndStatusResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetGlucoseAlertsAndStatusPacket : EversenseBasePacket() { + + private val STATUS_FLAG_COUNT = 13 + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val rawContent = receivedData.drop(s + 1).dropLast(2).map { it.toInt() and 0xFF } + val content = IntArray(STATUS_FLAG_COUNT) + val offset = STATUS_FLAG_COUNT - rawContent.size.coerceAtMost(STATUS_FLAG_COUNT) + rawContent.take(STATUS_FLAG_COUNT).forEachIndexed { i, v -> content[offset + i] = v } + + if (content.all { it == 0 }) return Response(alarms = emptyList()) + + val alarms = mutableListOf() + + fun add(alarm: com.nightscout.eversense.enums.EversenseAlarm?) { + alarm?.let { alarms.add(ActiveAlarm(code = it, codeRaw = it.code, flag = 0, priority = 0)) } + } + + add(MessageCoder.messageCodeForGlucoseLevelAlarmFlags(content[0])) + add(MessageCoder.messageCodeForGlucoseLevelAlertFlags(content[1])) + add(MessageCoder.messageCodeForRateAlertFlags(content[2])) + add(MessageCoder.messageCodeForPredictiveAlertFlags(content[3])) + add(MessageCoder.messageCodeForSensorHardwareAndAlertFlags(content[4])) + add(MessageCoder.messageCodeForSensorReadAlertFlags(content[5])) + add(MessageCoder.messageCodeForSensorReplacementFlags(content[6])) + add(MessageCoder.messageCodeForSensorCalibrationFlags(content[7])) + add(MessageCoder.messageCodeForTransmitterStatusAlertFlags(content[8])) + add(MessageCoder.messageCodeForTransmitterBatteryAlertFlags(content[9])) + add(MessageCoder.messageCodeForTransmitterEOLAlertFlags(content[10])) + add(MessageCoder.messageCodeForSensorReplacementFlags2(content[11])) + add(MessageCoder.messageCodeForCalibrationSwitchFlags(content[12])) + + return Response(alarms = alarms) + } + + data class Response(val alarms: List) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogPacket.kt new file mode 100644 index 000000000000..064398520cc7 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogPacket.kt @@ -0,0 +1,50 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadAllSensorGlucoseDataInSpecifiedRangeCommandId, + responseId = EversenseE3Packets.ReadAllSensorGlucoseDataInSpecifiedRangeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetGlucoseLogPacket(private val index: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + // 24-bit (3-byte) index sent as both from and to + return byteArrayOf( + index.toByte(), + (index shr 8).toByte(), + (index shr 16).toByte(), + index.toByte(), + (index shr 8).toByte(), + (index shr 16).toByte() + ) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val recordIndex = (receivedData[s].toInt() and 0xFF) or + ((receivedData[s + 1].toInt() and 0xFF) shl 8) or + ((receivedData[s + 2].toInt() and 0xFF) shl 16) + val datetime = EversenseE3Parser.readDate(receivedData, s + 3) + EversenseE3Parser.readTime(receivedData, s + 5) + val glucoseInMgDl = (receivedData[s + 7].toInt() and 0xFF) or ((receivedData[s + 8].toInt() and 0xFF) shl 8) + + return Response( + index = recordIndex, + datetime = datetime, + glucoseInMgDl = glucoseInMgDl + ) + } + + data class Response( + val index: Int, + val datetime: Long, + val glucoseInMgDl: Int + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogRangePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogRangePacket.kt new file mode 100644 index 000000000000..1808d2bd23ca --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetGlucoseLogRangePacket.kt @@ -0,0 +1,36 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +/** + * Reads the first and last record numbers for the sensor glucose log (24-bit indices). + * Use [GetCalibrationLogRangePacket] for blood glucose (calibration) log range. + */ +@EversensePacket( + requestId = EversenseE3Packets.ReadFirstAndLastSensorGlucoseRecordNumbersCommandId, + responseId = EversenseE3Packets.ReadFirstAndLastSensorGlucoseRecordNumbersResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetGlucoseLogRangePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val s = getStartIndex() + val from = (receivedData[s].toInt() and 0xFF) or + ((receivedData[s + 1].toInt() and 0xFF) shl 8) or + ((receivedData[s + 2].toInt() and 0xFF) shl 16) + val to = (receivedData[s + 3].toInt() and 0xFF) or + ((receivedData[s + 4].toInt() and 0xFF) shl 8) or + ((receivedData[s + 5].toInt() and 0xFF) shl 16) + + return Response(rangeFrom = from, rangeTo = to) + } + + data class Response(val rangeFrom: Int, val rangeTo: Int) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetHighGlucoseRepeatIntervalPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetHighGlucoseRepeatIntervalPacket.kt new file mode 100644 index 000000000000..1ebe2d0a8e53 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetHighGlucoseRepeatIntervalPacket.kt @@ -0,0 +1,21 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetHighGlucoseRepeatIntervalPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = EversenseE3Memory.HighGlucoseAlarmRepeatIntervalDay.getRequestData() + override fun parseResponse(): Response? { + if (receivedData.size < getStartIndex() + 1) return null + return Response(intervalMinutes = receivedData[getStartIndex()].toInt() and 0xFF) + } + data class Response(val intervalMinutes: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionDatePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionDatePacket.kt new file mode 100644 index 000000000000..44831229544a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionDatePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetInsertionDatePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.SensorInsertionDate.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(date = EversenseE3Parser.readDate(receivedData, getStartIndex())) + } + + data class Response(val date: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionTimePacket.kt new file mode 100644 index 000000000000..17cd351bb52f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetInsertionTimePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetInsertionTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.SensorInsertionTime.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(time = EversenseE3Parser.readTime(receivedData, getStartIndex())) + } + + data class Response(val time: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetIsOneCalPhasePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetIsOneCalPhasePacket.kt new file mode 100644 index 000000000000..7f234c317f64 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetIsOneCalPhasePacket.kt @@ -0,0 +1,26 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetIsOneCalPhasePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.IsOneCalibration.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response(value = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val value: Boolean) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationDatePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationDatePacket.kt new file mode 100644 index 000000000000..b445a54c1af0 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationDatePacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetLastCalibrationDatePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LastCalibrationDate.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response(date = EversenseE3Parser.readDate(receivedData, getStartIndex())) + } + + data class Response(val date: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationTimePacket.kt new file mode 100644 index 000000000000..9b4f13d23111 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLastCalibrationTimePacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetLastCalibrationTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LastCalibrationTime.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response(time = EversenseE3Parser.readTime(receivedData, getStartIndex())) + } + + data class Response(val time: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLowGlucoseRepeatIntervalPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLowGlucoseRepeatIntervalPacket.kt new file mode 100644 index 000000000000..c397ed30e155 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetLowGlucoseRepeatIntervalPacket.kt @@ -0,0 +1,21 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetLowGlucoseRepeatIntervalPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = EversenseE3Memory.LowGlucoseAlarmRepeatIntervalDay.getRequestData() + override fun parseResponse(): Response? { + if (receivedData.size < getStartIndex() + 1) return null + return Response(intervalMinutes = receivedData[getStartIndex()].toInt() and 0xFF) + } + data class Response(val intervalMinutes: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetMmaFeaturesPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetMmaFeaturesPacket.kt new file mode 100644 index 000000000000..1a4e87756142 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetMmaFeaturesPacket.kt @@ -0,0 +1,21 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetMmaFeaturesPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = EversenseE3Memory.MmaFeatures.getRequestData() + override fun parseResponse(): Response? { + if (receivedData.size < getStartIndex() + 1) return null + return Response(value = receivedData[getStartIndex()].toInt() and 0xFF) + } + data class Response(val value: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationDatePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationDatePacket.kt new file mode 100644 index 000000000000..4123b98fc07a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationDatePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetNextCalibrationDatePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.NextCalibrationDate.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(date = EversenseE3Parser.readDate(receivedData, getStartIndex())) + } + + data class Response(val date: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationTimePacket.kt new file mode 100644 index 000000000000..bbd36786cf7d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetNextCalibrationTimePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetNextCalibrationTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.NextCalibrationTime.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(time = EversenseE3Parser.readTime(receivedData, getStartIndex())) + } + + data class Response(val time: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighEnabled.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighEnabled.kt new file mode 100644 index 000000000000..eef9a00d909d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighEnabled.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingGlucoseHighEnabled : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmEnabled.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighThresholdPacket.kt new file mode 100644 index 000000000000..a10c7e337185 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseHighThresholdPacket.kt @@ -0,0 +1,32 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingGlucoseHighThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmThreshold.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response( + threshold = EversenseE3Parser.readGlucose(receivedData, getStartIndex()) + ) + } + + data class Response(val threshold: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseLowThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseLowThresholdPacket.kt new file mode 100644 index 000000000000..5c630b61381f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingGlucoseLowThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingGlucoseLowThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LowGlucoseAlarmThreshold.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(threshold = EversenseE3Parser.readGlucose(receivedData, getStartIndex())) + } + + data class Response(val threshold: Int): EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveAlarmEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveAlarmEnabledPacket.kt new file mode 100644 index 000000000000..a2dd0448a20b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveAlarmEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveAlarmEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighEnabledPacket.kt new file mode 100644 index 000000000000..6246df767059 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveHighEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighThresholdPacket.kt new file mode 100644 index 000000000000..6f4782d1ec46 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveHighThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighTarget.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(threshold = EversenseE3Parser.readGlucose(receivedData, getStartIndex())) + } + + data class Response(val threshold: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighTimePacket.kt new file mode 100644 index 000000000000..5a35c8e094cb --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveHighTimePacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveHighTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighTime.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(minutes = receivedData[getStartIndex()].toInt()) + } + + data class Response(val minutes: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowEnabledPacket.kt new file mode 100644 index 000000000000..f66e33162075 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveLowEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowThresholdPacket.kt new file mode 100644 index 000000000000..03af10877ba4 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Parser + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveLowThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowTarget.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(threshold = EversenseE3Parser.readGlucose(receivedData, getStartIndex())) + } + + data class Response(val threshold: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowTimePacket.kt new file mode 100644 index 000000000000..13c6031d7af6 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingPredictiveLowTimePacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingPredictiveLowTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowTime.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(minutes = receivedData[getStartIndex()].toInt()) + } + + data class Response(val minutes: Int) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateEnabledPacket.kt new file mode 100644 index 000000000000..261771a233a5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingRateEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingEnabledPacket.kt new file mode 100644 index 000000000000..44e95c9dc921 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingRateFallingEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateFallingAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingThresholdPacket.kt new file mode 100644 index 000000000000..15bf0112402b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateFallingThresholdPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingRateFallingThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateFallingThreshold.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(threshold = receivedData[getStartIndex()].toDouble() / 10) + } + + data class Response(val threshold: Double): EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingEnabledPacket.kt new file mode 100644 index 000000000000..b617526b8551 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingEnabledPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingRateRisingEnabledPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateRisingAlert.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(enabled = receivedData[getStartIndex()].toInt() == 0x55) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingThresholdPacket.kt new file mode 100644 index 000000000000..23c5404014a2 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingRateRisingThresholdPacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingRateRisingThresholdPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateRisingThreshold.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response(threshold = receivedData[getStartIndex()].toDouble() / 10) + } + + data class Response(val threshold: Double) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingVibratePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingVibratePacket.kt new file mode 100644 index 000000000000..927fecd14df2 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSettingVibratePacket.kt @@ -0,0 +1,31 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSettingVibratePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.VibrateMode.getRequestData() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response( + enabled = receivedData[getStartIndex()].toInt() == 0x55 + ) + } + + data class Response(val enabled: Boolean) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSignalStrengthRawPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSignalStrengthRawPacket.kt new file mode 100644 index 000000000000..c2caa47b69e9 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetSignalStrengthRawPacket.kt @@ -0,0 +1,45 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetSignalStrengthRawPacket : EversenseBasePacket() { + + companion object { + // Raw thresholds matching iOS EversenseKit SignalStrength.swift rawThreshold values + const val THRESHOLD_EXCELLENT = 1600 + const val THRESHOLD_GOOD = 1300 + const val THRESHOLD_LOW = 800 + const val THRESHOLD_VERY_LOW = 500 + const val THRESHOLD_POOR = 350 + } + + override fun getRequestData(): ByteArray = EversenseE3Memory.SensorFieldCurrentRaw.getRequestData() + + override fun parseResponse(): Response? { + val start = getStartIndex() + if (receivedData.size < start + 2) return null + + // Little-endian UInt16 — matches iOS: UInt16(data[start]) | (UInt16(data[start + 1]) << 8) + val raw = (receivedData[start].toInt() and 0xFF) or + ((receivedData[start + 1].toInt() and 0xFF) shl 8) + + // Scale to 0-100 matching iOS PlacementGuideViewModel: rawValue / 20 + val signalPercent = (raw / 20).coerceIn(0, 100) + + return Response(rawValue = raw, signalStrength = signalPercent) + } + + data class Response( + val rawValue: Int, + val signalStrength: Int // 0-100 scaled, matching iOS implementation + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionExtendedPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionExtendedPacket.kt new file mode 100644 index 000000000000..40b644476f2c --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionExtendedPacket.kt @@ -0,0 +1,23 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadFourByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadFourByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetVersionExtendedPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = EversenseE3Memory.TransmitterSoftwareVersionExt.getRequestData() + override fun parseResponse(): Response? { + val start = getStartIndex() + if (receivedData.size < start + 4) return null + val extVersion = (start until start + 4).map { receivedData[it].toInt().toChar() }.joinToString("") + return Response(extVersion = extVersion.trim()) + } + data class Response(val extVersion: String) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionPacket.kt new file mode 100644 index 000000000000..42abaf6f838c --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/GetVersionPacket.kt @@ -0,0 +1,23 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.ReadFourByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.ReadFourByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class GetVersionPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray = EversenseE3Memory.TransmitterSoftwareVersion.getRequestData() + override fun parseResponse(): Response? { + val start = getStartIndex() + if (receivedData.size < start + 4) return null + val version = (start until start + 4).map { receivedData[it].toInt().toChar() }.joinToString("") + return Response(version = version.trim()) + } + data class Response(val version: String) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/PingPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/PingPacket.kt new file mode 100644 index 000000000000..95dc23528738 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/PingPacket.kt @@ -0,0 +1,23 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.PingCommandId, + responseId = EversenseE3Packets.PingResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class PingPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SaveBondingInformationPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SaveBondingInformationPacket.kt new file mode 100644 index 000000000000..b02063b05d9b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SaveBondingInformationPacket.kt @@ -0,0 +1,28 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.SaveBLEBondingInformationCommandId, + responseId = EversenseE3Packets.SaveBLEBondingInformationResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SaveBondingInformationPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return ByteArray(0) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return SaveBondingInformationResponse() + } + + class SaveBondingInformationResponse() : Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SendCalibrationPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SendCalibrationPacket.kt new file mode 100644 index 000000000000..5387c20d44fa --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SendCalibrationPacket.kt @@ -0,0 +1,77 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +/** + * Sends a blood glucose calibration value to the Eversense E3 transmitter. + * + * Packet structure verified against official Eversense app + * (decompiled from operationToSendBloodGlucoseValueWithTwoTimestampsToTransmitter): + * + * [0] = 0x3C (60) — command ID for E3 two-timestamp calibration + * [1-2] = sampleDate — FAT-encoded date of the BG measurement + * [3-4] = sampleTime — FAT-encoded time of the BG measurement + * [5-6] = currentDate — FAT-encoded date of submission (NOW) ← critical, was missing + * [7-8] = currentTime — FAT-encoded time of submission (NOW) + * [9] = glucoseMgDl raw value (low byte) + * [10] = glucose MSB (data16BitsFromIntLSByteFirst[1]) + * [11] = glucose LSB (data16BitsFromIntLSByteFirst[0]) + * [12] = 0x00 — additional param (zeros) + * [13] = 0x00 — additional param (zeros) + * [14] = 0x00 — rolling cal disabled for non-US devices + * [15-16]= CRC16, appended by buildRequest() + * + * NOTE: The previous implementation used command 0x15 (single timestamp, 365-style) + * which caused the E3 transmitter to read currentTime bytes as the glucose value, + * producing wildly incorrect readings (e.g. 36416 mg/dL = FAT time 17:50 UTC). + * + * @param glucoseMgDl Blood glucose value in mg/dL + * @param sampleTimeMs Timestamp of the BG measurement (defaults to now) + */ +@EversensePacket( + requestId = EversenseE3Packets.SendBloodGlucoseDataWithTwoTimestampsCommandId, + responseId = EversenseE3Packets.SendBloodGlucoseDataWithTwoTimestampsResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SendCalibrationPacket( + private val glucoseMgDl: Int, + private val sampleTimeMs: Long = System.currentTimeMillis() +) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + val now = System.currentTimeMillis() + + val sampleDate = EversenseE3Writer.writeDate(sampleTimeMs) + val sampleTime = EversenseE3Writer.writeTime(sampleTimeMs) + val currentDate = EversenseE3Writer.writeDate(now) // current date of submission + val currentTime = EversenseE3Writer.writeTime(now) // current time of submission + + // Official app uses data16BitsFromIntLSByteFirst: [LSB, MSB] + val bgLsb = (glucoseMgDl and 0xFF).toByte() + val bgMsb = ((glucoseMgDl shr 8) and 0xFF).toByte() + + return byteArrayOf( + sampleDate[0], sampleDate[1], // [1-2] sample date + sampleTime[0], sampleTime[1], // [3-4] sample time + currentDate[0], currentDate[1], // [5-6] current submission date + currentTime[0], currentTime[1], // [7-8] current submission time + bgLsb, // [9] glucose LSB (data16Bits low byte) + bgMsb, // [10] glucose MSB (data16Bits high byte) + 0x00.toByte(), // [11] param7[1] = 0 + 0x00.toByte(), // [12] param7[0] = 0 + 0x00.toByte(), // [13] param6 = 0 + 0x55.toByte() // [14] 0x55 = calibration flag (confirmed from iOS EversenseKit PR#35) + ) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetAppVersionE3Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetAppVersionE3Packet.kt new file mode 100644 index 000000000000..ed7e9002a988 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetAppVersionE3Packet.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteFourByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteFourByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetAppVersionE3Packet(private val appVersion: String = "8.0.4") : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + val parts = appVersion.split(".") + val i0 = parts.getOrNull(0)?.toIntOrNull() ?: 0 + val i1 = parts.getOrNull(1)?.toIntOrNull() ?: 0 + val i2 = parts.getOrNull(2)?.toIntOrNull() ?: 0 + val addr = EversenseE3Memory.AppVersion.getRequestData() + return byteArrayOf(addr[0], addr[1], addr[2], + (i2 and 0xFF).toByte(), + ((i2 and 0xFF00) shr 8).toByte(), + (i1 and 0xFF).toByte(), + (i0 and 0xFF).toByte() + ) + } + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBleDisconnectPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBleDisconnectPacket.kt new file mode 100644 index 000000000000..beac3c1eb871 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBleDisconnectPacket.kt @@ -0,0 +1,24 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetBleDisconnectPacket(private val intervalSeconds: Int = 300) : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + val addr = EversenseE3Memory.BleDisconnect.getRequestData() + return byteArrayOf(addr[0], addr[1], addr[2], + (intervalSeconds and 0xFF).toByte(), + ((intervalSeconds shr 8) and 0xFF).toByte() + ) + } + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBloodGlucosePointPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBloodGlucosePointPacket.kt new file mode 100644 index 000000000000..c0bbe3ae4389 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetBloodGlucosePointPacket.kt @@ -0,0 +1,43 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer +import com.nightscout.eversense.packets.e365.utils.toUnixArray + +/** + * Sends a blood glucose calibration point using Unix2000 timestamps. + * Accepts a specific sample timestamp plus the current time, matching the + * iOS SendBloodGlucoseDataWithTwoTimestamps protocol format. + * + * @param glucoseInMgDl Blood glucose value in mg/dL + * @param sampleTimestamp Epoch milliseconds of the blood glucose sample + */ +@EversensePacket( + requestId = EversenseE3Packets.SendBloodGlucoseDataCommandId, + responseId = EversenseE3Packets.SendBloodGlucoseDataResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetBloodGlucosePointPacket( + private val glucoseInMgDl: Int, + private val sampleTimestamp: Long = System.currentTimeMillis() +) : EversenseBasePacket() { + override val skipResponseIdValidation: Boolean = true + + override fun getRequestData(): ByteArray { + val now = System.currentTimeMillis() + return sampleTimestamp.toUnixArray() + + now.toUnixArray() + + EversenseE3Writer.writeInt16(glucoseInMgDl) + + byteArrayOf(0x55) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetCurrentDatetimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetCurrentDatetimePacket.kt new file mode 100644 index 000000000000..6c9ff2c2dcff --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetCurrentDatetimePacket.kt @@ -0,0 +1,32 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.SetCurrentTransmitterDateAndTimeCommandId, + responseId = EversenseE3Packets.SetCurrentTransmitterDateAndTimeResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetCurrentDatetimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + val now = System.currentTimeMillis() + return EversenseE3Writer.writeDate(now) + + EversenseE3Writer.writeTime(now) + + EversenseE3Writer.writeTimezone(now) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() {} +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalDayPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalDayPacket.kt new file mode 100644 index 000000000000..c5daa83d91af --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalDayPacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetHighGlucoseRepeatIntervalDayPacket(private val intervalMinutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmRepeatIntervalDay.getRequestData() + + byteArrayOf(intervalMinutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalNightPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalNightPacket.kt new file mode 100644 index 000000000000..0d8487162f65 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetHighGlucoseRepeatIntervalNightPacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetHighGlucoseRepeatIntervalNightPacket(private val intervalMinutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmRepeatIntervalNight.getRequestData() + + byteArrayOf(intervalMinutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalDayPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalDayPacket.kt new file mode 100644 index 000000000000..fd5c5516176f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalDayPacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetLowGlucoseRepeatIntervalDayPacket(private val intervalMinutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LowGlucoseAlarmRepeatIntervalDay.getRequestData() + + byteArrayOf(intervalMinutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalNightPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalNightPacket.kt new file mode 100644 index 000000000000..926605c3c838 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetLowGlucoseRepeatIntervalNightPacket.kt @@ -0,0 +1,27 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetLowGlucoseRepeatIntervalNightPacket(private val intervalMinutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LowGlucoseAlarmRepeatIntervalNight.getRequestData() + + byteArrayOf(intervalMinutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighEnablePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighEnablePacket.kt new file mode 100644 index 000000000000..9d72e98e78b4 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighEnablePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingGlucoseHighEnablePacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmEnabled.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response() : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighThresholdPacket.kt new file mode 100644 index 000000000000..acc13c3f0229 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseHighThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingGlucoseHighThresholdPacket(private val threshold: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.HighGlucoseAlarmThreshold.getRequestData() + EversenseE3Writer.writeInt16(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response() : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseLowThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseLowThresholdPacket.kt new file mode 100644 index 000000000000..584b0d688fa5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingGlucoseLowThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingGlucoseLowThresholdPacket(private val threshold: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.LowGlucoseAlarmThreshold.getRequestData() + EversenseE3Writer.writeInt16(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response() : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveAlarmEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveAlarmEnabledPacket.kt new file mode 100644 index 000000000000..5ba9da6b3a81 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveAlarmEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveAlarmEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighAlarmEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighAlarmEnabledPacket.kt new file mode 100644 index 000000000000..22e013df1de7 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighAlarmEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveHighAlarmEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighThresholdPacket.kt new file mode 100644 index 000000000000..39d855be6ce5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveHighThresholdPacket(private val threshold: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighTarget.getRequestData() + EversenseE3Writer.writeInt16(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighTimePacket.kt new file mode 100644 index 000000000000..f03c112a6ec5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveHighTimePacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveHighTimePacket(private val minutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveHighTime.getRequestData() + byteArrayOf(minutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowAlarmEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowAlarmEnabledPacket.kt new file mode 100644 index 000000000000..aa8a7d9fa4b5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowAlarmEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveLowAlarmEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowThresholdPacket.kt new file mode 100644 index 000000000000..5782f2495cc1 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteTwoByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveLowThresholdPacket(private val threshold: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowTarget.getRequestData() + EversenseE3Writer.writeInt16(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowTimePacket.kt new file mode 100644 index 000000000000..1a15d4648c64 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingPredictiveLowTimePacket.kt @@ -0,0 +1,29 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingPredictiveLowTimePacket(private val minutes: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.PredictiveLowTime.getRequestData() + byteArrayOf(minutes.toByte()) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateEnabledPacket.kt new file mode 100644 index 000000000000..fbf7788b034f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingRateEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingEnabledPacket.kt new file mode 100644 index 000000000000..27f1db9867ab --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingRateFallingEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateFallingAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingThresholdPacket.kt new file mode 100644 index 000000000000..29bab7fa7319 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateFallingThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingRateFallingThresholdPacket(private val threshold: Double) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateFallingThreshold.getRequestData() + EversenseE3Writer.writeDouble(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingEnabledPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingEnabledPacket.kt new file mode 100644 index 000000000000..7f9083dca0fd --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingEnabledPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingRateRisingEnabledPacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateRisingAlert.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingThresholdPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingThresholdPacket.kt new file mode 100644 index 000000000000..0895f41b8461 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingRateRisingThresholdPacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingRateRisingThresholdPacket(private val threshold: Double) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.RateRisingThreshold.getRequestData() + EversenseE3Writer.writeDouble(threshold) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingVibratePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingVibratePacket.kt new file mode 100644 index 000000000000..d083c463719f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/SetSettingVibratePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.EversenseE3Memory +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e3.util.EversenseE3Writer + +@EversensePacket( + requestId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterCommandId, + responseId = EversenseE3Packets.WriteSingleByteSerialFlashRegisterResponseId, + typeId = 0, + securityType = EversenseSecurityType.None +) +class SetSettingVibratePacket(private val enabled: Boolean) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return EversenseE3Memory.VibrateMode.getRequestData() + EversenseE3Writer.writeBoolean(enabled) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Parser.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Parser.kt new file mode 100644 index 000000000000..a4736487c857 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Parser.kt @@ -0,0 +1,77 @@ +package com.nightscout.eversense.packets.e3.util + +import java.util.Calendar +import java.util.TimeZone + +class EversenseE3Parser { + companion object { + fun readDate(data: UByteArray, start: Int): Long { + require(data.size >= start + 2) { "readDate: data too short (size=${data.size}, start=$start)" } + val lowByte = data[start].toInt() + val highByte = data[start + 1].toInt() + + val day = lowByte and 31 + var month = lowByte shr 5 + val year = (highByte shr 1) + 2000 + + if (highByte and 1 == 1) { + month += 8 + } + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + calendar.set(Calendar.YEAR, year) + calendar.set(Calendar.MONTH, month - 1) + calendar.set(Calendar.DAY_OF_MONTH, day) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + + return calendar.timeInMillis + } + + fun readTime(data: UByteArray, start: Int): Long { + require(data.size >= start + 2) { "readTime: data too short (size=${data.size}, start=$start)" } + val lowByte = data[start].toInt() + val highByte = data[start + 1].toInt() + + val hour = highByte shr 3 + val minute = ((highByte and 7) shl 3) or (lowByte shr 5) + val second = (lowByte and 31) * 2 + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + calendar.set(Calendar.YEAR, 1970) + calendar.set(Calendar.MONTH, 0) + calendar.set(Calendar.DAY_OF_MONTH, 1) + calendar.set(Calendar.HOUR_OF_DAY, hour) + calendar.set(Calendar.MINUTE, minute) + calendar.set(Calendar.SECOND, second) + calendar.set(Calendar.MILLISECOND, 0) + + return calendar.timeInMillis + } + + /** + * Reads a timezone offset from 3 bytes: 2 bytes of time (HH:MM encoded) + 1 sign byte. + * Returns the offset in milliseconds (positive = east of UTC, negative = west). + */ + fun readTimezone(data: UByteArray, start: Int): Long { + require(data.size >= start + 3) { "readTimezone: data too short (size=${data.size}, start=$start)" } + val lowByte = data[start].toInt() + val highByte = data[start + 1].toInt() + + val hour = highByte shr 3 + val minute = ((highByte and 7) shl 3) or (lowByte shr 5) + val offsetMs = (hour * 60L + minute) * 60L * 1000L + + return if (data[start + 2] != 0.toUByte()) -offsetMs else offsetMs + } + + fun readGlucose(data: UByteArray, start: Int): Int { + require(data.size >= start + 2) { "readGlucose: data too short (size=${data.size}, start=$start)" } + val lowByte = data[start].toInt() and 0xFF + val highByte = (data[start + 1].toInt() and 0xFF) shl 8 + return lowByte or highByte + } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Writer.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Writer.kt new file mode 100644 index 000000000000..49cf9ce74539 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e3/util/EversenseE3Writer.kt @@ -0,0 +1,60 @@ +package com.nightscout.eversense.packets.e3.util +import java.util.Calendar +import java.util.TimeZone +import kotlin.math.abs +class EversenseE3Writer { + companion object { + fun generateChecksumCRC16(data: ByteArray): ByteArray { + var crc = 0xFFFF + for (byte in data) { + var currentByte = byte.toInt() and 0xFF + repeat(8) { + val xor = ((crc shr 15) and 0x01) xor ((currentByte shr 7) and 0x01) + crc = (crc shl 1) and 0xFFFF + if (xor != 0) { + crc = (crc xor 0x1021) and 0xFFFF + } + currentByte = (currentByte shl 1) and 0xFF + } + } + return writeInt16(crc) + } + fun writeDate(timestamp: Long): ByteArray { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + calendar.setTimeInMillis(timestamp) + val year = calendar.get(Calendar.YEAR) - 2000 + val month = calendar.get(Calendar.MONTH) + 1 + val day = calendar.get(Calendar.DAY_OF_MONTH) + val byte1 = (month shl 5) or day + val byte2 = (year shl 1) or (if (month >= 8) 1 else 0) + return byteArrayOf(byte1.toByte(), byte2.toByte()) + } + fun writeTime(timestamp: Long): ByteArray { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + calendar.setTimeInMillis(timestamp) + val hour = calendar.get(Calendar.HOUR_OF_DAY) + val minute = calendar.get(Calendar.MINUTE) + val second = calendar.get(Calendar.SECOND) + val byte1 = ((minute and 7) shl 5) or (second / 2) + val byte2 = (hour shl 3) or ((minute and 56) shr 3) + return byteArrayOf(byte1.toByte(), byte2.toByte()) + } + fun writeTimezone(timestamp: Long): ByteArray { + val timezoneOffset = TimeZone.getDefault().getOffset(timestamp) + val timezoneNegative = if (timezoneOffset < 0) 255 else 0 + return writeTime(abs(timezoneOffset).toLong()) + byteArrayOf(timezoneNegative.toByte()) + } + fun writeBoolean(value: Boolean): ByteArray { + return byteArrayOf(if (value) 0x55 else 0x00) + } + fun writeDouble(value: Double): ByteArray { + return writeInt16((value * 10).toInt()) + } + fun writeInt16(value: Int): ByteArray { + return byteArrayOf( + value.toByte(), + (value shr 8).toByte() + ) + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthIdentityPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthIdentityPacket.kt new file mode 100644 index 000000000000..a3185b6a2c29 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthIdentityPacket.kt @@ -0,0 +1,28 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.AuthenticateCommandId, + responseId = Eversense365Packets.AuthenticateResponseId, + typeId = Eversense365Packets.AuthenticateIdentity, + securityType = EversenseSecurityType.SecureV2 +) +class AuthIdentityPacket(val secret: ByteArray) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return secret + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response() + } + + class Response: EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthStartPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthStartPacket.kt new file mode 100644 index 000000000000..19aa855b2690 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthStartPacket.kt @@ -0,0 +1,32 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.AuthenticateCommandId, + responseId = Eversense365Packets.AuthenticateResponseId, + typeId = Eversense365Packets.AuthenticateStart, + securityType = EversenseSecurityType.SecureV2 +) +class AuthStartPacket(val secret: ByteArray) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return secret + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response( + receivedData.copyOfRange(2, 66).toByteArray() + ) + } + + data class Response( + val sessionPublicKey: ByteArray + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthWhoAmIPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthWhoAmIPacket.kt new file mode 100644 index 000000000000..8f69f6c3b026 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/AuthWhoAmIPacket.kt @@ -0,0 +1,36 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.AuthenticateCommandId, + responseId = Eversense365Packets.AuthenticateResponseId, + typeId = Eversense365Packets.AuthenticateWhoAmI, + securityType = EversenseSecurityType.SecureV2 +) +class AuthWhoAmIPacket(private val clientId: ByteArray) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return clientId + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response( + receivedData.copyOfRange(2, 34).toByteArray(), + receivedData.copyOfRange(34, 42).toByteArray(), + ((receivedData[42].toInt() shl 8) or receivedData[43].toInt()) == 0, + ) + } + + data class Response( + val serialNumber: ByteArray, + val nonce: ByteArray, + val flags: Boolean + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/EnterDiagnosticMode365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/EnterDiagnosticMode365Packet.kt new file mode 100644 index 000000000000..9b23ddb43bf7 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/EnterDiagnosticMode365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.OperationCommandId, + responseId = Eversense365Packets.OperationResponseId, + typeId = Eversense365Packets.EnterDiagnosticModeOperationId, + securityType = EversenseSecurityType.SecureV2 +) +class EnterDiagnosticMode365Packet : EversenseBasePacket() { + override fun getRequestData(): ByteArray = ByteArray(0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Eversense365Packets.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Eversense365Packets.kt new file mode 100644 index 000000000000..644ac4b19fd6 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Eversense365Packets.kt @@ -0,0 +1,75 @@ +package com.nightscout.eversense.packets.e365 + +class Eversense365Packets { + companion object { + + const val AuthenticateCommandId = 0x09.toByte() + const val AuthenticateResponseId = 0x0B.toByte() + + const val ReadCommandId = 0x02.toByte() + const val ReadResponseId = 0x42.toByte() + + const val WriteCommandId = 0x03.toByte() + const val WriteResponseId = 0x43.toByte() + const val OperationCommandId = 0x01.toByte() + const val OperationResponseId = 0x41.toByte() + const val EnterDiagnosticModeOperationId = 0x08.toByte() + const val ExitDiagnosticModeOperationId = 0x09.toByte() + + const val NotificationResponseId = 0x44.toByte() + + const val AuthenticateWhoAmI = 0x01.toByte() + const val AuthenticateIdentity = 0x02.toByte() + const val AuthenticateStart = 0x03.toByte() + + const val ReadPing = 0x01.toByte() + const val ReadLogRangeOld = 0x09.toByte() + const val ReadSignalStrength = 0x1B.toByte() + const val ReadCalibrationInfo = 0x1D.toByte() + const val ReadGlucoseData = 0x1F.toByte() + const val ReadSensorInformation = 0x20.toByte() + const val ReadPatientInformation = 0x21.toByte() + const val ReadActiveAlerts = 0x22.toByte() + const val ReadLogRange = 0x38.toByte() + const val ReadLogValue = 0x3A.toByte() + + const val WriteCurrentDateTime = 0x01.toByte() + const val WriteCalibration = 0x0C.toByte() + const val WriteAppVersion = 0x0E.toByte() + const val WriteVibrateMode = 0x10.toByte() + const val WriteBleDisconnect = 0x11.toByte() + const val WritePredictionLowThreshold = 0x12.toByte() + const val WritePredictionHighThreshold = 0x13.toByte() + const val WriteRateFallingEnabled = 0x14.toByte() + const val WriteRateFallingThreshold = 0x15.toByte() + const val WriteRateRisingEnabled = 0x16.toByte() + const val WriteRateRisingThreshold = 0x17.toByte() + const val WritePredictionLowEnabled = 0x18.toByte() + const val WritePredictionLowTime = 0x19.toByte() + const val WritePredictionHighEnabled = 0x1A.toByte() + const val WritePredictionHighTime = 0x1B.toByte() + const val WriteHighGlucoseAlarmEnable = 0x1C.toByte() + const val WriteHighGlucoseAlarm = 0x1D.toByte() + const val WriteHighGlucoseAlarmRepeat = 0x1E.toByte() + const val WriteLowGlucoseAlarm = 0x1F.toByte() + const val WriteLowGlucoseAlarmRepeat = 0x20.toByte() + + const val NotificationKeepAlive = 0x02.toByte() + const val NotificationAlarmWithData = 0x03.toByte() + + const val ReadLogsId = 0x62.toByte() + + const val LogTypeAlerts: Byte = 0 + const val LogTypeCalibrations: Byte = 6 + const val LogTypeGlucose: Byte = 13 + + + fun isNotificationPacket(value: Byte): Boolean { + return value == NotificationResponseId + } + + fun isKeepAlivePacket(value1: Byte, value2: Byte): Boolean { + return value1 == NotificationResponseId && value2 == NotificationKeepAlive + } + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/ExitDiagnosticMode365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/ExitDiagnosticMode365Packet.kt new file mode 100644 index 000000000000..8e0bee3a33d8 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/ExitDiagnosticMode365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.OperationCommandId, + responseId = Eversense365Packets.OperationResponseId, + typeId = Eversense365Packets.ExitDiagnosticModeOperationId, + securityType = EversenseSecurityType.SecureV2 +) +class ExitDiagnosticMode365Packet : EversenseBasePacket() { + override fun getRequestData(): ByteArray = ByteArray(0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetActiveAlarmsPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetActiveAlarmsPacket.kt new file mode 100644 index 000000000000..af54cfd3d306 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetActiveAlarmsPacket.kt @@ -0,0 +1,52 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseAlarm +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.models.ActiveAlarm +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.util.EversenseLogger + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadActiveAlerts, + securityType = EversenseSecurityType.SecureV2 +) +class GetActiveAlarmsPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = byteArrayOf() + + // 42 22 -> CmdType & CmdId + // 03 -> Active alarm count + // [code, flag, priority] * count -> 3 bytes per alarm + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val count = receivedData[2].toInt() and 0xFF + val alarms = mutableListOf() + + for (i in 0 until count) { + val offsetStart = i * 3 + 3 + if (receivedData.size < offsetStart + 3) { + EversenseLogger.warning("GetActiveAlarmsPacket", "Missing data for alarm $i") + break + } + alarms.add(ActiveAlarm( + code = EversenseAlarm.from(receivedData[offsetStart].toInt() and 0xFF), + codeRaw = receivedData[offsetStart].toInt() and 0xFF, + flag = receivedData[offsetStart + 1].toInt() and 0xFF, + priority = receivedData[offsetStart + 2].toInt() and 0xFF + )) + } + + alarms.sortBy { it.priority } + EversenseLogger.info("GetActiveAlarmsPacket", "Active alarms: ${alarms.map { it.code.title }}") + return Response(count = count, alarms = alarms) + } + + data class Response( + val count: Int, + val alarms: List + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetAlertsLogValuesPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetAlertsLogValuesPacket.kt new file mode 100644 index 000000000000..b2b291def216 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetAlertsLogValuesPacket.kt @@ -0,0 +1,64 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseAlarm +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix +import com.nightscout.eversense.util.EversenseLogger + +data class AlertHistoryItem( + val datetime: Long, + val code: EversenseAlarm +) + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadLogsId, + typeId = Eversense365Packets.ReadLogValue, + securityType = EversenseSecurityType.SecureV2 +) +class GetAlertsLogValuesPacket( + private val from: Int, + private val to: Int +) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return byteArrayOf( + Eversense365Packets.LogTypeAlerts, + (from and 0xFF).toByte(), ((from shr 8) and 0xFF).toByte(), + ((from shr 16) and 0xFF).toByte(), ((from shr 24) and 0xFF).toByte(), + (to and 0xFF).toByte(), ((to shr 8) and 0xFF).toByte(), + ((to shr 16) and 0xFF).toByte(), ((to shr 24) and 0xFF).toByte() + ) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + if (receivedData[6].toInt() != Eversense365Packets.LogTypeAlerts.toInt()) { + EversenseLogger.error("GetAlertsLogValuesPacket", "Invalid log type: ${receivedData[6]}") + return Response(count = 0, alertHistory = emptyList()) + } + + val actualData = receivedData.drop(7).toUByteArray() + val recordLength = 60 + val history = mutableListOf() + var i = 0 + + while (i + recordLength <= actualData.size) { + val chunk = actualData.copyOfRange(i, i + recordLength) + val datetime = chunk.copyOfRange(4, 12).toUnix() + val alarmCode = chunk[12].toInt() and 0xFF + history.add(AlertHistoryItem(datetime = datetime, code = EversenseAlarm.from(alarmCode))) + i += recordLength + } + + return Response(count = history.size, alertHistory = history) + } + + data class Response( + val count: Int, + val alertHistory: List + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationInfoPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationInfoPacket.kt new file mode 100644 index 000000000000..54d0d7447fdd --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationInfoPacket.kt @@ -0,0 +1,61 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.CalibrationMode +import com.nightscout.eversense.enums.CalibrationPhase +import com.nightscout.eversense.enums.CalibrationReadiness +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix +import com.nightscout.eversense.util.EversenseLogger + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadCalibrationInfo, + securityType = EversenseSecurityType.SecureV2 +) +class GetCalibrationInfoPacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + return byteArrayOf() + } + + // Parsed message: + // 42 1D -> CmdType & CmdId + // 00 -> Current calibration phase + // 06 -> Ready for calibration (CALIBRATION_READINESS) + // 00 00 00 00 00 00 00 00 -> Next calibration datetime + // 00 -> Number of calibrations per day + // 00 -> Number of calibrations in this Phase + // 00 00 -> Minutes allowed before next calibration due + // 00 00 -> Minutes allowed after next calibration due + // 00 00 -> Number of completed calibrations + // 00 00 00 00 00 00 00 00 -> Start datetime of current phase + // 00 00 -> Sensor lifetime + // 00 00 -> Warmup duration + // 00 00 -> Minutes until next calibration + // 00 00 00 00 00 00 00 00 -> Last calibration datetime + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + val calPerDay = receivedData[12].toInt() + val rawReadiness = receivedData[3].toInt() and 0xFF + EversenseLogger.info("GetCalibrationInfoPacket", "Raw calibration readiness byte: $rawReadiness (0x${rawReadiness.toString(16)})") + return Response( + currentPhase = CalibrationPhase.from365(receivedData[2].toInt(), calPerDay), + calibrationReadiness = CalibrationReadiness.from365(rawReadiness), + calibrationMode = CalibrationMode.from365(calPerDay), + nextCalibration = receivedData.copyOfRange(4, 12).toUnix(), + lastCalibration = receivedData.copyOfRange(34, 42).toUnix(), + ) + } + + data class Response( + val currentPhase: CalibrationPhase, + val calibrationReadiness: CalibrationReadiness, + val calibrationMode: CalibrationMode, + val nextCalibration: Long, + val lastCalibration: Long + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationLogValuesPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationLogValuesPacket.kt new file mode 100644 index 000000000000..791cd147c8e1 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetCalibrationLogValuesPacket.kt @@ -0,0 +1,72 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.CalibrationFlag +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix +import com.nightscout.eversense.util.EversenseLogger + +data class CalibrationHistoryItem( + val datetime: Long, + val glucoseInMgDl: Int, + val flag: CalibrationFlag +) + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadLogsId, + typeId = Eversense365Packets.ReadLogValue, + securityType = EversenseSecurityType.SecureV2 +) +class GetCalibrationLogValuesPacket( + private val from: Int, + private val to: Int +) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return byteArrayOf( + Eversense365Packets.LogTypeCalibrations, + (from and 0xFF).toByte(), ((from shr 8) and 0xFF).toByte(), + ((from shr 16) and 0xFF).toByte(), ((from shr 24) and 0xFF).toByte(), + (to and 0xFF).toByte(), ((to shr 8) and 0xFF).toByte(), + ((to shr 16) and 0xFF).toByte(), ((to shr 24) and 0xFF).toByte() + ) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + if (receivedData[6].toInt() != Eversense365Packets.LogTypeCalibrations.toInt()) { + EversenseLogger.error("GetCalibrationLogValuesPacket", "Invalid log type: ${receivedData[6]}") + return Response(count = 0, calibrationHistory = emptyList()) + } + + val actualData = receivedData.drop(7).toUByteArray() + val recordLength = 56 + + // Offset: recordLength(4) + datetime(8) + FsStartEndFlag(2) + ProcessingDatetime(8) + // + SampleDatetime(8) + MmaFSDatetime(8) + DecisionDatetime(8) = 46 + val offsetGlucose = 4 + 8 + 2 + 8 + 8 + 8 + 8 + val offsetFlag = offsetGlucose + 2 + + val history = mutableListOf() + var i = 0 + + while (i + recordLength <= actualData.size) { + val chunk = actualData.copyOfRange(i, i + recordLength) + val datetime = chunk.copyOfRange(4, 12).toUnix() + val glucose = (chunk[offsetGlucose].toInt() and 0xFF) or ((chunk[offsetGlucose + 1].toInt() and 0xFF) shl 8) + val flag = CalibrationFlag.from(chunk[offsetFlag].toInt() and 0xFF) + history.add(CalibrationHistoryItem(datetime = datetime, glucoseInMgDl = glucose, flag = flag)) + i += recordLength + } + + return Response(count = history.size, calibrationHistory = history) + } + + data class Response( + val count: Int, + val calibrationHistory: List + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseDataPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseDataPacket.kt new file mode 100644 index 000000000000..6ea0e670ab12 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseDataPacket.kt @@ -0,0 +1,84 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toInt +import com.nightscout.eversense.util.EversenseLogger +import com.nightscout.eversense.packets.e365.utils.toUnix + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadGlucoseData, + securityType = EversenseSecurityType.SecureV2 +) +class GetGlucoseDataPacket(private val sensorIdLen: Int) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return byteArrayOf() + } + + // 42 1F -> CmdType & CmdId + // F6 95 86 CB C1 00 00 00 -> Current datetime + // 00 -> Sensor type + // 0A -> Sensor ID length + // 00 00 00 00 00 00 00 00 00 00 -> Sensor ID + // 00 18 82 cb c1 00 00 00 -> Most recent glucose datetime + // bc 00 -> Most recent glucose value + // 32 00 -> Signal strength (transmitter-to-sensor, little-endian UInt16) + // 00 00 -> Glucose unavailable reason + // ... measurement bytes ... + // 04 -> Trend direction + // 07 -> Battery percentage + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + var sensorIdLen = receivedData[11].toInt() + if (sensorIdLen == 0x00) { + sensorIdLen = this.sensorIdLen + } + + // Signal strength at bytes 22+sensorIdLen (little-endian UInt16) + val signalRaw = (receivedData[22 + sensorIdLen].toInt() and 0xFF) or + ((receivedData[23 + sensorIdLen].toInt() and 0xFF) shl 8) + + EversenseLogger.info("GetGlucoseDataPacket", "Sensor signal strength raw: $signalRaw") + + val sensorId = receivedData.copyOfRange(12, 12 + sensorIdLen) + .toByteArray().joinToString("") { "%02x".format(it) } + val rawHex = receivedData.toByteArray().joinToString("") { "%02x".format(it) } + + return Response( + datetime = receivedData.copyOfRange(12 + sensorIdLen, 20 + sensorIdLen).toUnix(), + glucoseInMgDl = receivedData.copyOfRange(20 + sensorIdLen, 22 + sensorIdLen).toInt(), + trend = getTrend(receivedData[164 + sensorIdLen].toInt()), + signalStrength = signalRaw, + sensorId = sensorId, + rawResponseHex = rawHex + ) + } + + private fun getTrend(value: Int): EversenseTrendArrow { + return when (value) { + 1 -> EversenseTrendArrow.SINGLE_DOWN + 2 -> EversenseTrendArrow.FORTY_FIVE_DOWN + 4 -> EversenseTrendArrow.FLAT + 8 -> EversenseTrendArrow.FORTY_FIVE_UP + 16 -> EversenseTrendArrow.SINGLE_UP + 32 -> EversenseTrendArrow.SINGLE_DOWN + 64 -> EversenseTrendArrow.SINGLE_UP + else -> EversenseTrendArrow.NONE + } + } + + data class Response( + val datetime: Long, + val glucoseInMgDl: Int, + val trend: EversenseTrendArrow, + val signalStrength: Int, + val sensorId: String = "", + val rawResponseHex: String = "" + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseLogValuesPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseLogValuesPacket.kt new file mode 100644 index 000000000000..1095b16fb0f0 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetGlucoseLogValuesPacket.kt @@ -0,0 +1,84 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.models.GlucoseHistoryItem +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix +import com.nightscout.eversense.util.EversenseLogger + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = 0x62.toByte(), + typeId = Eversense365Packets.ReadLogValue, + securityType = EversenseSecurityType.SecureV2 +) +class GetGlucoseLogValuesPacket( + private val from: Int, + private val to: Int, + private val sensorIdLength: Int = 10 +) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + val logType: Byte = 13 // LogTypes.Glucose + return byteArrayOf( + logType, + (from and 0xFF).toByte(), ((from shr 8) and 0xFF).toByte(), + ((from shr 16) and 0xFF).toByte(), ((from shr 24) and 0xFF).toByte(), + (to and 0xFF).toByte(), ((to shr 8) and 0xFF).toByte(), + ((to shr 16) and 0xFF).toByte(), ((to shr 24) and 0xFF).toByte() + ) + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + if (receivedData[6].toInt() != 13) { + EversenseLogger.error("GetGlucoseLogValuesPacket", "Invalid log type: ${receivedData[6]}") + return Response(count = 0, glucoseHistory = emptyList()) + } + + val actualData = receivedData.drop(7).toUByteArray() + val recordLength = 193 + val offsetGlucose = sensorIdLength + 8 + 4 + val offsetTrend = offsetGlucose + 2 + 4 + val history = mutableListOf() + var i = 0 + + while (i + recordLength <= actualData.size) { + val chunk = actualData.copyOfRange(i, i + recordLength) + val datetime = chunk.copyOfRange(4, 12).toUnix() + val glucose = (chunk[offsetGlucose].toInt() and 0xFF) or + ((chunk[offsetGlucose + 1].toInt() and 0xFF) shl 8) + val trend = getTrend(chunk[offsetTrend].toInt() and 0xFF) + i += recordLength + + if (glucose >= 0x03E8) { + EversenseLogger.warning("GetGlucoseLogValuesPacket", "Glucose exceeds limits: $glucose — skipping") + continue + } + val rawHex = chunk.joinToString("") { "%02X".format(it.toInt()) } + history.add(GlucoseHistoryItem(valueInMgDl = glucose, datetime = datetime, trend = trend, rawResponseHex = rawHex)) + } + + EversenseLogger.info("GetGlucoseLogValuesPacket", "History records: ${history.size}") + return Response(count = history.size, glucoseHistory = history) + } + + private fun getTrend(value: Int): EversenseTrendArrow = when (value) { + 1 -> EversenseTrendArrow.SINGLE_DOWN + 2 -> EversenseTrendArrow.FORTY_FIVE_DOWN + 4 -> EversenseTrendArrow.FLAT + 8 -> EversenseTrendArrow.FORTY_FIVE_UP + 16 -> EversenseTrendArrow.SINGLE_UP + 32 -> EversenseTrendArrow.SINGLE_DOWN + 64 -> EversenseTrendArrow.SINGLE_UP + else -> EversenseTrendArrow.FLAT + } + + data class Response( + val count: Int, + val glucoseHistory: List + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetLogRangePacket365.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetLogRangePacket365.kt new file mode 100644 index 000000000000..d66df1010fff --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetLogRangePacket365.kt @@ -0,0 +1,47 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +enum class LogType(val value: Byte) { + ALERTS(0), + CALIBRATIONS(6), + GLUCOSE(13) +} + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadLogRange, + securityType = EversenseSecurityType.SecureV2 +) +class GetLogRangePacket365(private val logType: LogType) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = byteArrayOf(logType.value) + + // 42 38 -> CmdType & CmdId + // XX -> log type + // 00 00 00 00 -> rangeFrom (UInt32 little-endian) + // 00 00 00 00 -> rangeTo (UInt32 little-endian) + override fun parseResponse(): Response? { + if (receivedData.size < 11) return null + + val rangeFrom = ((receivedData[3].toLong() and 0xFF) or + ((receivedData[4].toLong() and 0xFF) shl 8) or + ((receivedData[5].toLong() and 0xFF) shl 16) or + ((receivedData[6].toLong() and 0xFF) shl 24)).toInt() + + val rangeTo = ((receivedData[7].toLong() and 0xFF) or + ((receivedData[8].toLong() and 0xFF) shl 8) or + ((receivedData[9].toLong() and 0xFF) shl 16) or + ((receivedData[10].toLong() and 0xFF) shl 24)).toInt() + + com.nightscout.eversense.util.EversenseLogger.info( + "GetLogRangePacket365", "Log range: $rangeFrom - $rangeTo" + ) + return Response(rangeFrom = rangeFrom, rangeTo = rangeTo) + } + + data class Response(val rangeFrom: Int, val rangeTo: Int) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetPatientSettingsPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetPatientSettingsPacket.kt new file mode 100644 index 000000000000..0392ceffd159 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetPatientSettingsPacket.kt @@ -0,0 +1,83 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toShort + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadPatientInformation, + securityType = EversenseSecurityType.SecureV2 +) +class GetPatientSettingsPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return byteArrayOf() + } + + override fun parseResponse(): Response? { + if (receivedData.size < 65) { + return null + } + + // Message parsed: + // 42 21 -> CmdType & CmdId + // 44 33 30 36 33 36 36 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 -> Transmitter name + // 38 2E 30 2E 34 00 00 00 00 00 00 00 00 00 00 00 -> Recent MMA Version + // 00 -> Is clinical mode enabled + // 00 -> Is do not Disturb Enabled + // 2C 01 -> BLE connect time in sec -> 300s + // 46 00 -> Low sugar target in mg/dl + // B4 00 -> High sugar target in mg/dl + // 00 -> Alarm rate falling enabled + // 19 -> Alarm rate falling threshold + // 00 -> Alarm rate rising enabled + // 19 -> Alarm rate rising threshold + // 00 -> Alarm Predictive Low enabled + // 14 -> Alarm Predictive Low Time + // 00 -> Alarm Predictive High enabled + // 14 -> Alarm Predictive High Time + // 01 -> Alarm High Glucose enabled + // FA 00 -> Alarm High Glucose Threshold + // 1E -> Alarm High Glucose Repeat Interval + // 41 00 -> Alarm Low Glucose Threshold + // 0F -> Alarm Low Glucose Repeat Interval + // 34 -> Battery Temp Thresh Mode Change + // 44 -> Battery Temp Thresh Warn + return Response( + vibrateMode = receivedData[44].toInt() != 0x00, + highGlucoseEnabled = receivedData[59].toInt() != 0x00, + lowGlucoseAlarmInMgDl = receivedData.copyOfRange(60, 62).toShort().toInt(), + highGlucoseAlarmInMgDl = receivedData.copyOfRange(63, 65).toShort().toInt(), + predictionLowEnabled = receivedData[55].toInt() != 0x00, + predictionHighEnabled = receivedData[57].toInt() != 0x00, + predictionFallingInterval = receivedData[56].toInt(), + predictionRisingInterval = receivedData[58].toInt(), + predictionFallingThreshold = receivedData.copyOfRange(47, 49).toShort().toInt(), + predictionRisingThreshold = receivedData.copyOfRange(49, 51).toShort().toInt(), + rateFallingEnabled = receivedData[51].toInt() != 0x00, + rateRisingEnabled = receivedData[53].toInt() != 0x00, + rateFallingThreshold = receivedData[52].toDouble() / 10, + rateRisingThreshold = receivedData[54].toDouble() / 10, + ) + } + + data class Response( + val vibrateMode: Boolean, + val highGlucoseEnabled: Boolean, + val lowGlucoseAlarmInMgDl: Int, + val highGlucoseAlarmInMgDl: Int, + val predictionLowEnabled: Boolean, + val predictionHighEnabled: Boolean, + val predictionFallingInterval: Int, + val predictionRisingInterval: Int, + val predictionFallingThreshold: Int, + val predictionRisingThreshold: Int, + val rateFallingEnabled: Boolean, + val rateRisingEnabled: Boolean, + val rateFallingThreshold: Double, + val rateRisingThreshold: Double + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSensorInformationPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSensorInformationPacket.kt new file mode 100644 index 000000000000..c4566793339b --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSensorInformationPacket.kt @@ -0,0 +1,90 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix +import com.nightscout.eversense.packets.e365.utils.toUtfString + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadSensorInformation, + securityType = EversenseSecurityType.SecureV2 +) +class GetSensorInformationPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + return byteArrayOf() + } + + // 42 20 + // 33 30 36 33 36 36 00 00 00 00 00 00 00 00 00 00 + // 44 33 30 36 33 36 36 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + // 26 9f 14 b7 c4 00 00 00 + // 29 6d 23 06 + // 30352e30302e30312e30374d000030312e30360030312e30300030312e30300030312e30300000000111008c012c01010a7900000000000076000083d959f3c30000006d01790000000000007600005730352e30302e30312e30374d2d303600000000000000000030312e30302e30312e30320000000000 + // Message parsed: + // 42 20 -> CmdType & CmdId + // 33 30 36 33 36 36 00 00 00 00 00 00 00 00 00 00 -> Serial number + // 44 33 30 36 33 36 36 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 -> Transmitter name + // 1A 44 08 CB BF 00 00 00 -> Current datetime + // 29 6D 23 06 -> Transmitter model + // 30 35 2E 30 30 2E 30 31 2E 30 37 4D 00 00 -> Current firmware version + // 30 31 2E 30 36 00 -> Comm version + // 30 31 2E 30 30 00 -> Register map version + // 30 31 2E 30 30 00 -> Log map version + // 30 31 2E 30 30 00 -> Push map version + // 00 -> Glucose algorithm version major + // 00 -> Glucose algorithm version minor + // 01 -> MMA functionality + // 11 00 -> Transmitter mode + // 8C 01 -> Transmitter life remaining + // 2C 01 -> Sensor sample interval + // 01 -> Sensor type + // 0A -> Sensor ID length + // 00 00 00 00 00 00 00 00 00 00 -> Sensor ID (size is based on previous value) + // 00 00 00 00 00 00 00 00 -> Sensor insertion date + // 00 00 -> Sensor life remaining + // 00 00 00 00 00 00 00 00 00 00 -> Detected sensor ID (length is based on Sensor ID length) + // 62 -> Battery percentage + // 30 35 2E 30 30 2E 30 31 2E 30 37 4D 2D 30 36 00 -> Firmware version + // 00 00 00 00 00 00 00 00 -> Operation start datetime + // 30 31 2E 30 30 2E 30 31 2E 30 32 00 00 00 00 00 -> Other firmware version + override fun parseResponse(): Response? { + if (receivedData.size < 104) { + return null + } + + val sensorIdLength = receivedData[103].toInt() + val sensorIdDoubleLength = 2 * sensorIdLength + if (receivedData.size < 139 + sensorIdDoubleLength) { + return null + } + return Response( + serialNumber = receivedData.copyOfRange(2, 18).toUtfString(), + transmitterName = receivedData.copyOfRange(18, 43).toUtfString(), + transmitterDatetime = receivedData.copyOfRange(43, 51).toUnix(), + insertionDate = receivedData.copyOfRange(104+sensorIdLength, 112+sensorIdLength).toUnix(), + mmaFeatures = receivedData[95].toInt(), + batteryLevel = receivedData[114+sensorIdDoubleLength].toInt(), + version = receivedData.copyOfRange(115+sensorIdDoubleLength, 131+sensorIdDoubleLength).toUtfString(), + extVersion = receivedData.copyOfRange(131+sensorIdDoubleLength, 139+sensorIdDoubleLength).toUtfString(), + sensorIdLength = sensorIdLength, + communicationProtocolVersion = receivedData.copyOfRange(69, 75).toUtfString().toDouble() + ) + } + + data class Response( + val serialNumber: String, + val transmitterName: String, + val transmitterDatetime: Long, + val insertionDate: Long, + val mmaFeatures: Int, + val batteryLevel: Int, + val version: String, + val extVersion: String, + val sensorIdLength: Int, + val communicationProtocolVersion: Double + ) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSignalStrengthPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSignalStrengthPacket.kt new file mode 100644 index 000000000000..da3f33ba9863 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/GetSignalStrengthPacket.kt @@ -0,0 +1,63 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import java.nio.ByteBuffer +import java.nio.ByteOrder + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadSignalStrength, + securityType = EversenseSecurityType.SecureV2 +) +class GetSignalStrengthPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = byteArrayOf() + + // Parsed message: + // 42 1B -> CmdType & CmdId + // 01 -> SensorType + // 0A -> Sensor ID length + // 00 00 00 00 00 00 00 00 00 00 -> Sensor ID (length = byte[3]) + // XX XX XX XX XX XX XX XX -> Timestamp + // XX XX -> Signal Strength (raw) + // XX XX XX XX -> Signal Coupling (little-endian float, multiply by 100 for percentage) + // XX -> Placement + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) return null + + val hex = receivedData.joinToString(" ") { it.toString(16).padStart(2, '0') } + com.nightscout.eversense.util.EversenseLogger.info("GetSignalStrengthPacket", "Raw response: $hex") + + // Byte [3] = sensor ID length + val sensorIdLength = receivedData[3].toInt() and 0xFF + + // Signal coupling float starts at byte 14 + sensorIdLength + val couplingStart = 14 + sensorIdLength + if (receivedData.size < couplingStart + 4) { + com.nightscout.eversense.util.EversenseLogger.warning("GetSignalStrengthPacket", "Response too short: ${receivedData.size} bytes, need ${couplingStart + 4}") + return Response(signalStrength = 0) + } + + // Read little-endian float and multiply by 100 for percentage + val floatBytes = byteArrayOf( + receivedData[couplingStart].toByte(), + receivedData[couplingStart + 1].toByte(), + receivedData[couplingStart + 2].toByte(), + receivedData[couplingStart + 3].toByte() + ) + val signalFloat = ByteBuffer.wrap(floatBytes) + .order(ByteOrder.LITTLE_ENDIAN) + .float + val signalPercent = (signalFloat * 100).toInt().coerceIn(0, 100) + + com.nightscout.eversense.util.EversenseLogger.info("GetSignalStrengthPacket", "sensorIdLength: $sensorIdLength, signalFloat: $signalFloat -> $signalPercent%") + return Response(signalStrength = signalPercent) + } + + data class Response( + val signalStrength: Int // 0-100, transmitter-to-sensor placement signal + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/KeepAlivePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/KeepAlivePacket.kt new file mode 100644 index 000000000000..1886bac3efcd --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/KeepAlivePacket.kt @@ -0,0 +1,30 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix + +@EversensePacket( + requestId = -1, // Can only be received + responseId = Eversense365Packets.NotificationResponseId, + typeId = Eversense365Packets.NotificationKeepAlive, + securityType = EversenseSecurityType.SecureV2 +) +class KeepAlivePacket : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + return byteArrayOf() + } + + override fun parseResponse(): Response? { + if (receivedData.isEmpty()) { + return null + } + + return Response( + glucoseDatetime = receivedData.copyOfRange(11, 19).toUnix(), + ) + } + + data class Response(val glucoseDatetime: Long) : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Ping365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Ping365Packet.kt new file mode 100644 index 000000000000..d22ac73aa63d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/Ping365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.ReadCommandId, + responseId = Eversense365Packets.ReadResponseId, + typeId = Eversense365Packets.ReadPing, + securityType = EversenseSecurityType.SecureV2 +) +class Ping365Packet : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf() + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushAlarmWithDataPacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushAlarmWithDataPacket.kt new file mode 100644 index 000000000000..ade5e3719d4d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushAlarmWithDataPacket.kt @@ -0,0 +1,48 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseAlarm +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.models.ActiveAlarm +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix + +/** + * Push notification packet for alarms with data payload. + * + * Packet format: + * [0] = 0x44 (NotificationResponseId) + * [1] = 0x03 (AlarmWithData) + * [2] = reserved + * [3] = alarm code + * [4..11] = alarm datetime (Unix2000) + * [12..] = alarm data + */ +@EversensePacket( + requestId = Eversense365Packets.NotificationResponseId, + responseId = Eversense365Packets.NotificationAlarmWithData, + typeId = 0, + securityType = EversenseSecurityType.SecureV2 +) +class PushAlarmWithDataPacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.size < 12) return null + + val alarmCode = receivedData[3].toInt() and 0xFF + val alarm = EversenseAlarm.from(alarmCode) + val datetime = receivedData.copyOfRange(4, 12).toUnix() + + return Response( + alarm = ActiveAlarm(code = alarm, codeRaw = alarmCode, flag = 0, priority = 0), + datetime = datetime + ) + } + + data class Response( + val alarm: ActiveAlarm, + val datetime: Long + ) : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushKeepAlive365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushKeepAlive365Packet.kt new file mode 100644 index 000000000000..c65c7a9e0188 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/PushKeepAlive365Packet.kt @@ -0,0 +1,50 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnix + +/** + * Push keep-alive packet that includes battery level and most recent glucose datetime. + * + * Offsets: + * [10] = battery level (raw enum value) + * [11..18] = most recent glucose datetime (Unix2000) + */ +@EversensePacket( + requestId = Eversense365Packets.NotificationResponseId, + responseId = Eversense365Packets.NotificationKeepAlive, + typeId = 0, + securityType = EversenseSecurityType.SecureV2 +) +class PushKeepAlive365Packet : EversenseBasePacket() { + + override fun getRequestData(): ByteArray = ByteArray(0) + + override fun parseResponse(): Response? { + if (receivedData.size < 19) return null + + val batteryLevel = receivedData[OFFSET_BATTERY_LEVEL].toInt() and 0xFF + val glucoseDatetime = receivedData.copyOfRange( + OFFSET_GLUCOSE_DATETIME_START, + OFFSET_GLUCOSE_DATETIME_END + ).toUnix() + + return Response( + batteryLevelRaw = batteryLevel, + mostRecentGlucoseDatetime = glucoseDatetime + ) + } + + data class Response( + val batteryLevelRaw: Int, + val mostRecentGlucoseDatetime: Long + ) : EversenseBasePacket.Response() + + companion object { + private const val OFFSET_BATTERY_LEVEL = 10 + private const val OFFSET_GLUCOSE_DATETIME_START = 11 + private const val OFFSET_GLUCOSE_DATETIME_END = 19 + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetAppVersion365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetAppVersion365Packet.kt new file mode 100644 index 000000000000..1387653e3467 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetAppVersion365Packet.kt @@ -0,0 +1,24 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteAppVersion, + securityType = EversenseSecurityType.SecureV2 +) +class SetAppVersion365Packet(private val appVersion: String = "8.0.4") : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + val data = ByteArray(18) + val versionBytes = appVersion.toByteArray(Charsets.US_ASCII) + versionBytes.copyInto(data, 0, 0, minOf(versionBytes.size, 18)) + return data + } + + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBleDisconnect365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBleDisconnect365Packet.kt new file mode 100644 index 000000000000..fb2c32535f7d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBleDisconnect365Packet.kt @@ -0,0 +1,25 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteBleDisconnect, + securityType = EversenseSecurityType.SecureV2 +) +class SetBleDisconnect365Packet(private val intervalSeconds: Int = 300) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + // UInt16 little-endian interval in seconds + return byteArrayOf( + (intervalSeconds and 0xFF).toByte(), + ((intervalSeconds shr 8) and 0xFF).toByte() + ) + } + + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBloodGlucosePointPacket365.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBloodGlucosePointPacket365.kt new file mode 100644 index 000000000000..5fa3bbf88d7f --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetBloodGlucosePointPacket365.kt @@ -0,0 +1,32 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toUnixArray + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteCalibration, + securityType = EversenseSecurityType.SecureV2 +) +class SetBloodGlucosePointPacket365(private val glucoseInMgDl: Int, private val timestampMs: Long = System.currentTimeMillis()) : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + var data = timestampMs.toUnixArray() // fingerstick measurement timestamp + data += System.currentTimeMillis().toUnixArray() // current time + data += byteArrayOf( + (glucoseInMgDl and 0xFF).toByte(), + ((glucoseInMgDl shr 8) and 0xFF).toByte() + ) + data += byteArrayOf(1, 0, 0) + return data + } + + override fun parseResponse(): Response { + return Response() + } + + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetCurrentDateTimePacket.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetCurrentDateTimePacket.kt new file mode 100644 index 000000000000..eb73eac0f49d --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetCurrentDateTimePacket.kt @@ -0,0 +1,38 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import com.nightscout.eversense.packets.e365.utils.toByteArray +import com.nightscout.eversense.packets.e365.utils.toTimeZone +import com.nightscout.eversense.packets.e365.utils.toUnixArray +import java.time.ZonedDateTime +import java.util.TimeZone +import kotlin.math.abs + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteCurrentDateTime, + securityType = EversenseSecurityType.SecureV2 +) +class SetCurrentDateTimePacket : EversenseBasePacket() { + + override fun getRequestData(): ByteArray { + val now = System.currentTimeMillis() + val timezoneOffset = TimeZone.getDefault().getOffset(now) + val timezoneNegative = if (timezoneOffset < 0) 255.toByte() else 0.toByte() + + var request = now.toUnixArray() + request += abs(timezoneOffset).toTimeZone() + request += byteArrayOf(timezoneNegative) + + return request + } + + override fun parseResponse(): Response { + return Response() + } + + class Response : EversenseBasePacket.Response() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarm365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarm365Packet.kt new file mode 100644 index 000000000000..3733676dd5e5 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarm365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteHighGlucoseAlarm, + securityType = EversenseSecurityType.SecureV2 +) +class SetHighGlucoseAlarm365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarmEnabled365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarmEnabled365Packet.kt new file mode 100644 index 000000000000..15531d2a72a6 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetHighGlucoseAlarmEnabled365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteHighGlucoseAlarmEnable, + securityType = EversenseSecurityType.SecureV2 +) +class SetHighGlucoseAlarmEnabled365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetLowGlucoseAlarm365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetLowGlucoseAlarm365Packet.kt new file mode 100644 index 000000000000..65076a5c3a76 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetLowGlucoseAlarm365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteLowGlucoseAlarm, + securityType = EversenseSecurityType.SecureV2 +) +class SetLowGlucoseAlarm365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighEnabled365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighEnabled365Packet.kt new file mode 100644 index 000000000000..e021423ff0ac --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighEnabled365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionHighEnabled, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionHighEnabled365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighThreshold365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighThreshold365Packet.kt new file mode 100644 index 000000000000..b14d9ec752eb --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighThreshold365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionHighThreshold, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionHighThreshold365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighTime365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighTime365Packet.kt new file mode 100644 index 000000000000..0eb5ed0c5621 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionHighTime365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionHighTime, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionHighTime365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowEnabled365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowEnabled365Packet.kt new file mode 100644 index 000000000000..260854f473af --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowEnabled365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionLowEnabled, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionLowEnabled365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowThreshold365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowThreshold365Packet.kt new file mode 100644 index 000000000000..e123847df0ec --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowThreshold365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionLowThreshold, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionLowThreshold365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowTime365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowTime365Packet.kt new file mode 100644 index 000000000000..96360e24253a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetPredictionLowTime365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WritePredictionLowTime, + securityType = EversenseSecurityType.SecureV2 +) +class SetPredictionLowTime365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingEnabled365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingEnabled365Packet.kt new file mode 100644 index 000000000000..a835b425c1fe --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingEnabled365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteRateFallingEnabled, + securityType = EversenseSecurityType.SecureV2 +) +class SetRateFallingEnabled365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingThreshold365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingThreshold365Packet.kt new file mode 100644 index 000000000000..d8128c827335 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateFallingThreshold365Packet.kt @@ -0,0 +1,23 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import java.nio.ByteBuffer +import java.nio.ByteOrder + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteRateFallingThreshold, + securityType = EversenseSecurityType.SecureV2 +) +class SetRateFallingThreshold365Packet(private val value: Double) : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + val buf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + buf.putFloat(value.toFloat()) + return buf.array() + } + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingEnabled365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingEnabled365Packet.kt new file mode 100644 index 000000000000..43044404ba47 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingEnabled365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteRateRisingEnabled, + securityType = EversenseSecurityType.SecureV2 +) +class SetRateRisingEnabled365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingThreshold365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingThreshold365Packet.kt new file mode 100644 index 000000000000..77bd1dca0ddf --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRateRisingThreshold365Packet.kt @@ -0,0 +1,23 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket +import java.nio.ByteBuffer +import java.nio.ByteOrder + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteRateRisingThreshold, + securityType = EversenseSecurityType.SecureV2 +) +class SetRateRisingThreshold365Packet(private val value: Double) : EversenseBasePacket() { + override fun getRequestData(): ByteArray { + val buf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + buf.putFloat(value.toFloat()) + return buf.array() + } + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatHighGlucose365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatHighGlucose365Packet.kt new file mode 100644 index 000000000000..3adc7ce29e57 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatHighGlucose365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteHighGlucoseAlarmRepeat, + securityType = EversenseSecurityType.SecureV2 +) +class SetRepeatHighGlucose365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatLowGlucose365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatLowGlucose365Packet.kt new file mode 100644 index 000000000000..a1d7a39a578e --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetRepeatLowGlucose365Packet.kt @@ -0,0 +1,20 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteLowGlucoseAlarmRepeat, + securityType = EversenseSecurityType.SecureV2 +) +class SetRepeatLowGlucose365Packet(private val value: Int) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf( + (value and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte() + ) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetVibrateMode365Packet.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetVibrateMode365Packet.kt new file mode 100644 index 000000000000..281fc2fb3760 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/SetVibrateMode365Packet.kt @@ -0,0 +1,17 @@ +package com.nightscout.eversense.packets.e365 + +import com.nightscout.eversense.enums.EversenseSecurityType +import com.nightscout.eversense.packets.EversenseBasePacket +import com.nightscout.eversense.packets.EversensePacket + +@EversensePacket( + requestId = Eversense365Packets.WriteCommandId, + responseId = Eversense365Packets.WriteResponseId, + typeId = Eversense365Packets.WriteVibrateMode, + securityType = EversenseSecurityType.SecureV2 +) +class SetVibrateMode365Packet(private val enabled: Boolean) : EversenseBasePacket() { + override fun getRequestData(): ByteArray = byteArrayOf(if (enabled) 1 else 0) + override fun parseResponse(): Response = Response() + class Response : EversenseBasePacket.Response() +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/utils/ByteArrayUtil.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/utils/ByteArrayUtil.kt new file mode 100644 index 000000000000..9e30a154edc0 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/packets/e365/utils/ByteArrayUtil.kt @@ -0,0 +1,70 @@ +package com.nightscout.eversense.packets.e365.utils + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.experimental.or + +fun UByteArray.toUtfString(): String { + val sb = StringBuilder() + for (i in this) { + if (i == 0.toUByte()) { + continue + } + + sb.append(i.toInt().toChar()) + } + + return sb.toString() +} + +fun Short.toByteArray(): ByteArray { + val allocate = ByteBuffer.allocate(2) + allocate.order(ByteOrder.LITTLE_ENDIAN) + allocate.putShort(this) + return allocate.array() +} + +fun Int.toTimeZone(): ByteArray { + val totalMinutes = this / (60 * 1000) + val hour = totalMinutes / 60 + val minute = totalMinutes % 60 + + val byte1: Byte = ((minute and 7) shl 5).toByte() + val byte2: Byte = ((hour shl 3) or ((minute and 56) shr 3) ).toByte() + + return byteArrayOf(byte1, byte2) +} + +fun UByteArray.toShort(): Short { + return ((this[0].toInt() and 0xFF) or ((this[1].toInt() and 0xFF) shl 8)).toShort() +} + +fun UByteArray.toInt(): Int { + return (this[0].toInt() and 0xFF) or ((this[1].toInt() and 0xFF) shl 8) +} + +fun UByteArray.toLong(): Long { + var result = 0L + for (i in indices) { + val shifted = (this[i].toLong() and 0xFF) shl (8 * i) + result = result or shifted + } + return result +} +fun ByteArray.toLong(): Long { + return this.toUByteArray().toLong() +} + +const val UNIX = 946_684_800_000 +fun UByteArray.toUnix(): Long { + val offset = this.toLong() / 1024 * 1000 + return UNIX + offset +} + +fun Long.toUnixArray(): ByteArray { + val offset = (this - UNIX) / 1000 * 1024 + + val allocate = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + allocate.putLong(offset) + return allocate.array() +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseCrypto365Util.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseCrypto365Util.kt new file mode 100644 index 000000000000..5752a0c95465 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseCrypto365Util.kt @@ -0,0 +1,303 @@ +package com.nightscout.eversense.util + +import android.content.SharedPreferences +import android.security.keystore.KeyProperties +import androidx.core.content.edit +import com.nightscout.eversense.models.EversenseSecureState +import com.nightscout.eversense.packets.e365.utils.toByteArray +import com.nightscout.eversense.packets.e365.utils.toLong +import kotlinx.serialization.json.Json +import org.bouncycastle.asn1.ASN1InputStream +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.DLSequence +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.engines.AESEngine +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.modes.CCMBlockCipher +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.util.BigIntegers +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.spec.PKCS8EncodedKeySpec +import javax.crypto.KeyAgreement +import kotlin.collections.toByteArray + +class EversenseCrypto365Util(val preference: SharedPreferences) { + private var ephemPrivate: ECPrivateKey? = null + private var ephemPublic: ECPublicKey? = null + private var ephemSalt: ByteArray? = null + + private var messageSequenceNumber = 1 + private var sessionKey: ByteArray? = null + + fun canUseShortcut(): Boolean { + return getState(preference).canUseShortcut + } + + fun allowUseShortcut() { + val state = getState(preference) + state.canUseShortcut = true + saveState(state, preference) + } + + fun disallowUseShortcut() { + val state = getState(preference) + state.canUseShortcut = false + saveState(state, preference) + } + + fun getClientPublicKey(): ByteArray { + val state = getState(preference) + return state.publicKey.hexToByteArray() + } + + fun generateKeyPairIfNotExists(): Boolean { + val state = getState(preference) + if (state.clientId.isNotEmpty() && state.publicKey.isNotEmpty() && state.privateKey.isNotEmpty()) { + EversenseLogger.debug(TAG, "Already generated keypair") + return true + } + + val keyPair = generatePrivateKeyPair() ?: return false + state.clientId = generateRandomBytes(32).toHexString() + state.privateKey = keyPair.private.encoded.toHexString() + state.publicKey = keyPair.public.encoded.toHexString() + + saveState(state, preference) + EversenseLogger.debug(TAG, "Generated keypair!") + return true + } + + fun getClientId(): ByteArray { + val state = getState(preference) + return state.clientId.hexToByteArray() + } + + fun getStartSecret(signature: ByteArray): ByteArray { + val public = ephemPublic?.encoded ?: return byteArrayOf() + val salt = ephemSalt ?: return byteArrayOf() + + var secret = byteArrayOf(128.toByte(), 0) + secret += getState(preference).clientId.hexToByteArray() + secret += public.copyOfRange(27, public.count()) + secret += salt + secret += signature + + return secret + } + + fun generateEphem(): ByteArray? { + val keyPair = generatePrivateKeyPair() ?:run { + EversenseLogger.error(TAG, "Failed to generate keypair...") + return null + } + + val privateKey = getState(preference).privateKey.hexToByteArray() + try { + val privateKey = KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(privateKey)) + val publicKey = keyPair.public.encoded + val v2Salt = generateRandomBytes(8) + + ephemPrivate = keyPair.private as ECPrivateKey + ephemPublic = keyPair.public as ECPublicKey + ephemSalt = v2Salt + + val data = publicKey.copyOfRange(27, publicKey.count()) + v2Salt + return ecdsaSign(privateKey, data) + } catch (e: Exception) { + e.printStackTrace() + EversenseLogger.error(TAG, "Got exception during generateEphem - exception: $e") + return null + } + } + + fun generateSessionKey(encodedPublicKey: ByteArray) { + try { + val salt = ephemSalt ?: return + + val ecPoint = ECPoint( + BigInteger(1, encodedPublicKey.copyOfRange(0, 32)), + BigInteger(1, encodedPublicKey.copyOfRange(32, 64)) + ) + val algorithmParameters = AlgorithmParameters.getInstance(KeyProperties.KEY_ALGORITHM_EC).run { + init(ECGenParameterSpec("secp256r1")) + getParameterSpec(ECParameterSpec::class.java) + } + + val publicKey = KeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_EC).generatePublic( + ECPublicKeySpec(ecPoint, algorithmParameters) + ) + + val sharedSecret = + KeyAgreement.getInstance("ECDH").run { + init(ephemPrivate) + doPhase(publicKey, true) + generateSecret() + } + + val symmetricKey = HKDFBytesGenerator(SHA256Digest()).run { + init(HKDFParameters(sharedSecret, null, salt)) + + val arr = ByteArray(16) + generateBytes(arr, 0, 16) + arr + } + + EversenseLogger.info(TAG, "SessionKey = ${symmetricKey.toHexString()}") + sessionKey = symmetricKey + } catch (e: Exception) { + e.printStackTrace() + EversenseLogger.error(TAG, "Failed to generate sessionKey: $e") + } + } + + fun encrypt(data: ByteArray): ByteArray { + val ephemSalt = ephemSalt ?:run { + EversenseLogger.error(TAG, "No salt available...") + return byteArrayOf() + } + + val sessionKey = sessionKey ?:run { + EversenseLogger.error(TAG, "No sessionKey available...") + return byteArrayOf() + } + + val i = (messageSequenceNumber and 0x3FFF).toLong() + val prefix = (i shl 2).toShort().toByteArray() + + val salt = generateEncryptionSalt(ephemSalt, i) + messageSequenceNumber++ + + val encryptedData = aeadCCM(salt, data, prefix, true, sessionKey) ?: return byteArrayOf() + return ByteBuffer.allocate(encryptedData.count() + 2).run { + put(prefix) + put(encryptedData) + array() + } + } + + fun decrypt(response: ByteArray): ByteArray { + val ephemSalt = ephemSalt ?:run { + EversenseLogger.error(TAG, "No salt available...") + return byteArrayOf() + } + + val sessionKey = sessionKey ?:run { + EversenseLogger.error(TAG, "No sessionKey available...") + return byteArrayOf() + } + + val cypherText = response.copyOfRange(2, response.size) + val prefix = response.copyOfRange(0, 2) + val i = (prefix.toLong() shr 2) and 0x3FFF + val salt = generateEncryptionSalt(ephemSalt, i) + + return aeadCCM(salt, cypherText, prefix, false, sessionKey) ?: byteArrayOf() + } + + companion object { + private const val TAG = "EversenseCrypto365Handler" + private val JSON = Json { ignoreUnknownKeys = true } + + private fun generateRandomBytes(i: Int): ByteArray { + val bArr = ByteArray(i) + SecureRandom().nextBytes(bArr) + return bArr + } + + private fun generatePrivateKeyPair(): KeyPair? { + try { + return KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC).run { + initialize(ECGenParameterSpec("secp256r1")) + generateKeyPair() + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "Error generating key pair: $e") + return null + } + } + + private fun ecdsaSign(privateKey: PrivateKey, data: ByteArray): ByteArray? { + try { + val signature = Signature.getInstance("SHA256withECDSA").run { + initSign(privateKey) + update(data) + sign() + } + + val dLSequence = ASN1InputStream(signature).readObject() as DLSequence + val value1 = (dLSequence.getObjectAt(0) as ASN1Integer).value + val value2 = (dLSequence.getObjectAt(1) as ASN1Integer).value + val bigInt1 = BigIntegers.asUnsignedByteArray(32, value1) + val bigInt2 = BigIntegers.asUnsignedByteArray(32, value2) + + return bigInt1 + bigInt2 + } catch(e: Exception) { + EversenseLogger.error(TAG, "Got exception during ecdsaSign - exception: $e") + return null + } + } + + private fun generateEncryptionSalt(salt: ByteArray, i: Long): ByteArray { + val wrapLong = ByteBuffer.wrap(salt).run { + order(ByteOrder.LITTLE_ENDIAN) + getLong() + } + + val wrapLongLong = ((wrapLong and (-16384)) or i) + val wrapByteArray = longToBytes(wrapLongLong) + return wrapByteArray.reversed().toByteArray() + } + + private fun getState(preference: SharedPreferences): EversenseSecureState { + val stateJson = preference.getString(StorageKeys.SECURE_STATE, null) ?: "{}" + return JSON.decodeFromString(stateJson) + } + + private fun saveState(state: EversenseSecureState, preference: SharedPreferences) { + preference.edit(commit = true) { + putString(StorageKeys.SECURE_STATE, JSON.encodeToString(state)) + } + } + + private fun aeadCCM(salt: ByteArray, data: ByteArray, prefix: ByteArray, forEncryption: Boolean, sessionKey: ByteArray): ByteArray? { + try { + return CCMBlockCipher.newInstance(AESEngine.newInstance()).run { + init(forEncryption, + AEADParameters(KeyParameter(sessionKey), salt.count() * 8, salt, prefix) + ) + + val bArr5 = ByteArray(getOutputSize(data.count())) + doFinal(bArr5, processBytes(data, 0, data.count(), bArr5, 0)) + bArr5 + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "AEAD-CCM encryption/decryption error: $e"); + e.printStackTrace() + return null; + } + } + + private fun longToBytes(j: Long): ByteArray { + val allocate = ByteBuffer.allocate(8) + allocate.putLong(j) + return allocate.array() + } + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttp365Util.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttp365Util.kt new file mode 100644 index 000000000000..9b63847e6078 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttp365Util.kt @@ -0,0 +1,500 @@ +package com.nightscout.eversense.util + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import androidx.core.content.edit +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseSecureState +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class EversenseHttp365Util { + companion object { + private val TAG = "EversenseHttp365Util" + private val JSON = Json { ignoreUnknownKeys = true } + + // OAuth2 client credentials embedded in the official Eversense Android app (publicly + // extractable from the APK). Not a personal secret — same value ships to all users. + private val CLIENT_ID = "eversenseMMAAndroid" // NOSONAR + private val CLIENT_SECRET = "6ksPx#]~wQ3U" // NOSONAR + private val CLIENT_NO = 2 + private val CLIENT_TYPE = 128 + + // Overridable for unit tests + internal var tokenBaseUrl = "https://usiamapi.eversensedms.com/" + internal var uploadBaseUrl = "https://usmobileappmsprod.eversensedms.com/" + internal var careBaseUrl = "https://usapialpha.eversensedms.com/" + + fun login(preference: SharedPreferences): LoginResponseModel? { + val state = getState(preference) + try { + val formBody = listOf( + "grant_type=password", + "client_id=$CLIENT_ID", + "client_secret=$CLIENT_SECRET", + "username=${URLEncoder.encode(state.username, "UTF-8")}", + "password=${URLEncoder.encode(state.password, "UTF-8")}" + ).joinToString("&") + + val url = URL("${tokenBaseUrl}connect/token") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + + OutputStreamWriter(conn.outputStream, "UTF-8").use { writer -> + writer.write(formBody) + writer.flush() + } + + val responseCode = conn.responseCode + if (responseCode >= 400) { + val errorBody = try { + conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + } catch (e: Exception) { "" } + EversenseLogger.error(TAG, "Login failed — status: $responseCode, body: $errorBody") + return null + } + + val dataJson = BufferedInputStream(conn.inputStream).use { stream -> + val buffer = ByteArrayOutputStream() + var data = stream.read() + while (data != -1) { + buffer.write(data) + data = stream.read() + } + buffer.toString() + } + + EversenseLogger.info(TAG, "Login success — status: $responseCode") + return Json.decodeFromString(LoginResponseModel.serializer(), dataJson) + } catch (e: Exception) { + EversenseLogger.error(TAG, "Got exception during login - exception: $e") + return null + } + } + + fun getFleetSecretV2(accessToken: String, serialNumber: ByteArray, nonce: ByteArray, flags: Boolean, publicKey: ByteArray): FleetSecretV2ResponseModel? { + try { + val publicKeyStr = Base64.getUrlEncoder().withoutPadding() + .encodeToString(publicKey.copyOfRange(27, publicKey.count())) + val serialNumberStr = + Base64.getUrlEncoder().withoutPadding().encodeToString(serialNumber) + val nonceStr = Base64.getUrlEncoder().withoutPadding().encodeToString(nonce) + val query = listOf( + "tx_flags=$flags", + "txSerialNumber=$serialNumberStr", + "nonce=$nonceStr", + "clientNo=$CLIENT_NO", + "clientType=$CLIENT_TYPE", + "kp_client_unique_id=$publicKeyStr" + ).joinToString("&") + + val url = + URL("https://deviceauthorization.eversensedms.com/api/vault/GetTxCertificate?$query") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $accessToken") + conn.connect() + + val bufferStream = BufferedInputStream(conn.inputStream) + val buffer = ByteArrayOutputStream() + var data = bufferStream.read() + while (data != -1) { + buffer.write(data) + data = bufferStream.read() + } + + val dataJson = buffer.toString() + + if (conn.responseCode >= 400) { + EversenseLogger.error(TAG, "Failed to do login - status: ${conn.responseCode}, data: $dataJson") + return null + } + + val response = Json.decodeFromString(FleetSecretV2ResponseModel.serializer(), dataJson) + if (response.Status != "Success" || response.Result.Certificate == null) { + EversenseLogger.error(TAG, "Received invalid response - message: $dataJson") + return null + } + + return response + } catch (e: Exception) { + EversenseLogger.error(TAG, "Failed to get fleetSecretV2 - exception: $e") + return null + } + } + + private val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + + fun getOrRefreshToken(preferences: SharedPreferences): String? { + val expiry = preferences.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0) + val cached = preferences.getString(StorageKeys.ACCESS_TOKEN, null) + // Use cached token if it has more than 5 minutes remaining + if (cached != null && System.currentTimeMillis() < expiry - 300_000L) { + return cached + } + // Re-login to get a fresh token + val fresh = login(preferences) ?: return null + val newExpiry = System.currentTimeMillis() + (fresh.expires_in * 1000L) + preferences.edit(commit = true) { + putString(StorageKeys.ACCESS_TOKEN, fresh.access_token) + putLong(StorageKeys.ACCESS_TOKEN_EXPIRY, newExpiry) + } + return fresh.access_token + } + + /** + * Upload glucose readings to the Eversense DMS cloud. + * Returns true if the server accepted the upload (HTTP 2xx), false on any error. + */ + fun uploadGlucoseReadings( + preferences: SharedPreferences, + readings: List, + transmitterSerialNumber: String, + firmwareVersion: String + ): Boolean { + if (readings.isEmpty()) return true + val token = getOrRefreshToken(preferences) ?: run { + EversenseLogger.error(TAG, "Cannot upload glucose — no valid access token") + return false + } + + return try { + // Only upload readings that have raw BLE data — readings without rawResponseHex are skipped. + val uploadable = readings.filter { it.rawResponseHex.isNotEmpty() } + if (uploadable.isEmpty()) { + EversenseLogger.info(TAG, "No readings with raw BLE data to upload — skipping") + return true + } + + EversenseLogger.info(TAG, "Uploading ${uploadable.size} reading(s) — TransmitterId='$transmitterSerialNumber'") + + // SensorId: the official app stores the first 8 bytes of the raw sensor ID in reversed + // byte order, uppercase — matching what the DMS portal indexes readings by. + // EssentialLog: base64-encoded bytes — the .NET server model uses System.Byte[] which + // JSON-serializes as base64 (despite the Android app sending "0x"+hex, the server rejects it). + // Body must be a bare JSON array — server deserializes directly to List + val jsonBody = uploadable.joinToString(prefix = "[", postfix = "]") { r -> + val portalSensorId = r.sensorId.chunked(2).take(8).reversed().joinToString("").uppercase() + val essentialLog = Base64.getEncoder().encodeToString( + r.rawResponseHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + ) + val ts = dateFormatter.format(Date(r.datetime)) + EversenseLogger.info(TAG, " Reading: sensorId='$portalSensorId' glucose=${r.glucoseInMgDl} ts=$ts rawHex=${r.rawResponseHex.length / 2}B") + """{"SensorId":"$portalSensorId","TransmitterId":"$transmitterSerialNumber","Timestamp":"$ts","CurrentGlucoseValue":${r.glucoseInMgDl},"CurrentGlucoseDateTime":"$ts","FWVersion":"$firmwareVersion","EssentialLog":"$essentialLog"}""" + } + + val url = URL("${uploadBaseUrl}api/v1.0/DiagnosticLog/PostEssentialLogs") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + + val writer = OutputStreamWriter(conn.outputStream, "UTF-8") + writer.write(jsonBody) + writer.flush() + writer.close() + conn.connect() + + val responseCode = conn.responseCode + if (responseCode >= 400) { + val error = try { conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" } catch (e: Exception) { "" } + EversenseLogger.error(TAG, "Glucose upload failed — status: $responseCode, body: $error") + false + } else { + val responseBody = try { conn.inputStream.readBytes().toString(Charsets.UTF_8) } catch (e: Exception) { "" } + EversenseLogger.info(TAG, "Glucose upload success — status: $responseCode, readings: ${uploadable.size}, response: $responseBody") + true + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "Glucose upload exception: $e") + false + } + } + + // Map signal strength percentage to SIGNAL_STRENGTH ordinal (from decompiled app) + // NO_SIGNAL=0, POOR=1, VERY_LOW=2, LOW=3, GOOD=4, EXCELLENT=5 + private fun signalStrengthOrdinal(percent: Int): Int = when { + percent >= 75 -> 5 + percent >= 48 -> 4 + percent >= 30 -> 3 + percent >= 28 -> 2 + percent >= 25 -> 1 + else -> 0 + } + + // Map EversenseTrendArrow to Eversense ARROW_TYPE ordinal (from decompiled app) + // STALE=0, FALLING_FAST=1, FALLING=2, FLAT=3, RISING=4, RISING_FAST=5 + private fun trendOrdinal(trend: EversenseTrendArrow): Int = when (trend) { + EversenseTrendArrow.NONE -> 0 + EversenseTrendArrow.SINGLE_DOWN -> 1 + EversenseTrendArrow.FORTY_FIVE_DOWN -> 2 + EversenseTrendArrow.FLAT -> 3 + EversenseTrendArrow.FORTY_FIVE_UP -> 4 + EversenseTrendArrow.SINGLE_UP -> 5 + } + + /** + * Post current glucose state to the Eversense DMS portal (api/care/PutCurrentValues). + * This updates "Last Sync Date" on the portal and feeds AGP reports. + * Returns true on HTTP 2xx, false on any error. + */ + fun putCurrentValues( + preferences: SharedPreferences, + glucose: Int, + timestamp: Long, + trend: EversenseTrendArrow, + signalStrength: Int, + batteryPercentage: Int + ): Boolean { + val token = getOrRefreshToken(preferences) ?: run { + EversenseLogger.error(TAG, "Cannot post current values — no valid access token") + return false + } + return try { + val ts = dateFormatter.format(Date(timestamp)) + val jsonBody = """{"CurrentGlucose":$glucose,"CGTime":"$ts","GlucoseTrend":${trendOrdinal(trend)},"SignalStrength":${signalStrengthOrdinal(signalStrength)},"BatteryStrength":${batteryPercentage.coerceAtLeast(0)},"IsTransmitterConnected":1}""" + + val url = URL("${careBaseUrl}api/care/PutCurrentValues") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + + OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(jsonBody); it.flush() } + + val responseCode = conn.responseCode + if (responseCode >= 400) { + val error = try { conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" } catch (e: Exception) { "" } + EversenseLogger.error(TAG, "PutCurrentValues failed — status: $responseCode, body: $error") + false + } else { + val responseBody = try { conn.inputStream.readBytes().toString(Charsets.UTF_8) } catch (e: Exception) { "" } + EversenseLogger.info(TAG, "PutCurrentValues success — status: $responseCode, glucose=$glucose, response: $responseBody") + true + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "PutCurrentValues exception: $e") + false + } + } + + /** + * Post device events (sensor glucose readings) to the Eversense DMS portal. + * This is the endpoint that populates the Sensor Glucose history table in the portal. + * Returns true on HTTP 2xx, false on any error. + * + * Binary format reverse-engineered from com.senseonics.util.AccountConstants in the + * decompiled official Eversense app. + */ + fun putDeviceEvents( + preferences: SharedPreferences, + readings: List, + transmitterSerialNumber: String + ): Boolean { + if (readings.isEmpty()) return true + val token = getOrRefreshToken(preferences) ?: run { + EversenseLogger.error(TAG, "Cannot post device events — no valid access token") + return false + } + return try { + val sensorId = readings.firstOrNull { it.sensorId.isNotEmpty() }?.sensorId ?: "" + val tzOffsetSec = TimeZone.getDefault().getOffset(Date().time) / 1000 + val offsetBytes = Base64.getEncoder().encodeToString(int32LE(tzOffsetSec)) + val sgBytes = buildSgBytes(readings) + val mgBytes = buildEmptyMgBytes() + val patientBytes = buildEmptyPatientBytes() + val alertBytes = buildAlertBytes(sensorId) + + EversenseLogger.info(TAG, "PutDeviceEvents: ${readings.size} reading(s), txId='$transmitterSerialNumber', sensorId='$sensorId'") + + val jsonBody = """{"deviceType":"SMSIMeter","deviceName":"Smart Transmitter (Android)","deviceID":"$transmitterSerialNumber","offsetBytes":"$offsetBytes","sgBytes":"$sgBytes","mgBytes":"$mgBytes","patientBytes":"$patientBytes","alertBytes":"$alertBytes","algorithmVersion":"10"}""" + + val url = URL("${careBaseUrl}api/care/PutDeviceEvents") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + + OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(jsonBody); it.flush() } + + val responseCode = conn.responseCode + if (responseCode >= 400) { + val error = try { conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" } catch (e: Exception) { "" } + EversenseLogger.error(TAG, "PutDeviceEvents failed — status: $responseCode, body: $error") + false + } else { + val responseBody = try { conn.inputStream.readBytes().toString(Charsets.UTF_8) } catch (e: Exception) { "" } + EversenseLogger.info(TAG, "PutDeviceEvents success — status: $responseCode, readings: ${readings.size}, response: $responseBody") + true + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "PutDeviceEvents exception: $e") + false + } + } + + // ─── Binary encoding helpers (from com.senseonics.bluetoothle.BinaryOperations) ────── + + private fun int16LE(v: Int): ByteArray = + byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte()) + + private fun int24LE(v: Int): ByteArray = + byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte()) + + private fun int32LE(v: Int): ByteArray = + byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte()) + + /** Encode a UTC timestamp as the 2-byte date format used by the Eversense transmitter binary protocol. */ + private fun calcDateBytes(tsMs: Long): ByteArray { + val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + cal.timeInMillis = tsMs + val year = cal.get(Calendar.YEAR) + val month = cal.get(Calendar.MONTH) + 1 // 1-12 + val day = cal.get(Calendar.DAY_OF_MONTH) + var b1 = (year - 2000) shl 1 + if (month > 7) b1 += 1 + val b0 = ((month and 7) shl 5) or day + return byteArrayOf(b0.toByte(), b1.toByte()) + } + + /** Encode a UTC timestamp as the 2-byte time format used by the Eversense transmitter binary protocol. */ + private fun calcTimeBytes(tsMs: Long): ByteArray { + val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")) + cal.timeInMillis = tsMs + val hour = cal.get(Calendar.HOUR_OF_DAY) + val minute = cal.get(Calendar.MINUTE) + val second = cal.get(Calendar.SECOND) + val b0 = ((minute and 7) shl 5) or (second / 2) + val b1 = (hour shl 3) or ((minute and 56) shr 3) + return byteArrayOf(b0.toByte(), b1.toByte()) + } + + /** + * Build the sgBytes base64 blob from a list of glucose readings. + * Format: header (8C 00 01 00 00 + 3-byte LE count) followed by one record per reading. + * Raw sensor ADC values are zeroed — only glucose, timestamp, and sensor ID are populated. + */ + private fun buildSgBytes(readings: List): String { + val baos = ByteArrayOutputStream() + // Header: 8C 00 01 00 00 + count (3 bytes LE) + baos.write(byteArrayOf(0x8C.toByte(), 0x00, 0x01, 0x00, 0x00)) + baos.write(int24LE(readings.size)) + readings.forEachIndexed { idx, r -> + val sensorIdBytes = if (r.sensorId.isNotEmpty()) + r.sensorId.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + else ByteArray(10) + baos.write(int24LE(idx + 1)) // record number (1-based) + baos.write(calcDateBytes(r.datetime)) // date (2 bytes) + baos.write(calcTimeBytes(r.datetime)) // time (2 bytes) + baos.write(int16LE(r.glucoseInMgDl)) // glucose (2 bytes LE) + baos.write(0x00) // padding + baos.write(sensorIdBytes) // sensor ID bytes + // RAW_DATA_INDEX 1, 2, 3, 7, 8 — zeroed (no raw ADC data available) + repeat(5) { baos.write(int16LE(0)) } + // Accel values (2 bytes) — zeroed + baos.write(int16LE(0)) + // AccelTemp (1 byte) — zeroed + baos.write(0x00) + // RAW_DATA_INDEX 4, 5, 6 — zeroed + repeat(3) { baos.write(int16LE(0)) } + } + return Base64.getEncoder().encodeToString(baos.toByteArray()) + } + + /** Build the mgBytes base64 blob with zero BGM/calibration records. */ + private fun buildEmptyMgBytes(): String { + // Header: 98 01 00 + count(2 bytes LE) + 00 → 0 records + val bytes = byteArrayOf(0x98.toByte(), 0x01, 0x00, 0x00, 0x00, 0x00) + return Base64.getEncoder().encodeToString(bytes) + } + + /** Build the patientBytes base64 blob with zero patient event records. */ + private fun buildEmptyPatientBytes(): String { + // Header: 9E 01 00 + count(2 bytes LE) → 0 events + val bytes = byteArrayOf(0x9E.toByte(), 0x01, 0x00, 0x00, 0x00) + return Base64.getEncoder().encodeToString(bytes) + } + + /** + * Build the alertBytes base64 blob with zero alert records. + * The header includes the raw sensor ID bytes followed by a zero-count field. + */ + private fun buildAlertBytes(sensorId: String): String { + // Header: 93 01 00 + count(2 bytes LE) + sensorIdBytes + 00 → 0 alerts + val baos = ByteArrayOutputStream() + baos.write(byteArrayOf(0x93.toByte(), 0x01, 0x00, 0x00, 0x00)) + if (sensorId.isNotEmpty()) { + val sensorIdBytes = sensorId.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + baos.write(sensorIdBytes) + } + baos.write(0x00) + return Base64.getEncoder().encodeToString(baos.toByteArray()) + } + + private fun getState(preference: SharedPreferences): EversenseSecureState { + val stateJson = preference.getString(StorageKeys.SECURE_STATE, null) ?: "{}" + return JSON.decodeFromString(stateJson) + } + } + + + @Serializable + @SuppressLint("UnsafeOptInUsageError") + data class LoginResponseModel( + val access_token: String, + val expires_in: Int, + val token_type: String, + val expires: String, + val lastLogin: String + ) + + @Serializable + @SuppressLint("UnsafeOptInUsageError") + data class FleetSecretV2ResponseModel( + val Status: String, + val StatusCode: Int, + val Result: FleetSecretV2Result + ) + + @Serializable + @SuppressLint("UnsafeOptInUsageError") + data class FleetSecretV2Result( + val Certificate: String? = null, + val Digital_Signature: String? = null, + val IsKeyAvailable: Boolean, + val KpAuthKey: String? = null, + val KpTxId: String? = null, + val KpTxUniqueId: String? = null, + val tx_flag: Boolean? = null, + val TxFleetKey: String? = null, + val TxKeyVersion: String? = null + ) +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttpE3Util.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttpE3Util.kt new file mode 100644 index 000000000000..39f25a0ad727 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseHttpE3Util.kt @@ -0,0 +1,282 @@ +package com.nightscout.eversense.util + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import androidx.core.content.edit +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseSecureState +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * DMS cloud upload for Eversense E3 (EU/OUS) transmitters. + * + * Endpoints confirmed from decompiled Eversense EU app v7.1.1: + * Token : https://ousiamapialpha.eversensedms.com/ + * Care : https://ousalphaapiservices.eversensedms.com/ + * + * Note: E3 does not use the diagnostic-log upload endpoint (PostEssentialLogs) + * because it does not expose raw BLE response bytes. Only PutCurrentValues and + * PutDeviceEvents are supported. + */ +class EversenseHttpE3Util { + companion object { + private const val TAG = "EversenseHttpE3Util" + private val JSON = Json { ignoreUnknownKeys = true } + + // OAuth2 client credentials embedded in the official Eversense Android app (publicly + // extractable from the APK). Not a personal secret — same value ships to all users. + private const val CLIENT_ID = "eversenseMMAAndroid" // NOSONAR + private const val CLIENT_SECRET = "6ksPx#]~wQ3U" // NOSONAR + + // EU/OUS endpoints � confirmed from decompiled Eversense EU app v7.1.1 + internal var tokenBaseUrl = "https://ousiamapialpha.eversensedms.com/" + internal var careBaseUrl = "https://ousalphaapiservices.eversensedms.com/" + + private val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + // -- Auth -------------------------------------------------------------- + + fun login(preferences: SharedPreferences): LoginResponseModel? { + val state = getState(preferences) + return try { + val formBody = listOf( + "grant_type=password", + "client_id=$CLIENT_ID", + "client_secret=$CLIENT_SECRET", + "username=${URLEncoder.encode(state.username, "UTF-8")}", + "password=${URLEncoder.encode(state.password, "UTF-8")}" + ).joinToString("&") + + val conn = URL("${tokenBaseUrl}connect/token").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(formBody); it.flush() } + + val code = conn.responseCode + if (code >= 400) { + val err = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + EversenseLogger.error(TAG, "E3 login failed � status: $code, body: $err") + return null + } + val body = conn.inputStream.readBytes().toString(Charsets.UTF_8) + EversenseLogger.info(TAG, "E3 login success � status: $code") + Json.decodeFromString(LoginResponseModel.serializer(), body) + } catch (e: Exception) { + EversenseLogger.error(TAG, "E3 login exception: $e") + null + } + } + + fun getOrRefreshToken(preferences: SharedPreferences): String? { + val expiry = preferences.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0) + val cached = preferences.getString(StorageKeys.ACCESS_TOKEN, null) + if (cached != null && System.currentTimeMillis() < expiry - 300_000L) return cached + val fresh = login(preferences) ?: return null + val newExpiry = System.currentTimeMillis() + (fresh.expires_in * 1000L) + preferences.edit(commit = true) { + putString(StorageKeys.ACCESS_TOKEN, fresh.access_token) + putLong(StorageKeys.ACCESS_TOKEN_EXPIRY, newExpiry) + } + return fresh.access_token + } + + // -- Portal sync ------------------------------------------------------- + + /** + * POST api/care/PutCurrentValues � updates Last Sync Date on portal and feeds AGP reports. + */ + fun putCurrentValues( + preferences: SharedPreferences, + glucose: Int, + timestamp: Long, + trend: EversenseTrendArrow, + signalStrength: Int, + batteryPercentage: Int + ): Boolean { + val token = getOrRefreshToken(preferences) ?: run { + EversenseLogger.error(TAG, "E3 putCurrentValues � no valid token") + return false + } + return try { + val ts = dateFormatter.format(Date(timestamp)) + val body = """{"CurrentGlucose":$glucose,"CGTime":"$ts","GlucoseTrend":${trendOrdinal(trend)},"SignalStrength":${signalStrengthOrdinal(signalStrength)},"BatteryStrength":${batteryPercentage.coerceAtLeast(0)},"IsTransmitterConnected":1}""" + + val conn = URL("${careBaseUrl}api/care/PutCurrentValues").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body); it.flush() } + + val code = conn.responseCode + if (code >= 400) { + val err = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + EversenseLogger.error(TAG, "E3 PutCurrentValues failed � status: $code, body: $err") + false + } else { + EversenseLogger.info(TAG, "E3 PutCurrentValues ok � status: $code, glucose=$glucose") + true + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "E3 PutCurrentValues exception: $e") + false + } + } + + /** + * POST api/care/PutDeviceEvents � populates Sensor Glucose history table on portal. + */ + fun putDeviceEvents( + preferences: SharedPreferences, + readings: List, + transmitterSerialNumber: String + ): Boolean { + if (readings.isEmpty()) return true + val token = getOrRefreshToken(preferences) ?: run { + EversenseLogger.error(TAG, "E3 putDeviceEvents � no valid token") + return false + } + return try { + val sensorId = readings.firstOrNull { it.sensorId.isNotEmpty() }?.sensorId ?: "" + val tzOffsetSec = TimeZone.getDefault().getOffset(Date().time) / 1000 + val offsetBytes = Base64.getEncoder().encodeToString(int32LE(tzOffsetSec)) + val sgBytes = buildSgBytes(readings) + val mgBytes = buildEmptyMgBytes() + val patientBytes = buildEmptyPatientBytes() + val alertBytes = buildAlertBytes(sensorId) + + EversenseLogger.info(TAG, "E3 PutDeviceEvents: ${readings.size} reading(s), txId='$transmitterSerialNumber'") + + val body = """{"deviceType":"SMSIMeter","deviceName":"Smart Transmitter (Android)","deviceID":"$transmitterSerialNumber","offsetBytes":"$offsetBytes","sgBytes":"$sgBytes","mgBytes":"$mgBytes","patientBytes":"$patientBytes","alertBytes":"$alertBytes","algorithmVersion":"10"}""" + + val conn = URL("${careBaseUrl}api/care/PutDeviceEvents").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body); it.flush() } + + val code = conn.responseCode + if (code >= 400) { + val err = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + EversenseLogger.error(TAG, "E3 PutDeviceEvents failed � status: $code, body: $err") + false + } else { + EversenseLogger.info(TAG, "E3 PutDeviceEvents ok � status: $code, readings: ${readings.size}") + true + } + } catch (e: Exception) { + EversenseLogger.error(TAG, "E3 PutDeviceEvents exception: $e") + false + } + } + + // -- Binary helpers (shared with E365 format) -------------------------- + + private fun int16LE(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte()) + private fun int24LE(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte()) + private fun int32LE(v: Int) = byteArrayOf((v and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte(), ((v shr 24) and 0xFF).toByte()) + + private fun calcDateBytes(tsMs: Long): ByteArray { + val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")).also { it.timeInMillis = tsMs } + val year = cal.get(Calendar.YEAR); val month = cal.get(Calendar.MONTH) + 1; val day = cal.get(Calendar.DAY_OF_MONTH) + var b1 = (year - 2000) shl 1; if (month > 7) b1 += 1 + val b0 = ((month and 7) shl 5) or day + return byteArrayOf(b0.toByte(), b1.toByte()) + } + + private fun calcTimeBytes(tsMs: Long): ByteArray { + val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")).also { it.timeInMillis = tsMs } + val hour = cal.get(Calendar.HOUR_OF_DAY); val minute = cal.get(Calendar.MINUTE); val second = cal.get(Calendar.SECOND) + val b0 = ((minute and 7) shl 5) or (second / 2) + val b1 = (hour shl 3) or ((minute and 56) shr 3) + return byteArrayOf(b0.toByte(), b1.toByte()) + } + + private fun buildSgBytes(readings: List): String { + val baos = ByteArrayOutputStream() + baos.write(byteArrayOf(0x8C.toByte(), 0x00, 0x01, 0x00, 0x00)) + baos.write(int24LE(readings.size)) + readings.forEachIndexed { idx, r -> + val sensorIdBytes = if (r.sensorId.isNotEmpty()) + r.sensorId.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + else ByteArray(10) + baos.write(int24LE(idx + 1)) + baos.write(calcDateBytes(r.datetime)) + baos.write(calcTimeBytes(r.datetime)) + baos.write(int16LE(r.glucoseInMgDl)) + baos.write(0x00) + baos.write(sensorIdBytes) + repeat(5) { baos.write(int16LE(0)) } + baos.write(int16LE(0)) + baos.write(0x00) + repeat(3) { baos.write(int16LE(0)) } + } + return Base64.getEncoder().encodeToString(baos.toByteArray()) + } + + private fun buildEmptyMgBytes() = Base64.getEncoder().encodeToString(byteArrayOf(0x98.toByte(), 0x01, 0x00, 0x00, 0x00, 0x00)) + private fun buildEmptyPatientBytes() = Base64.getEncoder().encodeToString(byteArrayOf(0x9E.toByte(), 0x01, 0x00, 0x00, 0x00)) + + private fun buildAlertBytes(sensorId: String): String { + val baos = ByteArrayOutputStream() + baos.write(byteArrayOf(0x93.toByte(), 0x01, 0x00, 0x00, 0x00)) + if (sensorId.isNotEmpty()) + baos.write(sensorId.chunked(2).map { it.toInt(16).toByte() }.toByteArray()) + baos.write(0x00) + return Base64.getEncoder().encodeToString(baos.toByteArray()) + } + + private fun signalStrengthOrdinal(percent: Int) = when { + percent >= 75 -> 5; percent >= 48 -> 4; percent >= 30 -> 3 + percent >= 28 -> 2; percent >= 25 -> 1; else -> 0 + } + + private fun trendOrdinal(trend: EversenseTrendArrow) = when (trend) { + EversenseTrendArrow.NONE -> 0 + EversenseTrendArrow.SINGLE_DOWN -> 1 + EversenseTrendArrow.FORTY_FIVE_DOWN -> 2 + EversenseTrendArrow.FLAT -> 3 + EversenseTrendArrow.FORTY_FIVE_UP -> 4 + EversenseTrendArrow.SINGLE_UP -> 5 + } + + private fun getState(preferences: SharedPreferences): EversenseSecureState { + val json = preferences.getString(StorageKeys.SECURE_STATE, null) ?: "{}" + return JSON.decodeFromString(json) + } + } + + @Serializable + @SuppressLint("UnsafeOptInUsageError") + data class LoginResponseModel( + val access_token: String, + val expires_in: Int, + val token_type: String, + val expires: String, + val lastLogin: String + ) +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseLogger.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseLogger.kt new file mode 100644 index 000000000000..16a5ba7eda71 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseLogger.kt @@ -0,0 +1,118 @@ +package com.nightscout.eversense.util + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.core.joran.spi.JoranException +import java.io.ByteArrayInputStream +import java.io.InputStream + +class EversenseLogger { + private val lc = LoggerContext() + private var isEnabled: Boolean = true + + init { + val config = JoranConfigurator() + config.setContext(lc) + + val stream: InputStream = ByteArrayInputStream(LOGBACK_XML.toByteArray()) + try { + config.doConfigure(stream) + } catch (e: JoranException) { + e.printStackTrace() + } + } + + private fun debug(tag: String, message: String) { + if (!isEnabled) { return } + + lc.getLogger(tag).debug(logLocationPrefix() + message) + } + + private fun info(tag: String, message: String) { + if (!isEnabled) { return } + lc.getLogger(tag).info(logLocationPrefix() + message) + } + + private fun warning(tag: String, message: String) { + if (!isEnabled) { return } + lc.getLogger(tag).warn(logLocationPrefix() + message) + } + + private fun error(tag: String, message: String) { + if (!isEnabled) { return } + lc.getLogger(tag).error(logLocationPrefix() + message) + } + + fun enableLogging(value: Boolean) { + this.isEnabled = value + } + + private fun logLocationPrefix(): String { + val stackInfo = Throwable().stackTrace[4] + val className = stackInfo.className.substringAfterLast(".") + val methodName = stackInfo.methodName + val lineNumber = stackInfo.lineNumber + + return "$className.$methodName():$lineNumber]: " + } + + companion object { + val instance = EversenseLogger() + + fun debug(tag: String, message: String) { + instance.debug(tag, message) + } + + fun info(tag: String, message: String) { + instance.info(tag, message) + } + + fun warning(tag: String, message: String) { + instance.warning(tag, message) + } + + fun error(tag: String, message: String) { + instance.error(tag, message) + } + + private const val LOGBACK_XML: String = "\n" + + " \n" + + " \n" + + " \n" + + " \${EXT_FILES_DIR}/Eversense.log\n" + + " \n" + + " \n" + + " \${EXT_FILES_DIR}/Eversense._%d{yyyy-MM-dd}_%d{HH-mm-ss, aux}_.%i.zip\n" + + " \n" + + "\n" + + " \n" + + " 5MB\n" + + " \n" + + " \n" + + " 120\n" + + " \n" + + " \n" + + " [%d{HH:mm:ss.SSS} %.-1level/%logger %msg%n\n" + + " \n" + + " \n" + + "\n" + + " \n" + + " \n" + + " %logger{0}\n" + + " \n" + + " \n" + + " [%d{HH:mm:ss.SSS} %msg%n\n" + + " \n" + + " \n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "" + + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseScanner.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseScanner.kt new file mode 100644 index 000000000000..d9e5f31301b7 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/EversenseScanner.kt @@ -0,0 +1,35 @@ +package com.nightscout.eversense.util + +import android.annotation.SuppressLint +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import com.nightscout.eversense.callbacks.EversenseScanCallback +import com.nightscout.eversense.models.EversenseScanResult + +class EversenseScanner(private val callback: EversenseScanCallback): ScanCallback() { + + @SuppressLint("MissingPermission") + override fun onScanResult(callbackType: Int, scanRecord: ScanResult) { + // Allow devices with null names — use address as fallback label. + // Some Eversense transmitters may advertise without a device name. + val deviceName = scanRecord.device?.name ?: scanRecord.device?.address ?: return + + // Filter to only show Eversense transmitters. + // E3 transmitters advertise as "T" followed by a serial number (e.g. "T0214389", "T3xxxxxx"). + // E365 transmitters advertise starting with "365". + if (!deviceName.startsWith("T") && !deviceName.startsWith("365") && !deviceName.contains("versense", ignoreCase = true)) { + return + } + + EversenseLogger.info(TAG, "Found Eversense device: $deviceName (address: ${scanRecord.device?.address})") + callback.onResult(EversenseScanResult(deviceName, scanRecord.rssi, scanRecord.device)) + } + + override fun onScanFailed(errorCode: Int) { + EversenseLogger.error(TAG, "BLE scan failed with error code: $errorCode") + } + + companion object { + private const val TAG = "EversenseScanner" + } +} \ No newline at end of file diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/MessageCoder.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/MessageCoder.kt new file mode 100644 index 000000000000..a7fcc821dc06 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/MessageCoder.kt @@ -0,0 +1,114 @@ +package com.nightscout.eversense.util + +import com.nightscout.eversense.enums.EversenseAlarm + +object MessageCoder { + + fun messageCodeForGlucoseLevelAlarmFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.LOW_GLUCOSE + 2 -> EversenseAlarm.HIGH_GLUCOSE + else -> null + } + + fun messageCodeForGlucoseLevelAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.LOW_GLUCOSE + 2 -> EversenseAlarm.HIGH_GLUCOSE + else -> null + } + + fun messageCodeForRateAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.RATE_FALLING + 2 -> EversenseAlarm.RATE_RISING + else -> null + } + + fun messageCodeForPredictiveAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.PREDICTIVE_LOW + 4 -> EversenseAlarm.PREDICTIVE_HIGH + else -> null + } + + fun messageCodeForSensorHardwareAndAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.UNKNOWN + 2 -> EversenseAlarm.SENSOR_AWOL + 4 -> EversenseAlarm.UNKNOWN + 8 -> EversenseAlarm.UNKNOWN + 16 -> EversenseAlarm.UNKNOWN + 32 -> EversenseAlarm.UNKNOWN + 64 -> EversenseAlarm.UNKNOWN + 128 -> EversenseAlarm.UNKNOWN + else -> null + } + + fun messageCodeForSensorReadAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.SERIOUSLY_HIGH + 2 -> EversenseAlarm.SERIOUSLY_LOW + 4 -> EversenseAlarm.UNKNOWN + 8 -> EversenseAlarm.UNKNOWN + 16 -> EversenseAlarm.SENSOR_TEMPERATURE + 32 -> EversenseAlarm.SENSOR_LOW_TEMPERATURE + 64 -> EversenseAlarm.READER_TEMPERATURE + 128 -> EversenseAlarm.MSP_ALARM + else -> null + } + + fun messageCodeForSensorReplacementFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.SENSOR_RETIRED + 2 -> EversenseAlarm.SENSOR_RETIRING_SOON_1 + 4 -> EversenseAlarm.SENSOR_RETIRING_SOON_1 + 8 -> EversenseAlarm.SENSOR_RETIRING_SOON_3 + 16 -> EversenseAlarm.SENSOR_RETIRING_SOON_4 + 32 -> EversenseAlarm.SENSOR_RETIRING_SOON_5 + 64 -> EversenseAlarm.SENSOR_RETIRING_SOON_6 + 128 -> EversenseAlarm.UNKNOWN + else -> null + } + + fun messageCodeForSensorCalibrationFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + 2 -> EversenseAlarm.CALIBRATION_EXPIRED + 4 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + 16 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + 32 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + 64 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + 128 -> EversenseAlarm.CALIBRATION_GRACE_PERIOD + else -> null + } + + fun messageCodeForTransmitterStatusAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.CRITICAL_FAULT + 4 -> EversenseAlarm.INVALID_SENSOR + 8 -> EversenseAlarm.INVALID_CLOCK + 32 -> EversenseAlarm.VIBRATION_CURRENT + 64 -> EversenseAlarm.UNKNOWN + 128 -> EversenseAlarm.UNKNOWN + else -> null + } + + fun messageCodeForTransmitterBatteryAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.EMPTY_BATTERY + 2 -> EversenseAlarm.VERY_LOW_BATTERY + 4 -> EversenseAlarm.EMPTY_BATTERY + 8 -> EversenseAlarm.BATTERY_ERROR + else -> null + } + + fun messageCodeForTransmitterEOLAlertFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.TRANSMITTER_EOL_396 + 2 -> EversenseAlarm.TRANSMITTER_EOL_366 + 4 -> EversenseAlarm.TRANSMITTER_EOL_330 + 8 -> EversenseAlarm.TRANSMITTER_EOL_395 + else -> null + } + + fun messageCodeForSensorReplacementFlags2(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.SENSOR_RETIRING_SOON_7 + else -> null + } + + fun messageCodeForCalibrationSwitchFlags(value: Int): EversenseAlarm? = when (value) { + 1 -> EversenseAlarm.ONE_CAL + 2 -> EversenseAlarm.TWO_CAL + else -> null + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/RangeCalculator.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/RangeCalculator.kt new file mode 100644 index 000000000000..49a6a5650c3a --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/RangeCalculator.kt @@ -0,0 +1,21 @@ +package com.nightscout.eversense.util + +import kotlin.math.min + +data class RangeCalculation(val from: Int, val to: Int) + +object RangeCalculator { + fun calculateGlucoseRange(rangeFrom: Int, rangeTo: Int, lastGlucoseTimestampMs: Long): RangeCalculation { + val timeDiffMs = System.currentTimeMillis() - lastGlucoseTimestampMs + val fiveMinMs = 5 * 60 * 1000L + val pageCount = min(((timeDiffMs / fiveMinMs) + 2).toInt(), 20) + val from = maxOf(rangeTo - pageCount, rangeFrom) + return RangeCalculation(from = from, to = rangeTo) + } + + fun calculateRange(rangeFrom: Int, rangeTo: Int): RangeCalculation { + val count = min(rangeTo - rangeFrom, 20) + val from = maxOf(rangeTo - count, rangeFrom) + return RangeCalculation(from = from, to = rangeTo) + } +} diff --git a/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/StorageKeys.kt b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/StorageKeys.kt new file mode 100644 index 000000000000..e4fb4f2ce307 --- /dev/null +++ b/plugins/eversense/src/main/kotlin/com/nightscout/eversense/util/StorageKeys.kt @@ -0,0 +1,11 @@ +package com.nightscout.eversense.util + +class StorageKeys { + companion object { + const val REMOTE_DEVICE_KEY = "eversense_remote_device" + const val STATE = "eversense_state" + const val SECURE_STATE = "eversense_state_secure" + const val ACCESS_TOKEN = "eversense_access_token" + const val ACCESS_TOKEN_EXPIRY = "eversense_access_token_expiry" + } +} \ No newline at end of file diff --git a/plugins/eversense/src/test/kotlin/com/nightscout/eversense/packets/e3/CalibrationPacketTest.kt b/plugins/eversense/src/test/kotlin/com/nightscout/eversense/packets/e3/CalibrationPacketTest.kt new file mode 100644 index 000000000000..47949b05f7f6 --- /dev/null +++ b/plugins/eversense/src/test/kotlin/com/nightscout/eversense/packets/e3/CalibrationPacketTest.kt @@ -0,0 +1,181 @@ +package com.nightscout.eversense.packets.e3 + +import com.nightscout.eversense.enums.CalibrationPhase +import com.nightscout.eversense.enums.CalibrationReadiness +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class GetCalibrationPhasePacketTest { + + private fun makePacket(vararg bytes: Int): GetCalibrationPhasePacket { + val packet = GetCalibrationPhasePacket() + packet.appendData(bytes.map { it.toUByte() }.toUByteArray()) + return packet + } + + @Test + fun `empty receivedData returns null`() { + val packet = GetCalibrationPhasePacket() + assertNull(packet.parseResponse()) + } + + @Test + fun `value 1 parses to WARMING_UP`() { + val packet = makePacket(0, 0, 0, 0, 1) + assertEquals(CalibrationPhase.WARMING_UP, packet.parseResponse()?.phase) + } + + @Test + fun `value 2 parses to INITIALIZATION`() { + val packet = makePacket(0, 0, 0, 0, 2) + assertEquals(CalibrationPhase.INITIALIZATION, packet.parseResponse()?.phase) + } + + @Test + fun `value 3 parses to DAILY_CALIBRATION`() { + val packet = makePacket(0, 0, 0, 0, 3) + assertEquals(CalibrationPhase.DAILY_CALIBRATION, packet.parseResponse()?.phase) + } + + @Test + fun `value 4 parses to SUSPICIOUS`() { + val packet = makePacket(0, 0, 0, 0, 4) + assertEquals(CalibrationPhase.SUSPICIOUS, packet.parseResponse()?.phase) + } + + @Test + fun `value 5 parses to DROPOUT`() { + val packet = makePacket(0, 0, 0, 0, 5) + assertEquals(CalibrationPhase.DROPOUT, packet.parseResponse()?.phase) + } + + @Test + fun `value 6 parses to DEBUG`() { + val packet = makePacket(0, 0, 0, 0, 6) + assertEquals(CalibrationPhase.DEBUG, packet.parseResponse()?.phase) + } + + @Test + fun `value 7 parses to UNKNOWN`() { + val packet = makePacket(0, 0, 0, 0, 7) + assertEquals(CalibrationPhase.UNKNOWN, packet.parseResponse()?.phase) + } + + @Test + fun `unknown value falls back to UNKNOWN`() { + val packet = makePacket(0, 0, 0, 0, 99) + assertEquals(CalibrationPhase.UNKNOWN, packet.parseResponse()?.phase) + } + + @Test + fun `annotation uses SingleByte command id`() { + val annotation = GetCalibrationPhasePacket().getAnnotation()!! + assertEquals(EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, annotation.requestId) + } + + @Test + fun `annotation uses SingleByte response id`() { + val annotation = GetCalibrationPhasePacket().getAnnotation()!! + assertEquals(EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, annotation.responseId) + } +} + +class GetCalibrationReadinessPacketTest { + + private fun makePacket(vararg bytes: Int): GetCalibrationReadinessPacket { + val packet = GetCalibrationReadinessPacket() + packet.appendData(bytes.map { it.toUByte() }.toUByteArray()) + return packet + } + + @Test + fun `empty receivedData returns null`() { + val packet = GetCalibrationReadinessPacket() + assertNull(packet.parseResponse()) + } + + @Test + fun `value 0 parses to READY`() { + val packet = makePacket(0, 0, 0, 0, 0) + assertEquals(CalibrationReadiness.READY, packet.parseResponse()?.readiness) + } + + @Test + fun `value 1 parses to NOT_ENOUGH_DATA`() { + val packet = makePacket(0, 0, 0, 0, 1) + assertEquals(CalibrationReadiness.NOT_ENOUGH_DATA, packet.parseResponse()?.readiness) + } + + @Test + fun `value 2 parses to GLUCOSE_RATE_TOO_HIGH`() { + val packet = makePacket(0, 0, 0, 0, 2) + assertEquals(CalibrationReadiness.GLUCOSE_RATE_TOO_HIGH, packet.parseResponse()?.readiness) + } + + @Test + fun `value 3 parses to TOO_SOON`() { + val packet = makePacket(0, 0, 0, 0, 3) + assertEquals(CalibrationReadiness.TOO_SOON, packet.parseResponse()?.readiness) + } + + @Test + fun `value 4 parses to DROPOUT_PHASE`() { + val packet = makePacket(0, 0, 0, 0, 4) + assertEquals(CalibrationReadiness.DROPOUT_PHASE, packet.parseResponse()?.readiness) + } + + @Test + fun `value 5 parses to SENSOR_EOL`() { + val packet = makePacket(0, 0, 0, 0, 5) + assertEquals(CalibrationReadiness.SENSOR_EOL, packet.parseResponse()?.readiness) + } + + @Test + fun `value 6 parses to NO_SENSOR_LINKED`() { + val packet = makePacket(0, 0, 0, 0, 6) + assertEquals(CalibrationReadiness.NO_SENSOR_LINKED, packet.parseResponse()?.readiness) + } + + @Test + fun `value 7 parses to UNSUPPORTED_MODE`() { + val packet = makePacket(0, 0, 0, 0, 7) + assertEquals(CalibrationReadiness.UNSUPPORTED_MODE, packet.parseResponse()?.readiness) + } + + @Test + fun `value 8 parses to WAITING_POST_CALIBRATION`() { + val packet = makePacket(0, 0, 0, 0, 8) + assertEquals(CalibrationReadiness.WAITING_POST_CALIBRATION, packet.parseResponse()?.readiness) + } + + @Test + fun `value 9 parses to LED_DISCONNECT_DETECTED`() { + val packet = makePacket(0, 0, 0, 0, 9) + assertEquals(CalibrationReadiness.LED_DISCONNECT_DETECTED, packet.parseResponse()?.readiness) + } + + @Test + fun `value 10 parses to TRANSMITTER_EOL`() { + val packet = makePacket(0, 0, 0, 0, 10) + assertEquals(CalibrationReadiness.TRANSMITTER_EOL, packet.parseResponse()?.readiness) + } + + @Test + fun `unknown value falls back to REASON_UNKNOWN`() { + val packet = makePacket(0, 0, 0, 0, 99) + assertEquals(CalibrationReadiness.REASON_UNKNOWN, packet.parseResponse()?.readiness) + } + + @Test + fun `annotation uses SingleByte command id`() { + val annotation = GetCalibrationReadinessPacket().getAnnotation()!! + assertEquals(EversenseE3Packets.ReadSingleByteSerialFlashRegisterCommandId, annotation.requestId) + } + + @Test + fun `annotation uses SingleByte response id`() { + val annotation = GetCalibrationReadinessPacket().getAnnotation()!! + assertEquals(EversenseE3Packets.ReadSingleByteSerialFlashRegisterResponseId, annotation.responseId) + } +} diff --git a/plugins/eversense/src/test/kotlin/com/nightscout/eversense/util/EversenseHttp365UtilTest.kt b/plugins/eversense/src/test/kotlin/com/nightscout/eversense/util/EversenseHttp365UtilTest.kt new file mode 100644 index 000000000000..80d4b28c6b25 --- /dev/null +++ b/plugins/eversense/src/test/kotlin/com/nightscout/eversense/util/EversenseHttp365UtilTest.kt @@ -0,0 +1,316 @@ +package com.nightscout.eversense.util + +import android.content.SharedPreferences +import com.nightscout.eversense.enums.EversenseTrendArrow +import com.nightscout.eversense.models.EversenseCGMResult +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class EversenseHttp365UtilTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var prefs: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + + private val validTokenJson = """ + { + "access_token": "test_access_token_abc123", + "expires_in": 3600, + "token_type": "Bearer", + "expires": "2099-01-01T00:00:00Z", + "lastLogin": "2026-04-10T00:00:00Z" + } + """.trimIndent() + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + + val baseUrl = mockWebServer.url("/").toString() + EversenseHttp365Util.tokenBaseUrl = baseUrl + EversenseHttp365Util.uploadBaseUrl = baseUrl + + prefs = mock() + editor = mock() + whenever(prefs.edit()).thenReturn(editor) + whenever(editor.putString(any(), anyOrNull())).thenReturn(editor) + whenever(editor.putLong(any(), any())).thenReturn(editor) + whenever(editor.commit()).thenReturn(true) + whenever(editor.apply()).then { } + + // Default: no stored state (empty secure state) + whenever(prefs.getString(StorageKeys.SECURE_STATE, null)).thenReturn(null) + } + + @AfterEach + fun tearDown() { + mockWebServer.shutdown() + // Restore production URLs + EversenseHttp365Util.tokenBaseUrl = "https://usiamapi.eversensedms.com/" + EversenseHttp365Util.uploadBaseUrl = "https://usmobileappmsprod.eversensedms.com/" + } + + // ─── getOrRefreshToken ──────────────────────────────────────────────────── + + @Test + fun `getOrRefreshToken returns cached token when not expired`() { + val futureExpiry = System.currentTimeMillis() + 600_000L // 10 minutes from now + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("cached_token_xyz") + + val token = EversenseHttp365Util.getOrRefreshToken(prefs) + + assertEquals("cached_token_xyz", token) + // No requests should have been made to the server + assertEquals(0, mockWebServer.requestCount) + } + + @Test + fun `getOrRefreshToken fetches new token when cache is expired`() { + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(0L) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn(null) + whenever(prefs.getString(StorageKeys.SECURE_STATE, null)).thenReturn( + """{"username":"user@example.com","password":"testpass"}""" + ) + + mockWebServer.enqueue(MockResponse().setBody(validTokenJson).setResponseCode(200)) + + val token = EversenseHttp365Util.getOrRefreshToken(prefs) + + assertEquals("test_access_token_abc123", token) + assertEquals(1, mockWebServer.requestCount) + val request = mockWebServer.takeRequest() + assertEquals("/connect/token", request.path) + assertEquals("POST", request.method) + assertTrue(request.body.readUtf8().contains("grant_type=password")) + } + + @Test + fun `getOrRefreshToken returns null when login fails`() { + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(0L) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn(null) + + mockWebServer.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"invalid_client"}""")) + + val token = EversenseHttp365Util.getOrRefreshToken(prefs) + + assertNull(token) + } + + @Test + fun `getOrRefreshToken refreshes token within 5 minutes of expiry`() { + // Token expires in 4 minutes — within the 5-minute refresh window + val nearExpiryMs = System.currentTimeMillis() + 240_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(nearExpiryMs) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("old_token") + whenever(prefs.getString(StorageKeys.SECURE_STATE, null)).thenReturn( + """{"username":"user@example.com","password":"testpass"}""" + ) + + mockWebServer.enqueue(MockResponse().setBody(validTokenJson).setResponseCode(200)) + + val token = EversenseHttp365Util.getOrRefreshToken(prefs) + + assertEquals("test_access_token_abc123", token) + assertEquals(1, mockWebServer.requestCount) + } + + // ─── uploadGlucoseReadings ──────────────────────────────────────────────── + + @Test + fun `uploadGlucoseReadings posts to correct endpoint with bearer token`() { + val futureExpiry = System.currentTimeMillis() + 600_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("my_bearer_token") + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + val readings = listOf( + EversenseCGMResult( + glucoseInMgDl = 120, + datetime = 1700000000000L, + trend = EversenseTrendArrow.FLAT, + sensorId = "sensor_001", + rawResponseHex = "deadbeef" + ) + ) + + val result = EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TX-12345", "1.2.3") + + assertTrue(result, "Expected upload to return true on HTTP 200") + assertEquals(1, mockWebServer.requestCount) + val request = mockWebServer.takeRequest() + assertEquals("/api/v1.0/DiagnosticLog/PostEssentialLogs", request.path) + assertEquals("POST", request.method) + assertEquals("Bearer my_bearer_token", request.getHeader("Authorization")) + assertEquals("application/json", request.getHeader("Content-Type")) + } + + @Test + fun `uploadGlucoseReadings sends correct JSON body fields`() { + val futureExpiry = System.currentTimeMillis() + 600_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("my_bearer_token") + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + val readings = listOf( + EversenseCGMResult( + glucoseInMgDl = 95, + datetime = 1700000000000L, + trend = EversenseTrendArrow.FLAT, + sensorId = "abc123", + rawResponseHex = "cafebabe" + ) + ) + + EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TXSERIAL", "2.0.1") + + val body = mockWebServer.takeRequest().body.readUtf8() + + assertTrue(body.startsWith("[") && body.endsWith("]"), "Body must be a bare JSON array") + assertTrue(body.contains("\"SensorId\":\"23C1AB\""), "Missing SensorId") + assertTrue(body.contains("\"TransmitterId\":\"TXSERIAL\""), "Missing TransmitterId") + assertTrue(body.contains("\"CurrentGlucoseValue\":95"), "Missing CurrentGlucoseValue") + assertTrue(body.contains("\"FWVersion\":\"2.0.1\""), "Missing FWVersion") + // EssentialLog must be base64-encoded bytes, not a hex string + val expectedBase64 = java.util.Base64.getEncoder().encodeToString(byteArrayOf(0xca.toByte(), 0xfe.toByte(), 0xba.toByte(), 0xbe.toByte())) + assertTrue(body.contains("\"EssentialLog\":\"$expectedBase64\""), "EssentialLog must be base64 bytes, got: $body") + } + + @Test + fun `uploadGlucoseReadings sends multiple readings in one request`() { + val futureExpiry = System.currentTimeMillis() + 600_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("token") + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + val readings = listOf( + EversenseCGMResult(100, 1700000000000L, EversenseTrendArrow.FLAT, "s1", "aa"), + EversenseCGMResult(110, 1700000300000L, EversenseTrendArrow.SINGLE_UP, "s1", "bb"), + EversenseCGMResult(105, 1700000600000L, EversenseTrendArrow.SINGLE_DOWN, "s1", "cc") + ) + + EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TX99", "3.0") + + assertEquals(1, mockWebServer.requestCount) + val body = mockWebServer.takeRequest().body.readUtf8() + assertTrue(body.contains("\"CurrentGlucoseValue\":100")) + assertTrue(body.contains("\"CurrentGlucoseValue\":110")) + assertTrue(body.contains("\"CurrentGlucoseValue\":105")) + } + + @Test + fun `uploadGlucoseReadings does nothing when readings list is empty`() { + EversenseHttp365Util.uploadGlucoseReadings(prefs, emptyList(), "TX99", "1.0") + + assertEquals(0, mockWebServer.requestCount) + } + + @Test + fun `uploadGlucoseReadings does not throw on 4xx server error`() { + val futureExpiry = System.currentTimeMillis() + 600_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("token") + + mockWebServer.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"bad request"}""")) + + val readings = listOf( + EversenseCGMResult(120, System.currentTimeMillis(), EversenseTrendArrow.FLAT, "s1", "ff") + ) + + // Should not throw — errors are logged internally, returns false + val result = EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TX1", "1.0") + + assertFalse(result, "Expected upload to return false on HTTP 400") + assertEquals(1, mockWebServer.requestCount) + } + + @Test + fun `uploadGlucoseReadings does not throw on 5xx server error`() { + val futureExpiry = System.currentTimeMillis() + 600_000L + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(futureExpiry) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn("token") + + mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody("Internal Server Error")) + + val readings = listOf( + EversenseCGMResult(80, System.currentTimeMillis(), EversenseTrendArrow.FLAT, "s1", "01") + ) + + val result = EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TX1", "1.0") + + assertFalse(result, "Expected upload to return false on HTTP 500") + assertEquals(1, mockWebServer.requestCount) + } + + @Test + fun `uploadGlucoseReadings skips upload when no valid token available`() { + // Token expired and login fails + whenever(prefs.getLong(StorageKeys.ACCESS_TOKEN_EXPIRY, 0)).thenReturn(0L) + whenever(prefs.getString(StorageKeys.ACCESS_TOKEN, null)).thenReturn(null) + + mockWebServer.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}""")) + + val readings = listOf( + EversenseCGMResult(100, System.currentTimeMillis(), EversenseTrendArrow.FLAT, "s1", "ab") + ) + + EversenseHttp365Util.uploadGlucoseReadings(prefs, readings, "TX1", "1.0") + + // Only the login attempt should have been made, not the upload + assertEquals(1, mockWebServer.requestCount) + assertEquals("/connect/token", mockWebServer.takeRequest().path) + } + + // ─── login ─────────────────────────────────────────────────────────────── + + @Test + fun `login sends correct form-encoded body`() { + whenever(prefs.getString(StorageKeys.SECURE_STATE, null)).thenReturn( + """{"username":"testuser@test.com","password":"secret123"}""" + ) + + mockWebServer.enqueue(MockResponse().setBody(validTokenJson).setResponseCode(200)) + + val result = EversenseHttp365Util.login(prefs) + + assertNotNull(result) + assertEquals("test_access_token_abc123", result!!.access_token) + assertEquals(3600, result.expires_in) + + val request = mockWebServer.takeRequest() + val body = request.body.readUtf8() + assertTrue(body.contains("grant_type=password")) + assertTrue(body.contains("client_id=eversenseMMAAndroid")) + assertTrue(body.contains("username=testuser%40test.com") || body.contains("username=testuser@test.com")) + } + + @Test + fun `login returns null on 401 response`() { + whenever(prefs.getString(StorageKeys.SECURE_STATE, null)).thenReturn( + """{"username":"bad@user.com","password":"wrong"}""" + ) + + mockWebServer.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"invalid_grant"}""")) + + val result = EversenseHttp365Util.login(prefs) + + assertNull(result) + } +} + From 128193effed0cec5a83b34e1e5bca3912c1292b8 Mon Sep 17 00:00:00 2001 From: Craig Gordon Date: Fri, 29 May 2026 17:20:22 -0400 Subject: [PATCH 02/22] Add EversenseStatusActivity, fix plugin registration, add credentials and calibration preferences --- plugins/source/build.gradle.kts | 4 +- plugins/source/src/main/AndroidManifest.xml | 12 +- .../aaps/plugins/source/EversensePlugin.kt | 597 ++++++++++++++++++ .../EversenseCalibrationActivity.kt | 212 +++++++ .../activities/EversensePlacementActivity.kt | 183 ++++++ .../activities/EversenseStatusActivity.kt | 178 ++++++ .../RequestEversensePermissionActivity.kt | 42 ++ .../aaps/plugins/source/di/SourceModule.kt | 15 +- .../plugins/source/keys/EversenseIntentKey.kt | 43 ++ .../plugins/source/keys/EversenseStringKey.kt | 36 ++ .../res/drawable/eversense_signal_bar.xml | 6 + .../layout/activity_eversense_calibration.xml | 81 +++ .../layout/activity_eversense_placement.xml | 117 ++++ .../res/layout/activity_eversense_status.xml | 85 +++ .../src/main/res/layout/source_fragment.xml | 16 + plugins/source/src/main/res/values/colors.xml | 8 + .../source/src/main/res/values/strings.xml | 78 ++- 17 files changed, 1705 insertions(+), 8 deletions(-) create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/EversensePlugin.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversenseCalibrationActivity.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversensePlacementActivity.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversenseStatusActivity.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/RequestEversensePermissionActivity.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/keys/EversenseIntentKey.kt create mode 100644 plugins/source/src/main/kotlin/app/aaps/plugins/source/keys/EversenseStringKey.kt create mode 100644 plugins/source/src/main/res/drawable/eversense_signal_bar.xml create mode 100644 plugins/source/src/main/res/layout/activity_eversense_calibration.xml create mode 100644 plugins/source/src/main/res/layout/activity_eversense_placement.xml create mode 100644 plugins/source/src/main/res/layout/activity_eversense_status.xml create mode 100644 plugins/source/src/main/res/layout/source_fragment.xml create mode 100644 plugins/source/src/main/res/values/colors.xml diff --git a/plugins/source/build.gradle.kts b/plugins/source/build.gradle.kts index d019d06c8886..ed9973114c8f 100644 --- a/plugins/source/build.gradle.kts +++ b/plugins/source/build.gradle.kts @@ -18,6 +18,8 @@ android { dependencies { + implementation(project(":plugins:eversense")) + implementation(libs.androidx.preference) implementation(project(":core:data")) implementation(project(":core:interfaces")) implementation(project(":core:keys")) @@ -42,4 +44,4 @@ dependencies { ksp(libs.com.google.dagger.compiler) ksp(libs.com.google.dagger.hilt.compiler) ksp(libs.com.google.dagger.android.processor) -} \ No newline at end of file +} diff --git a/plugins/source/src/main/AndroidManifest.xml b/plugins/source/src/main/AndroidManifest.xml index 607a2dbf65df..b94d282cb06d 100644 --- a/plugins/source/src/main/AndroidManifest.xml +++ b/plugins/source/src/main/AndroidManifest.xml @@ -18,6 +18,16 @@ + + + + - \ No newline at end of file + diff --git a/plugins/source/src/main/kotlin/app/aaps/plugins/source/EversensePlugin.kt b/plugins/source/src/main/kotlin/app/aaps/plugins/source/EversensePlugin.kt new file mode 100644 index 000000000000..a7a3afd62c0f --- /dev/null +++ b/plugins/source/src/main/kotlin/app/aaps/plugins/source/EversensePlugin.kt @@ -0,0 +1,597 @@ +package app.aaps.plugins.source + +import android.Manifest +import android.content.Intent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.notifications.NotificationId +import app.aaps.core.interfaces.notifications.NotificationLevel +import app.aaps.core.interfaces.notifications.NotificationManager +import com.nightscout.eversense.models.ActiveAlarm +import app.aaps.core.data.model.GV +import app.aaps.core.data.model.SourceSensor +import app.aaps.core.data.model.TrendArrow +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.data.ue.Sources +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.source.BgSource +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import com.nightscout.eversense.EversenseCGMPlugin +import com.nightscout.eversense.callbacks.EversenseScanCallback +import com.nightscout.eversense.callbacks.EversenseWatcher +import app.aaps.plugins.source.compose.BgSourceComposeContent +import com.nightscout.eversense.enums.CalibrationReadiness +import com.nightscout.eversense.enums.EversenseAlarm +import com.nightscout.eversense.enums.EversenseType +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseScanResult +import com.nightscout.eversense.models.EversenseSecureState +import com.nightscout.eversense.models.EversenseState +import com.nightscout.eversense.util.StorageKeys +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import app.aaps.core.keys.interfaces.withActivity +import app.aaps.core.keys.interfaces.withClick +import app.aaps.plugins.source.activities.EversenseStatusActivity +import app.aaps.plugins.source.activities.EversenseCalibrationActivity +import app.aaps.plugins.source.activities.EversensePlacementActivity +import app.aaps.plugins.source.keys.EversenseIntentKey +import app.aaps.plugins.source.keys.EversenseStringKey +import app.aaps.core.ui.compose.icons.IcPluginEversense +import app.aaps.core.keys.BooleanKey +import app.aaps.core.ui.compose.preference.PreferenceSubScreenDef +import javax.inject.Inject + +class EversensePlugin @Inject constructor( + rh: ResourceHelper, + private val context: Context, + aapsLogger: AAPSLogger, + preferences: Preferences, + config: Config, + private val notificationManager: NotificationManager +) : AbstractBgSourcePlugin( + PluginDescription() + .mainType(PluginType.BGSOURCE) + .composeContent { _ -> + BgSourceComposeContent( + title = rh.gs(R.string.source_eversense) + ) + } + .icon(IcPluginEversense) + .pluginName(R.string.source_eversense) + .preferencesVisibleInSimpleMode(false) + .description(R.string.description_source_eversense), + ownPreferences = emptyList(), + aapsLogger, rh, preferences, config +), BgSource, EversenseWatcher { + + @Inject lateinit var persistenceLayer: PersistenceLayer + + override var sensorBatteryLevel = -1 + + private val mainHandler = Handler(Looper.getMainLooper()) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + private val json = Json { ignoreUnknownKeys = true } + + private val securePrefs by lazy { + context.getSharedPreferences("EversenseCGMManager", Context.MODE_PRIVATE) + } + + private fun cloudUploadEnabled() = preferences.get(BooleanKey.EversenseCloudUploadEnabled) + + private val lastNotifiedFirmwareVersion: String get() = securePrefs.getString("last_notified_firmware_version", "") ?: "" + private fun setLastNotifiedFirmwareVersion(version: String) = securePrefs.edit(commit = true) { putString("last_notified_firmware_version", version) } + private fun isSensorExpiryDismissed(insertionDate: Long, days: Int): Boolean = + securePrefs.getBoolean("eversense_expiry_dismissed_${insertionDate}_${days}", false) + private fun setSensorExpiryDismissed(insertionDate: Long, days: Int) = + securePrefs.edit(commit = true) { putBoolean("eversense_expiry_dismissed_${insertionDate}_${days}", true) } + private fun isCalibrationDueDismissed(nextCalibrationDate: Long): Boolean = + securePrefs.getBoolean("eversense_cal_due_dismissed_${nextCalibrationDate}", false) + private fun setCalibrationDueDismissed(nextCalibrationDate: Long) = + securePrefs.edit(commit = true) { putBoolean("eversense_cal_due_dismissed_${nextCalibrationDate}", true) } + private fun isBatteryLowDismissed(): Boolean = + securePrefs.getBoolean("eversense_battery_low_dismissed", false) + private fun setBatteryLowDismissed() = + securePrefs.edit(commit = true) { putBoolean("eversense_battery_low_dismissed", true) } + private var consecutiveNoSignalReadings: Int = 0 + private val NO_SIGNAL_WARNING_THRESHOLD = 3 + private var releaseForOfficialApp: Boolean = false + @Volatile private var placementNotificationSnoozed: Boolean = false + + init { + eversense.setContext(context, true) + } + + override suspend fun onStart() { + super.onStart() + eversense.addWatcher(this) + if (hasBluetoothPermissions()) { + aapsLogger.debug(LTag.BGSOURCE, "onStart — permissions granted, attempting auto-reconnect") + ioScope.launch { + eversense.connect(null) + } + } else { + aapsLogger.warn(LTag.BGSOURCE, "Bluetooth permissions not granted — requesting permissions") + requestBluetoothPermissions() + } + // Alert if 365 credentials are missing + if (eversense.is365()) checkCredentialsNotification() + } + + override suspend fun onStop() { + super.onStop() + eversense.removeWatcher(this) + } + + private fun requestBluetoothPermissions() { + val intent = Intent(context, app.aaps.plugins.source.activities.RequestEversensePermissionActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + private fun hasBluetoothPermissions(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED + } else { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED + } + } + + private fun checkCredentialsNotification() { + val username = preferences.get(EversenseStringKey.EversenseUsername) + val password = preferences.get(EversenseStringKey.EversensePassword) + if (username.isEmpty() || password.isEmpty()) { + notificationManager.post( + NotificationId.EVERSENSE_CREDENTIALS, + rh.gs(R.string.eversense_credentials_missing), + level = NotificationLevel.URGENT + ) + } else { + notificationManager.dismiss(NotificationId.EVERSENSE_CREDENTIALS) + } + } + + private fun getSecureState(): EversenseSecureState { + val stateJson = securePrefs.getString(StorageKeys.SECURE_STATE, null) ?: "{}" + return json.decodeFromString(stateJson) + } + + private fun saveSecureState(state: EversenseSecureState) { + securePrefs.edit(commit = true) { + putString(StorageKeys.SECURE_STATE, json.encodeToString(EversenseSecureState.serializer(), state)) + } + } + + override fun getPreferenceScreenContent() = PreferenceSubScreenDef( + key = "eversense_settings", + titleResId = R.string.source_eversense, + items = listOf( + EversenseIntentKey.EversenseStatus.withActivity(EversenseStatusActivity::class.java), + BooleanKey.EversenseCloudUploadEnabled, + PreferenceSubScreenDef( + key = "eversense_credentials_screen", + titleResId = R.string.eversense_credentials_title, + items = listOf( + EversenseStringKey.EversenseUsername, + EversenseStringKey.EversensePassword + ) + ), + EversenseIntentKey.EversenseSignOut.withClick { + val cleared = getSecureState().also { + it.username = "" + it.password = "" + } + saveSecureState(cleared) + aapsLogger.info(LTag.BGSOURCE, "Eversense credentials cleared by user") + }, + EversenseIntentKey.EversenseCalibration.withActivity(EversenseCalibrationActivity::class.java as Class<*>), + EversenseIntentKey.EversensePlacement.withActivity(EversensePlacementActivity::class.java as Class<*>) + ), + icon = pluginDescription.icon + ) + + private fun startOfficialAppReleaseReconnectLoop() { + if (false) return + if (!releaseForOfficialApp) return + aapsLogger.info(LTag.BGSOURCE, "Release mode — attempting reconnect") + ioScope.launch { + eversense.connect(null) + mainHandler.postDelayed({ + if (eversense.isConnected()) { + aapsLogger.info(LTag.BGSOURCE, "Reconnected after official app release") + releaseForOfficialApp = false + mainHandler.post { + notificationManager.dismiss(NotificationId.EVERSENSE_RELEASE) + } + } else { + aapsLogger.info(LTag.BGSOURCE, "Reconnect failed — retrying in 5 minutes") + mainHandler.postDelayed({ startOfficialAppReleaseReconnectLoop() }, 300000L) + } + }, 10000L) + } + } + + private fun signalToLabel(strength: Int): String = when { + strength >= 75 -> "Excellent" + strength >= 48 -> "Good" + strength >= 30 -> "Low" + strength >= 25 -> "Poor" + strength > 0 -> "Very Poor" + else -> rh.gs(R.string.eversense_not_connected) + } + + override fun onStateChanged(state: EversenseState) { + aapsLogger.info(LTag.BGSOURCE, "New state received: ${Json.encodeToString(state)}") + + // Update sensor battery level for Overview status lights + sensorBatteryLevel = if (state.batteryPercentage >= 0) state.batteryPercentage else -1 + + // Keep SENSOR_CHANGE therapy event in sync with transmitter insertion date so + // the home screen sensor age matches the Eversense Status page insertion date. + if (state.insertionDate > 0) { + ioScope.launch { + persistenceLayer.insertCgmSourceData(Sources.Eversense, emptyList(), emptyList(), state.insertionDate) + aapsLogger.debug(LTag.BGSOURCE, "Updated SENSOR_CHANGE event to insertionDate: ${state.insertionDate}") + } + } + + // Sync SAGE color thresholds to match Eversense sensor lifetime and notification days + if (state.insertionDate > 0) { + val lifetimeDays = if (eversense.is365()) 365 else 180 + val warnHours = (lifetimeDays - 30) * 24 // orange when 30 days remaining + val urgentHours = (lifetimeDays - 10) * 24 // red when 10 days remaining + preferences.put(IntKey.OverviewSageWarning, warnHours) + preferences.put(IntKey.OverviewSageCritical, urgentHours) + } + + // Check for persistent no-signal — indicates transmitter not placed over sensor + if (state.sensorSignalStrength == 0) { + consecutiveNoSignalReadings++ + aapsLogger.warn(LTag.BGSOURCE, "No signal reading $consecutiveNoSignalReadings of $NO_SIGNAL_WARNING_THRESHOLD") + if (consecutiveNoSignalReadings >= NO_SIGNAL_WARNING_THRESHOLD) { + if (!placementNotificationSnoozed) { + onTransmitterNotPlaced() + } + consecutiveNoSignalReadings = 0 + } + } else { + consecutiveNoSignalReadings = 0 + placementNotificationSnoozed = false + notificationManager.dismiss(NotificationId.EVERSENSE_PLACEMENT) + } + + // Show sensor expiry notifications at 60, 30, and 10 days remaining — once each, at noon, keyed to insertionDate + if (state.insertionDate > 0) { + val sensorLifetimeMs = if (eversense.is365()) 365L * 24 * 60 * 60 * 1000 else 180L * 24 * 60 * 60 * 1000 + val expiryMs = state.insertionDate + sensorLifetimeMs + val daysRemaining = ((expiryMs - System.currentTimeMillis()) / (24 * 60 * 60 * 1000)).toInt() + val isAfterNoon = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) >= 12 + + if (isAfterNoon && daysRemaining in 31..60 && !isSensorExpiryDismissed(state.insertionDate, 60)) { + setSensorExpiryDismissed(state.insertionDate, 60) + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + "Eversense sensor expires in $daysRemaining days — plan your sensor replacement.", + level = NotificationLevel.INFO + ) + } else if (isAfterNoon && daysRemaining in 11..30 && !isSensorExpiryDismissed(state.insertionDate, 30)) { + setSensorExpiryDismissed(state.insertionDate, 30) + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + "Eversense sensor expires in $daysRemaining days — replace your sensor soon.", + level = NotificationLevel.NORMAL + ) + } else if (isAfterNoon && daysRemaining in 1..10 && !isSensorExpiryDismissed(state.insertionDate, daysRemaining)) { + setSensorExpiryDismissed(state.insertionDate, daysRemaining) + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + "Eversense sensor expires in $daysRemaining days — replace your sensor immediately.", + level = NotificationLevel.URGENT + ) + } + } + + // Battery low notification — fires once at noon when battery < 11% + val isAfterNoonBattery = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) >= 12 + if (isAfterNoonBattery && state.batteryPercentage in 1..10 && !isBatteryLowDismissed()) { + setBatteryLowDismissed() + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + "Eversense transmitter battery low: ${state.batteryPercentage}% — please charge your transmitter.", + level = NotificationLevel.NORMAL + ) + } + + // Calibration due notification — fires once per nextCalibrationDate + if (state.nextCalibrationDate > 0 + && System.currentTimeMillis() >= state.nextCalibrationDate + && !isCalibrationDueDismissed(state.nextCalibrationDate)) { + setCalibrationDueDismissed(state.nextCalibrationDate) + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + "Eversense calibration is due — open AAPS to calibrate your sensor.", + level = NotificationLevel.NORMAL + ) + } + + // Show firmware notification only once per unique firmware version + if (state.firmwareVersion.isNotEmpty() && state.firmwareVersion != lastNotifiedFirmwareVersion) { + setLastNotifiedFirmwareVersion(state.firmwareVersion) + aapsLogger.info(LTag.BGSOURCE, "Transmitter firmware: ${state.firmwareVersion}") + notificationManager.post( + NotificationId.EVERSENSE_FIRMWARE, + "Eversense firmware: ${state.firmwareVersion} — open the official Eversense app to check for updates", + level = NotificationLevel.INFO + ) + } + } + + override fun onTransmitterNotPlaced() { + aapsLogger.warn(LTag.BGSOURCE, "Transmitter not placed — firing placement warning notification") + mainHandler.post { + notificationManager.post( + NotificationId.EVERSENSE_PLACEMENT, + rh.gs(R.string.eversense_transmitter_not_placed), + level = NotificationLevel.URGENT + ) + } + } + + override fun onConnectionChanged(connected: Boolean) { + aapsLogger.info(LTag.BGSOURCE, "Connection changed — connected: $connected") + mainHandler.post { } + } + + override fun onTransmitterReady() { + // onTransmitterReady fires after auth completes and transmitter type is known. + // This is the correct place to trigger the initial fullSync — not onConnectionChanged + // which fires before auth and doesn't yet know if it's a 365 or E3 transmitter. + aapsLogger.info(LTag.BGSOURCE, "Transmitter ready — scheduling immediate fullSync on bleExecutor") + eversense.submitToExecutorAndSync(force = true) + } + + override fun onAlarmReceived(alarm: ActiveAlarm) { + aapsLogger.info(LTag.BGSOURCE, "Eversense alarm received: ${alarm.code.title}") + // CRITICAL_FAULT (code 0) is sent for both hardware faults and calibration-overdue events. + // If the stored next calibration date has already passed, treat it as a calibration alarm. + val title = if (alarm.code == EversenseAlarm.CRITICAL_FAULT) { + val stateJson = securePrefs.getString(StorageKeys.STATE, null) + val state = stateJson?.let { json.decodeFromString(it) } + if (state != null && state.nextCalibrationDate > 0 && state.nextCalibrationDate < System.currentTimeMillis()) { + "Eversense Calibration Due Now" + } else { + alarm.code.title + } + } else { + alarm.code.title + } + val level = when { + alarm.code.isCritical -> NotificationLevel.URGENT + alarm.code.isWarning -> NotificationLevel.NORMAL + else -> NotificationLevel.INFO + } + mainHandler.post { + notificationManager.post( + NotificationId.EVERSENSE_ALARM, + title, + level = level + ) + } + } + + override fun onCGMRead(type: EversenseType, readings: List) { + val glucoseValues = readings.map { reading -> + GV( + timestamp = reading.datetime, + value = reading.glucoseInMgDl.toDouble(), + noise = null, + raw = null, + trendArrow = TrendArrow.fromString(reading.trend.type), + sourceSensor = when (type) { + EversenseType.EVERSENSE_365 -> SourceSensor.EVERSENSE_365 + EversenseType.EVERSENSE_E3 -> SourceSensor.EVERSENSE_E3 + } + ) + } + + ioScope.launch { + val state = eversense.getCurrentState() + val insertionDate = state?.insertionDate?.takeIf { it > 0 } + val result = persistenceLayer.insertCgmSourceData( + Sources.Eversense, + glucoseValues, + listOf(), + insertionDate + ) + aapsLogger.info(LTag.BGSOURCE, "CGM insert complete — inserted: ${result.inserted}, updated: ${result.updated}") + + // Upload readings to Eversense cloud so official app sees data without needing BLE + if ((type == EversenseType.EVERSENSE_365 || type == EversenseType.EVERSENSE_E3) && state != null && cloudUploadEnabled()) { + val prefs = context.getSharedPreferences("EversenseCGMManager", android.content.Context.MODE_PRIVATE) + // Sync credentials from AAPS preferences into SECURE_STATE so EversenseHttp365Util can read them + val username = preferences.get(EversenseStringKey.EversenseUsername) + val password = preferences.get(EversenseStringKey.EversensePassword) + if (username.isNotEmpty() && password.isNotEmpty()) { + val secureState = getSecureState() + // Only invalidate the token cache if credentials have actually changed. + val credentialsChanged = secureState.username != username || secureState.password != password + secureState.username = username + secureState.password = password + saveSecureState(secureState) + if (credentialsChanged) { + val prefs2 = context.getSharedPreferences("EversenseCGMManager", android.content.Context.MODE_PRIVATE) + prefs2.edit(commit = true) { + remove(com.nightscout.eversense.util.StorageKeys.ACCESS_TOKEN) + remove(com.nightscout.eversense.util.StorageKeys.ACCESS_TOKEN_EXPIRY) + } + aapsLogger.info(LTag.BGSOURCE, "Eversense: credentials changed — token cache cleared, will re-login on next upload") + } + notificationManager.dismiss(NotificationId.EVERSENSE_CREDENTIALS) + } else { + notificationManager.post( + NotificationId.EVERSENSE_CREDENTIALS, + rh.gs(R.string.eversense_credentials_missing), + level = NotificationLevel.URGENT + ) + } + + if (type == EversenseType.EVERSENSE_365) { + // E365 US upload + val uploadOk = try { + com.nightscout.eversense.util.EversenseHttp365Util.uploadGlucoseReadings( + preferences = prefs, + readings = readings, + transmitterSerialNumber = state.transmitterName.ifEmpty { state.transmitterSerialNumber }, + firmwareVersion = state.firmwareVersion + ) + } catch (e: Exception) { + aapsLogger.error(LTag.BGSOURCE, "Eversense uploadGlucoseReadings EXCEPTION: ", e) + false + } + val msg365 = if (uploadOk) + "Eversense cloud upload: ✅ ${readings.size} reading(s) sent" + else + "Eversense cloud upload: ❌ failed — check credentials and internet" + aapsLogger.info(LTag.BGSOURCE, msg365) + + // Only notify user on failure — success is silent + if (!uploadOk) { + mainHandler.post { + android.widget.Toast.makeText(context, msg365, android.widget.Toast.LENGTH_LONG).show() + } + } + + val latest = readings.firstOrNull { it.rawResponseHex.isNotEmpty() } ?: readings.firstOrNull() + if (latest != null) { + val portalOk = com.nightscout.eversense.util.EversenseHttp365Util.putCurrentValues( + preferences = prefs, + glucose = latest.glucoseInMgDl, + timestamp = latest.datetime, + trend = latest.trend, + signalStrength = state.sensorSignalStrength, + batteryPercentage = state.batteryPercentage + ) + aapsLogger.info(LTag.BGSOURCE, "Eversense portal sync: ${if (portalOk) "✅ ok" else "❌ failed"}") + } + + val uploadableReadings = readings.filter { it.rawResponseHex.isNotEmpty() } + if (uploadableReadings.isNotEmpty()) { + val eventsOk = com.nightscout.eversense.util.EversenseHttp365Util.putDeviceEvents( + preferences = prefs, + readings = uploadableReadings, + transmitterSerialNumber = state.transmitterSerialNumber + ) + aapsLogger.info(LTag.BGSOURCE, "Eversense device events: ${if (eventsOk) "✅ ok" else "❌ failed"}") + } + } else { + // E3 EU/OUS upload + val latest = readings.firstOrNull() + if (latest != null) { + val portalOk = com.nightscout.eversense.util.EversenseHttpE3Util.putCurrentValues( + preferences = prefs, + glucose = latest.glucoseInMgDl, + timestamp = latest.datetime, + trend = latest.trend, + signalStrength = state.sensorSignalStrength, + batteryPercentage = state.batteryPercentage + ) + aapsLogger.info(LTag.BGSOURCE, "E3 portal sync: ${if (portalOk) "✅ ok" else "❌ failed"}") + } + val eventsOk = com.nightscout.eversense.util.EversenseHttpE3Util.putDeviceEvents( + preferences = prefs, + readings = readings, + transmitterSerialNumber = state.transmitterSerialNumber + ) + val msgE3 = if (eventsOk) + "E3 cloud upload: ✅ ${readings.size} reading(s) sent" + else + "E3 cloud upload: ❌ failed — check credentials and internet" + aapsLogger.info(LTag.BGSOURCE, msgE3) + + // Only notify user on failure — success is silent + if (!eventsOk) { + mainHandler.post { + android.widget.Toast.makeText(context, msgE3, android.widget.Toast.LENGTH_LONG).show() + } + } + } + } + } + } + + private fun showDeviceSelectionDialog(context: Context) { + val foundDevices = mutableListOf() + var isCancelled = false + var dialog: AlertDialog? = null + + val scanCallback = object : EversenseScanCallback { + override fun onResult(item: EversenseScanResult) { + val isEversenseTransmitter = item.name.matches(Regex("T\\d+.*")) + if (!isCancelled && isEversenseTransmitter && foundDevices.none { it.name == item.name }) { + foundDevices.add(item) + aapsLogger.info(LTag.BGSOURCE, "Scan found device: ${item.name}") + } + } + } + + eversense.startScan(scanCallback) + + mainHandler.postDelayed({ + if (isCancelled) return@postDelayed + eversense.stopScan() + dialog?.dismiss() + + if (foundDevices.isEmpty()) { + AlertDialog.Builder(context) + .setTitle(rh.gs(R.string.eversense_scan_title)) + .setMessage("No Eversense transmitters found. Make sure the transmitter is nearby and try again.") + .setPositiveButton("OK", null) + .show() + } else { + val items = foundDevices.map { it.name }.toTypedArray() + AlertDialog.Builder(context) + .setTitle(rh.gs(R.string.eversense_scan_title)) + .setItems(items) { _, position -> + val selected = foundDevices[position] + aapsLogger.info(LTag.BGSOURCE, "User selected device: ${selected.name}") + eversense.connect(selected.device) + } + .setNegativeButton(rh.gs(R.string.eversense_scan_cancel), null) + .show() + } + }, 10000) + + dialog = AlertDialog.Builder(context) + .setTitle(rh.gs(R.string.eversense_scan_title)) + .setMessage("Scanning for Eversense devices (10 seconds)...") + .setNegativeButton(rh.gs(R.string.eversense_scan_cancel)) { _, _ -> + isCancelled = true + eversense.stopScan() + } + .setCancelable(false) + .show() + } + + companion object { + private val eversense get() = EversenseCGMPlugin.instance + } +} \ No newline at end of file diff --git a/plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversenseCalibrationActivity.kt b/plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversenseCalibrationActivity.kt new file mode 100644 index 000000000000..87964dfec78a --- /dev/null +++ b/plugins/source/src/main/kotlin/app/aaps/plugins/source/activities/EversenseCalibrationActivity.kt @@ -0,0 +1,212 @@ +package app.aaps.plugins.source.activities + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.InputType +import android.view.MenuItem +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import app.aaps.core.interfaces.profile.ProfileUtil +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import app.aaps.plugins.source.R +import com.nightscout.eversense.EversenseCGMPlugin +import com.nightscout.eversense.callbacks.EversenseWatcher +import com.nightscout.eversense.enums.CalibrationReadiness +import com.nightscout.eversense.enums.EversenseType +import com.nightscout.eversense.models.ActiveAlarm +import com.nightscout.eversense.models.EversenseCGMResult +import com.nightscout.eversense.models.EversenseState +import com.nightscout.eversense.util.EversenseLogger +import javax.inject.Inject + +@AndroidEntryPoint +class EversenseCalibrationActivity : AppCompatActivity() { + + @Inject lateinit var profileUtil: ProfileUtil + + companion object { + private const val TAG = "EversenseCalibration" + private const val RECONNECT_TIMEOUT_MS = 30000L + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private var connectionWatcher: EversenseWatcher? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_eversense_calibration) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = getString(R.string.eversense_calibration_action) + + val state = EversenseCGMPlugin.instance.getCurrentState() + EversenseLogger.info(TAG, "Activity opened — readiness: ${state?.calibrationReadiness}, phase: ${state?.calibrationPhase}, connected: ${EversenseCGMPlugin.instance.isConnected()}") + + val statusText = findViewById(R.id.calibration_status) + statusText.text = when { + state == null -> getString(R.string.eversense_not_connected) + state.calibrationReadiness == CalibrationReadiness.READY -> getString(R.string.eversense_calibration_ready) + else -> readinessMessage(state.calibrationReadiness) + } + + val unitLabel = findViewById(R.id.calibration_unit_label) + unitLabel.text = profileUtil.units.asText + + val bgInput = findViewById(R.id.calibration_bg_input) + bgInput.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + bgInput.isEnabled = true // Allow submission regardless of readiness — matches official app + + val submitButton = findViewById