From 39662d323a4853c302da59f3954331e5dc758d03 Mon Sep 17 00:00:00 2001 From: alexanderkaptsov Date: Wed, 18 Mar 2026 23:23:18 +0900 Subject: [PATCH] =?UTF-8?q?HART=20Mobile=20v1.0.1=20=E2=80=94=20initial=20?= =?UTF-8?q?clean=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android app for HART protocol field devices (Bluetooth SPP / USB CP210x). Kotlin, MVVM, Jetpack Navigation, Material Design. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 26 + app/build.gradle.kts | 61 + app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 54 + app/src/main/assets/dd/602A_E30E.sym | 989 ++++++++++++++ .../ru/kaptsov/hartmobile/MainActivity.kt | 50 + .../connection/BluetoothSppConnection.kt | 117 ++ .../hartmobile/connection/IConnection.kt | 28 + .../connection/UsbSerialConnection.kt | 121 ++ .../ru/kaptsov/hartmobile/dd/DdManager.kt | 170 +++ .../java/ru/kaptsov/hartmobile/dd/DdModel.kt | 69 + .../java/ru/kaptsov/hartmobile/dd/DdParser.kt | 258 ++++ .../ru/kaptsov/hartmobile/dd/SymParser.kt | 257 ++++ .../hartmobile/license/LicenseManager.kt | 134 ++ .../hartmobile/license/LicenseStatus.kt | 15 + .../hartmobile/protocol/HartFileLogger.kt | 136 ++ .../kaptsov/hartmobile/protocol/HartFrame.kt | 144 ++ .../kaptsov/hartmobile/protocol/HartParser.kt | 461 +++++++ .../kaptsov/hartmobile/ui/DdMenuFragment.kt | 344 +++++ .../kaptsov/hartmobile/ui/DeviceFragment.kt | 287 ++++ .../hartmobile/ui/DeviceVariablesFragment.kt | 95 ++ .../kaptsov/hartmobile/ui/LicenseFragment.kt | 129 ++ .../kaptsov/hartmobile/ui/LoopTestFragment.kt | 92 ++ .../ru/kaptsov/hartmobile/ui/MainViewModel.kt | 1157 +++++++++++++++++ .../kaptsov/hartmobile/ui/PollScanFragment.kt | 109 ++ .../ru/kaptsov/hartmobile/ui/ScanFragment.kt | 200 +++ .../ru/kaptsov/hartmobile/ui/TrendFragment.kt | 119 ++ .../ru/kaptsov/hartmobile/ui/TrendView.kt | 191 +++ .../main/res/drawable/bg_activation_id.xml | 7 + app/src/main/res/layout/activity_main.xml | 26 + app/src/main/res/layout/fragment_dd_menu.xml | 113 ++ app/src/main/res/layout/fragment_device.xml | 196 +++ .../res/layout/fragment_device_variables.xml | 67 + app/src/main/res/layout/fragment_license.xml | 179 +++ .../main/res/layout/fragment_loop_test.xml | 200 +++ .../main/res/layout/fragment_poll_scan.xml | 80 ++ app/src/main/res/layout/fragment_scan.xml | 145 +++ app/src/main/res/layout/fragment_trend.xml | 62 + .../main/res/layout/item_bluetooth_device.xml | 23 + app/src/main/res/layout/item_dd_file.xml | 33 + app/src/main/res/layout/item_dd_menu.xml | 54 + .../main/res/layout/item_device_variable.xml | 39 + app/src/main/res/layout/item_poll_result.xml | 44 + app/src/main/res/menu/device_menu.xml | 20 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5054 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5008 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3054 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3028 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7459 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7449 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 12320 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 12286 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 17072 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16954 bytes .../res/mipmap-xxxhdpi/store_icon_512.png | Bin 0 -> 48431 bytes app/src/main/res/navigation/nav_graph.xml | 82 ++ app/src/main/res/values-sw600dp/dimens.xml | 6 + app/src/main/res/values-sw600dp/styles.xml | 47 + app/src/main/res/values/dimens.xml | 7 + app/src/main/res/values/strings.xml | 11 + app/src/main/res/values/themes.xml | 61 + app/src/main/res/xml/device_filter.xml | 5 + app/src/main/res/xml/file_paths.xml | 4 + build.gradle.kts | 4 + gen_icon.py | 116 ++ gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++ gradlew.bat | 92 ++ settings.gradle.kts | 18 + store/reestr_po/components.md | 31 + store/reestr_po/functional_description.md | 103 ++ store/reestr_po/install_guide.md | 65 + store/reestr_po/lifecycle.md | 52 + store/reestr_po/user_manual.md | 170 +++ tools/process_screenshots.py | 111 ++ 77 files changed, 8347 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/dd/602A_E30E.sym create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/MainActivity.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/connection/BluetoothSppConnection.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/connection/IConnection.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/connection/UsbSerialConnection.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/dd/DdManager.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/dd/DdModel.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/dd/DdParser.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/dd/SymParser.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/license/LicenseManager.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/license/LicenseStatus.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFileLogger.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFrame.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/protocol/HartParser.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/DdMenuFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceVariablesFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/LicenseFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/LoopTestFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/MainViewModel.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/PollScanFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/ScanFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/TrendFragment.kt create mode 100644 app/src/main/java/ru/kaptsov/hartmobile/ui/TrendView.kt create mode 100644 app/src/main/res/drawable/bg_activation_id.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/fragment_dd_menu.xml create mode 100644 app/src/main/res/layout/fragment_device.xml create mode 100644 app/src/main/res/layout/fragment_device_variables.xml create mode 100644 app/src/main/res/layout/fragment_license.xml create mode 100644 app/src/main/res/layout/fragment_loop_test.xml create mode 100644 app/src/main/res/layout/fragment_poll_scan.xml create mode 100644 app/src/main/res/layout/fragment_scan.xml create mode 100644 app/src/main/res/layout/fragment_trend.xml create mode 100644 app/src/main/res/layout/item_bluetooth_device.xml create mode 100644 app/src/main/res/layout/item_dd_file.xml create mode 100644 app/src/main/res/layout/item_dd_menu.xml create mode 100644 app/src/main/res/layout/item_device_variable.xml create mode 100644 app/src/main/res/layout/item_poll_result.xml create mode 100644 app/src/main/res/menu/device_menu.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/store_icon_512.png create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values-sw600dp/dimens.xml create mode 100644 app/src/main/res/values-sw600dp/styles.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/device_filter.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 build.gradle.kts create mode 100644 gen_icon.py create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 store/reestr_po/components.md create mode 100644 store/reestr_po/functional_description.md create mode 100644 store/reestr_po/install_guide.md create mode 100644 store/reestr_po/lifecycle.md create mode 100644 store/reestr_po/user_manual.md create mode 100644 tools/process_screenshots.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e750ab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Android build +app/build/ +build/ +.gradle/ +local.properties + +# IDE +.idea/ +*.iml + +# macOS +.DS_Store + +# Keystore (secret) +*.keystore + +# Temp +*.log + +# Internal docs (not for public repo) +CLAUDE.md +README.md +docs/ +tools/gen_code.py +store/rustore/ +store/reestr_po/PLAN.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1a1803a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "ru.kaptsov.hartmobile" + compileSdk = 34 + + defaultConfig { + applicationId = "ru.kaptsov.hartmobile" + minSdk = 23 + targetSdk = 34 + versionCode = 2 + versionName = "1.0.1" + } + + signingConfigs { + create("release") { + storeFile = file("../hart_mobile.keystore") + storePassword = project.findProperty("KEYSTORE_PASSWORD") as String? ?: "" + keyAlias = "hart_mobile" + keyPassword = project.findProperty("KEY_PASSWORD") as String? ?: "" + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") + implementation("androidx.navigation:navigation-ui-ktx:2.7.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + // USB Serial для CP210x (BriC USB) + implementation("com.github.mik3y:usb-serial-for-android:3.7.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..7803435 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep class ru.kaptsov.hartmobile.** { *; } +-keep class com.hoho.android.usbserial.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e36d6be --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/dd/602A_E30E.sym b/app/src/main/assets/dd/602A_E30E.sym new file mode 100644 index 0000000..bf1bf4d --- /dev/null +++ b/app/src/main/assets/dd/602A_E30E.sym @@ -0,0 +1,989 @@ +member TREND_VALUE_6_DEVICE_FAMILY_STATUS collection 4541 (std) +variable uint16_var_blk_char unsigned 2147548933 +command cmd1152 16437 +method RestoreFactParams 16387 +member blk_profile_rev record 3221290759 +variable physical_signaling_codes enumerated 2033 (std,imp) +member TREND_VALUE_9_DEVICE_FAMILY_STATUS collection 4553 (std) +array IO_card0 collection 4354 (std) +array IO_card1 collection 4355 (std) +method ResetConfigurationChangedFlag 16386 +member TREND_VALUE_4_DEVICE_FAMILY_STATUS collection 4533 (std) +variable date date 166 (std,mand,imp) +member BACK_RECEIVED collection 4335 (std) +variable iout_alarm_low_value float 16416 +variable trim_point_codes enumerated 2047 (std,imp) +variable temperatureLSL float 16400 +member blk_num_params record 3221290761 +member STANDARDIZED_STATUS_2_LATCHED_VALUE collection 4677 (std) +member EXTENDED_FLD_DEVICE_STATUS_LATCHED_VALUE collection 4673 (std) +member DEVICE_SPECIFIC_STATUS_15_MASK collection 4656 (std) +member LIMIT_STATUS collection 248 (std) +member COMMAND_NUMBER_ENUMERATED collection 4382 (std) +variable device_profile_codes enumerated 2257 (std,imp) +command write_tag_descriptor_date 186 (std,mand,imp) +member STAT_STX_CNT collection 4267 (std) +member DEVICE_STATUS_LATCHED_VALUE collection 4666 (std) +variable operatingMode enumerated 2160 (std,imp) +variable lowerRange_value float 235 (std,imp) +variable byte_count unsigned 1022 (std) +refresh sensor_rel 16424 +variable status_output enumerated 2147548930 +member TREND_VALUE_11_DEVICE_FAMILY_STATUS collection 4561 (std) +member TREND_VALUE_5_DEVICE_FAMILY_STATUS collection 4537 (std) +variable message packed-ascii 164 (std,mand,imp) +member SUB_DEVICE_MANUFACTUER 4219 (std) +member LOWER_ENDPOINT_VALUE 4087 (std) +member SENSOR_UNIT 6003 (std) +variable company_identification_code enumerated 2031 (std,imp) +member TREND_VALUE_0_DEVICE_FAMILY_STATUS collection 4517 (std) +variable standardized_status_0_codes enumerated 2241 (std,imp) +menu maintenance_root_menu 1026 (std,mand) +member EVENT_NOTIFICATION_CONTROL collection 4636 (std) +member TREND_DIGITAL_UNITS collection 4510 (std) +member DEVICE_SPECIFIC_STATUS_19_MASK collection 4660 (std) +menu hart_output 7018 (std,imp) +member TREND_VALUE_10_DEVICE_FAMILY_STATUS collection 4557 (std) +member DEVICE_SPECIFIC_STATUS_16_LATCHED_VALUE collection 4682 (std) +array dynamic_variables collection 172 (std,mand) +member STANDARDIZED_STATUS_2_MASK collection 4652 (std) +member CLASSIFICATION collection 218 (std) +member BURST_UNIT collection 4396 (std) +command read_final_assembly_number 184 (std,mand,imp) +member TREND_VALUE_1_DEVICE_FAMILY_STATUS collection 4521 (std) +method warning_message 4119 (std,imp) +member DAQ collection 213 (std) +member DEVICE_SPECIFIC_STATUS_1_MASK collection 4643 (std) +command read_dynamic_variable_classification 220 (std,imp) +member PARAM20 parameter 3221266195 +member PARAM21 parameter 3221266196 +member DEVICE_SPECIFIC_STATUS_5_LATCHED_VALUE collection 4672 (std) +member PARAM22 parameter 3221266197 +member PARAM23 parameter 3221266198 +record DIGVAL_RECORD80 2147549024 +menu device_root_menu 1020 (std,mand) +member PARAM24 parameter 3221266199 +record DIGVAL_RECORD81 2147549025 +member PARAM25 parameter 3221266200 +record DIGVAL_RECORD82 2147549026 +variable lock_device_codes enumerated 2044 (std,imp) +member PARAM26 parameter 3221266201 +record DIGVAL_RECORD83 2147549027 +member PARAM27 parameter 3221266202 +record DIGVAL_RECORD84 2147549028 +method transmitter_loop_test 4131 (std,imp) +member PARAM28 parameter 3221266203 +record DIGVAL_RECORD85 2147549029 +member PARAM29 parameter 3221266204 +record DIGVAL_RECORD86 2147549030 +record DIGVAL_RECORD87 2147549031 +variable __sensor_upper_limit float 959 (std) +variable device_id unsigned 161 (std,mand,imp) +record DIGVAL_RECORD88 2147549032 +record DIGVAL_RECORD89 2147549033 +member SOURCE_DEVICE_TYPE 4214 (std) +member PARAM10 parameter 3221266185 +member PARAM11 parameter 3221266186 +member blk_class record 3221290754 +member PARAM12 parameter 3221266187 +collection quaternary_variable collection 245 (std,imp) +member PARAM13 parameter 3221266188 +record DIGVAL_RECORD70 2147549014 +member PARAM14 parameter 3221266189 +record DIGVAL_RECORD71 2147549015 +member PARAM15 parameter 3221266190 +record DIGVAL_RECORD72 2147549016 +member PARAM16 parameter 3221266191 +record DIGVAL_RECORD73 2147549017 +member PARAM17 parameter 3221266192 +record DIGVAL_RECORD74 2147549018 +member PARAM18 parameter 3221266193 +record DIGVAL_RECORD75 2147549019 +variable device_specific_status_14 bit-enumerated 4145 (std,imp) +member PARAM19 parameter 3221266194 +member blk_execution_time record 3221290760 +record DIGVAL_RECORD76 2147549020 +variable device_specific_status_15 bit-enumerated 4146 (std,imp) +record DIGVAL_RECORD77 2147549021 +variable device_specific_status_16 bit-enumerated 4147 (std,imp) +record DIGVAL_RECORD78 2147549022 +variable device_specific_status_17 bit-enumerated 4148 (std,imp) +record DIGVAL_RECORD79 2147549023 +variable device_specific_status_18 bit-enumerated 4149 (std,imp) +variable device_specific_status_19 bit-enumerated 4150 (std,imp) +member PARAM40 parameter 3221266215 +member DEVICE_STATUS_MASK collection 4641 (std) +member PARAM41 parameter 3221266217 +member TREND_VALUE_7_DEVICE_FAMILY_STATUS collection 4545 (std) +member PARAM42 parameter 3221266218 +member PARAM43 parameter 3221266219 +record DIGVAL_RECORD60 2147549004 +member PARAM44 parameter 3221266220 +record DIGVAL_RECORD61 2147549005 +variable device_specific_status_20 bit-enumerated 4151 (std,imp) +member PARAM45 parameter 3221266221 +record DIGVAL_RECORD62 2147549006 +variable device_specific_status_21 bit-enumerated 4152 (std,imp) +member PARAM46 parameter 3221266222 +record DIGVAL_RECORD63 2147549007 +variable device_specific_status_22 bit-enumerated 4153 (std,imp) +command write_pv_damping_value 4006 (std,imp) +member PARAM47 parameter 3221266223 +record DIGVAL_RECORD64 2147549008 +variable device_specific_status_23 bit-enumerated 4154 (std,imp) +member PARAM48 parameter 3221266224 +record DIGVAL_RECORD65 2147549009 +variable device_specific_status_24 bit-enumerated 4155 (std,imp) +member PARAM49 parameter 3221266225 +record DIGVAL_RECORD66 2147549010 +member blk_num_display_lists record 3221290763 +record DIGVAL_RECORD67 2147549011 +record DIGVAL_RECORD68 2147549012 +record DIGVAL_RECORD69 2147549013 +member PARAM30 parameter 3221266205 +member PARAM31 parameter 3221266206 +member PARAM32 parameter 3221266207 +member PARAM33 parameter 3221266208 +record DIGVAL_RECORD50 2147548994 +collection primary_variable collection 242 (std,imp) +chart bar 16450 +member PARAM34 parameter 3221266209 +record DIGVAL_RECORD51 2147548995 +member PARAM35 parameter 3221266210 +record DIGVAL_RECORD52 2147548996 +member PARAM36 parameter 3221266211 +record DIGVAL_RECORD53 2147548997 +member CAPTURE_MODE 4212 (std) +member PARAM37 parameter 3221266212 +record DIGVAL_RECORD54 2147548998 +member PARAM38 parameter 3221266213 +record DIGVAL_RECORD55 2147548999 +variable operating_mode_code enumerated 2035 (std,imp) +member PARAM39 parameter 3221266214 +record DIGVAL_RECORD56 2147549000 +record DIGVAL_RECORD57 2147549001 +axis pv_range 16452 +record DIGVAL_RECORD58 2147549002 +record DIGVAL_RECORD59 2147549003 +member ANALOG_CHANNEL_SATURATED1_MASK collection 4651 (std) +member TREND_VALUE_3_DATA_QUALITY collection 4527 (std) +command read_loop_configuration 219 (std,imp) +variable response_preambles unsigned 4066 (std,imp) +menu OnlineWindow_display 7024 (std) +record DIGVAL_RECORD1 2147548945 +record DIGVAL_RECORD2 2147548946 +record DIGVAL_RECORD3 2147548947 +record DIGVAL_RECORD4 2147548948 +record DIGVAL_RECORD5 2147548949 +record DIGVAL_RECORD6 2147548950 +record DIGVAL_RECORD7 2147548951 +method ChangeHARTpass 16390 +record DIGVAL_RECORD8 2147548952 +record DIGVAL_RECORD9 2147548953 +member TREND_VALUE_8_DATA_QUALITY collection 4547 (std) +variable manual_coldjunction_value float 16431 +variable temperatureUpdatePeriod_int unsigned 16403 +member TREND_VALUE_9_DATA_QUALITY collection 4551 (std) +value-array trend_array 4447 (std) +variable loop_flags bit-enumerated 231 (std,imp) +member DEVICE_SPECIFIC_STATUS_21_MASK collection 4662 (std) +member TREND_VALUE_6_DATA_QUALITY collection 4539 (std) +record DIGVAL_RECORD90 2147549034 +record DIGVAL_RECORD91 2147549035 +record DIGVAL_RECORD92 2147549036 +record DIGVAL_RECORD93 2147549037 +record DIGVAL_RECORD94 2147549038 +collection __SENSOR variable 950 (std) +record DIGVAL_RECORD95 2147549039 +variable device_type enumerated 154 (std,mand,imp) +record DIGVAL_RECORD96 2147549040 +variable hart_pass_local unsigned 16389 +record DIGVAL_RECORD97 2147549041 +method return_to_normal 4118 (std,imp) +record DIGVAL_RECORD98 2147549042 +unit scaling_units_relation 241 (std,imp) +record DIGVAL_RECORD99 2147549043 +unit __digital_units_relation 971 (std) +member UPDATE_PERIOD collection 4383 (std) +member PVST1 chart 16457 +member BURST_MODE_SELECT collection 4385 (std) +command read_device_variables 4180 (std,imp) +member TREND_VALUE_4_DATA_QUALITY collection 4531 (std) +member EVENT_STATUS collection 4635 (std) +member PARAM90 parameter 3221266266 +member SOURCE_SLOT_NUMBER 4217 (std) +member PARAM91 parameter 3221266267 +member PARAM92 parameter 3221266268 +member PARAM93 parameter 3221266269 +member PARAM94 parameter 3221266270 +member PARAM95 parameter 3221266271 +member PARAM96 parameter 3221266272 +member PARAM97 parameter 3221266273 +member PARAM98 parameter 3221266274 +variable country_code enumerated 4697 (std,imp) +member PARAM99 parameter 3221266275 +refresh __pv_unit_refresh 977 (std) +member TREND_VALUE_5_DATA_QUALITY collection 4535 (std) +edit-display rerange 7023 (std,imp) +command write_pv_range_values 4007 (std,imp) +member ANALOG_CHANNEL_FLAGS collection 217 (std) +member PVS1 source 16455 +variable hardware_revision unsigned 159 (std,mand,imp) +member TREND_VALUE_2_DATA_QUALITY collection 4523 (std) +chart measure 16449 +member blk_parent_class record 3221290755 +variable manual_coldjunction_units enumerated 16430 +command read_pv_current_and_percent_range 176 (std,mand,imp) +variable time_set_codes enumerated 2250 (std,imp) +member PARAM60 parameter 3221266236 +command write_device_variable_trim_point 4176 (std,imp) +member PARAM61 parameter 3221266237 +member PARAM62 parameter 3221266238 +member PARAM63 parameter 3221266239 +record DIGVAL_RECORD40 2147548984 +member PARAM64 parameter 3221266240 +record DIGVAL_RECORD41 2147548985 +member PARAM65 parameter 3221266241 +record DIGVAL_RECORD42 2147548986 +member PARAM66 parameter 3221266242 +record DIGVAL_RECORD43 2147548987 +member PARAM67 parameter 3221266243 +record DIGVAL_RECORD44 2147548988 +member PARAM68 parameter 3221266244 +record DIGVAL_RECORD45 2147548989 +member PARAM69 parameter 3221266245 +record DIGVAL_RECORD46 2147548990 +record DIGVAL_RECORD47 2147548991 +member SHED_TIME 4218 (std) +record DIGVAL_RECORD48 2147548992 +record DIGVAL_RECORD49 2147548993 +member PVST chart 16456 +member TREND_VALUE_0_DATA_QUALITY collection 4515 (std) +member ANALOG_VALUE collection 193 (std) +variable physical_signaling_code enumerated 171 (std,mand,imp) +member PARAM50 parameter 3221266226 +member PARAM51 parameter 3221266227 +member PARAM52 parameter 3221266228 +collection secondary_variable collection 243 (std,imp) +variable comm_status bit-enumerated 152 (std,mand,imp) +member PARAM53 parameter 3221266229 +record DIGVAL_RECORD30 2147548974 +member DEVICE_SPECIFIC_STATUS_19_LATCHED_VALUE collection 4685 (std) +member PARAM54 parameter 3221266230 +record DIGVAL_RECORD31 2147548975 +method ChangeWriteProtectMode 16388 +member PARAM55 parameter 3221266231 +record DIGVAL_RECORD32 2147548976 +member SOURCE_DEVICE_ID 4215 (std) +member PARAM56 parameter 3221266232 +record DIGVAL_RECORD33 2147548977 +method pre_send_configuration_method 1007 (std) +member PARAM57 parameter 3221266233 +record DIGVAL_RECORD34 2147548978 +variable time_set enumerated 4439 (std,imp) +member PARAM58 parameter 3221266234 +record DIGVAL_RECORD35 2147548979 +member PARAM59 parameter 3221266235 +record DIGVAL_RECORD36 2147548980 +record DIGVAL_RECORD37 2147548981 +record DIGVAL_RECORD38 2147548982 +record DIGVAL_RECORD39 2147548983 +member TREND_VALUE_1_DATA_QUALITY collection 4519 (std) +member PARAM80 parameter 3221266256 +variable final_assembly_number unsigned 169 (std,mand,imp) +member PARAM81 parameter 3221266257 +variable universal_revision unsigned 156 (std,mand,imp) +member PARAM82 parameter 3221266258 +member PARAM83 parameter 3221266259 +record DIGVAL_RECORD20 2147548964 +member PARAM84 parameter 3221266260 +record DIGVAL_RECORD21 2147548965 +member PARAM85 parameter 3221266261 +record DIGVAL_RECORD22 2147548966 +member PARAM86 parameter 3221266262 +record DIGVAL_RECORD23 2147548967 +member PARAM87 parameter 3221266263 +record DIGVAL_RECORD24 2147548968 +member PARAM88 parameter 3221266264 +record DIGVAL_RECORD25 2147548969 +variable device_specific_status_0 bit-enumerated 4139 (std,imp) +member PARAM89 parameter 3221266265 +record DIGVAL_RECORD26 2147548970 +variable device_specific_status_1 bit-enumerated 4140 (std,imp) +array transmitter_variables collection 4109 (std) +record DIGVAL_RECORD27 2147548971 +variable device_specific_status_2 bit-enumerated 4141 (std,imp) +record DIGVAL_RECORD28 2147548972 +variable device_specific_status_3 bit-enumerated 4142 (std,imp) +record DIGVAL_RECORD29 2147548973 +variable device_specific_status_4 bit-enumerated 4143 (std,imp) +variable time_stamp time_value 254 (std,imp) +variable device_specific_status_5 bit-enumerated 4144 (std,imp) +member DEVICE_SPECIFIC_STATUS_21_LATCHED_VALUE collection 4687 (std) +member PARAM70 parameter 3221266246 +member PARAM71 parameter 3221266247 +member PARAM72 parameter 3221266248 +member PARAM73 parameter 3221266249 +record DIGVAL_RECORD10 2147548954 +member PARAM74 parameter 3221266250 +record DIGVAL_RECORD11 2147548955 +member PARAM75 parameter 3221266251 +record DIGVAL_RECORD12 2147548956 +variable material_code enumerated 2028 (std,imp) +member PARAM76 parameter 3221266252 +record DIGVAL_RECORD13 2147548957 +method device_variable_trim_reset 4227 (std,imp) +member PARAM77 parameter 3221266253 +record DIGVAL_RECORD14 2147548958 +member PARAM78 parameter 3221266254 +record DIGVAL_RECORD15 2147548959 +member PARAM79 parameter 3221266255 +record DIGVAL_RECORD16 2147548960 +record DIGVAL_RECORD17 2147548961 +collection __INPT variable 951 (std) +record DIGVAL_RECORD18 2147548962 +variable physical_layer_type_codes enumerated 2048 (std,imp) +record DIGVAL_RECORD19 2147548963 +member FAMILY_STATUS collection 4775 (std,imp) +member OACK_RECEIVED collection 4334 (std) +array deviceVariables collection 269 (std) +member SUB_DEVICE_MISSING collection 4845 (std,imp) +member IDENT collection 4794 (std,imp) +unit temperatureUnitsRelation 16445 +member DEVICE_SPECIFIC_STATUS_0_LATCHED_VALUE collection 4667 (std) +variable transfer_function enumerated 8068 (std,imp) +variable device_family_status enumerated 2054 (std,imp) +member STAT_ACK_CNT collection 4268 (std) +member EVENT_REPORT collection 4762 (std,imp) +member TREND_VALUE_7_LIMIT_STATUS collection 4544 (std) +command read_dynamic_variables_and_pv_current 177 (std,mand,imp) +source pvsrc 16451 +method std_CheckWriteProtect 1016 (std) +menu view_menu 1009 (std) +variable temperatureLS enumerated 16397 +member EXTENDED_FLD_DEVICE_STATUS_MASK collection 4648 (std) +variable lock_device_status_codes enumerated 2046 (std,imp) +variable __sensor_lower_limit float 958 (std) +member LOWER_LIMIT_VALUE 4885 (std) +member TREND_CLASSIFICATION collection 4509 (std) +variable device_variable_code_codes enumerated 2246 (std,imp) +menu menu_temper_sensor 16446 +member _MNG_VALUE record 3221290752 +variable background_period float 1025 (std) +member DEVICE_SPECIFIC_STATUS_16_MASK collection 4657 (std) +collection temper variable 16414 +variable device_flags bit-enumerated 160 (std,mand,imp) +member TREND_DEVICE_VARIABLE_CODE collection 4508 (std) +menu offline_root_menu 1002 (std,mand) +variable loop_alarm_code enumerated 236 (std,imp) +member DIGITAL_UNITS collection 190 (std) +collection __ANALOG variable 952 (std) +variable transmitter_revision unsigned 157 (std,imp) +member MAXIMUM_LOWER_TRIM_POINT collection 4197 (std) +menu detailed_setup 7006 (std,imp) +member UPPER_ENDPOINT_VALUE 4086 (std) +command read_unique_identifier_with_long_tag 223 (std,imp) +member TREND_VALUE_0 collection 4514 (std) +member TREND_VALUE_1 collection 4518 (std) +member TREND_VALUE_2 collection 4522 (std) +member TREND_VALUE_3 collection 4526 (std) +member TREND_VALUE_4 collection 4530 (std) +variable private_label_distributor enumerated 168 (std,mand,imp) +member TREND_VALUE_5 collection 4534 (std) +member TREND_VALUE_6 collection 4538 (std) +member TREND_VALUE_7 collection 4542 (std) +member TREND_VALUE_8 collection 4546 (std) +member TREND_VALUE_9 collection 4550 (std) +member DEVICE_SPECIFIC_STATUS_18_LATCHED_VALUE collection 4684 (std) +member STATS collection 4795 (std,imp) +member TREND_VALUE_2_DEVICE_FAMILY_STATUS collection 4525 (std) +menu process_variables_root_menu 1019 (std,mand) +member BURST_VAR collection 4714 (std,imp) +member blk_dd_reference record 3221290756 +variable analog_channel_flags enumerated 2050 (std,imp) +member STANDARDIZED_STATUS_3_MASK collection 4653 (std) +member DEVICE_SPECIFIC_STATUS_20_LATCHED_VALUE collection 4686 (std) +method trim_warning 4225 (std,imp) +variable temperatureClassification enumerated 16395 +variable __analog_value float 961 (std) +member EVENT_DEBOUNCE_INTERVAL collection 4640 (std) +variable analog_output_numbers_code enumerated 2036 (std,imp) +member DEVICE_SPECIFIC_STATUS_3_LATCHED_VALUE collection 4670 (std) +member DEVICE_SPECIFIC_STATUS_2_MASK collection 4644 (std) +method check_num_wires_on_probe_type_change 16421 +variable temperatureMaximumLTP float 16409 +member SOURCE_MANUFACTURER 4213 (std) +member UPPER_TRIM_POINT collection 4199 (std) +variable device_variable_classification_codes enumerated 2043 (std,imp) +member PARAM210 parameter 3221266386 +member PARAM211 parameter 3221266387 +member PARAM212 parameter 3221266388 +member PARAM213 parameter 3221266389 +member PARAM214 parameter 3221266390 +member PARAM215 parameter 3221266391 +member PARAM216 parameter 3221266392 +variable analog_channel_fixed_codes enumerated 2263 (std,imp) +member PARAM217 parameter 3221266393 +member PARAM218 parameter 3221266394 +member PARAM219 parameter 3221266395 +member PARAM220 parameter 3221266396 +member PARAM221 parameter 3221266397 +member PARAM222 parameter 3221266398 +member PARAM223 parameter 3221266399 +member PARAM224 parameter 3221266400 +member PARAM225 parameter 3221266401 +member PARAM226 parameter 3221266402 +member PARAM227 parameter 3221266403 +member PARAM228 parameter 3221266404 +member PARAM229 parameter 3221266405 +variable tag packed-ascii 163 (std,mand,imp) +member PARAM230 parameter 3221266406 +member PARAM231 parameter 3221266407 +member PARAM232 parameter 3221266408 +member PARAM233 parameter 3221266409 +member TREND_VALUE_9_LIMIT_STATUS collection 4552 (std) +member PARAM234 parameter 3221266410 +collection __DEV variable 963 (std) +member PARAM235 parameter 3221266411 +member PARAM236 parameter 3221266412 +member PARAM237 parameter 3221266413 +member PARAM238 parameter 3221266414 +variable analog_channel_saturated1 bit-enumerated 4157 (std,imp) +member PARAM239 parameter 3221266415 +member SENSOR_SERIAL_NUMBER collection 199 (std) +variable loop_current_mode enumerated 2056 (std,imp) +variable software_revision unsigned 158 (std,mand,imp) +member PARAM240 parameter 3221266416 +member PARAM241 parameter 3221266417 +member PARAM242 parameter 3221266418 +member PARAM243 parameter 3221266419 +member PARAM244 parameter 3221266420 +member PARAM245 parameter 3221266421 +member PARAM246 parameter 3221266422 +member PARAM247 parameter 3221266423 +member ALARM_CODE collection 196 (std) +member PARAM248 parameter 3221266424 +member PARAM249 parameter 3221266425 +member DIGITAL_VALUE collection 191 (std) +command read_unique_identifier 174 (std,mand,imp) +variable __input_percent_range float 965 (std) +variable temperatureUpdatePeriod time_value 16458 +menu signal_conditioning 7012 (std,imp) +menu Menu_Main_Maintenance 1017 (std) +member TREND_VALUE_0_LIMIT_STATUS collection 4516 (std) +member DEVICE_SPECIFIC_STATUS_22_MASK collection 4663 (std) +variable thermocouple_probe_connection enumerated 16428 +method std_CheckDeviceIdentificationDownload 1015 (std) +member PARAM200 parameter 3221266376 +member PARAM201 parameter 3221266377 +member PARAM202 parameter 3221266378 +member STX_SENT collection 4331 (std) +member PARAM203 parameter 3221266379 +member TREND_VALUE_2_LIMIT_STATUS collection 4524 (std) +member PARAM204 parameter 3221266380 +member PARAM205 parameter 3221266381 +member PARAM206 parameter 3221266382 +member PARAM207 parameter 3221266383 +member PARAM208 parameter 3221266384 +member PARAM209 parameter 3221266385 +command write_long_tag 224 (std,imp) +member PARAM290 parameter 3221266466 +command write_message 185 (std,mand,imp) +member PARAM291 parameter 3221266467 +command reset_configuration_change_flag 4010 (std,imp) +member PARAM292 parameter 3221266468 +array subdevice_variables collection 4243 (std) +member PARAM293 parameter 3221266469 +variable analog_channel_saturated_codes enumerated 2262 (std,imp) +member PARAM294 parameter 3221266470 +member PARAM295 parameter 3221266471 +member PARAM296 parameter 3221266472 +write-as-one scaling_wao 252 (std,imp) +member PARAM297 parameter 3221266473 +member PARAM298 parameter 3221266474 +member PARAM299 parameter 3221266475 +variable loop_current_mode_codes enumerated 2041 (std,imp) +menu device_setup 7002 (std,imp) +member TREND_VALUE_4_LIMIT_STATUS collection 4532 (std) +variable alarm_selection_code enumerated 2027 (std,imp) +variable __sensor_units enumerated 957 (std) +menu process_variables 7003 (std,imp) +method std_CheckDeviceIdentificationUpload 1014 (std) +member _MNG_STATUS record 3221290753 +command read_long_tag 222 (std,imp) +command write_device_variable 4187 (std,imp) +variable uint8_var_blk_char unsigned 2147548932 +variable limit_status enumerated 2053 (std,imp) +member TREND_VALUE_6_LIMIT_STATUS collection 4540 (std) +member UNIV_CMD_REV collection 4265 (std) +variable write_device_variable_codes enumerated 2045 (std,imp) +member DATA_QUALITY collection 247 (std) +member TIME_OF_FIRST_UNACK_EVENT collection 4637 (std) +variable max_num_device_variables unsigned 206 (std,imp) +member MINIMUM_LOWER_TRIM_POINT collection 4196 (std) +member PARAM250 parameter 3221266426 +member PARAM251 parameter 3221266427 +member PARAM252 parameter 3221266428 +variable temperatureUnits enumerated 16393 +member PARAM253 parameter 3221266429 +member TREND_VALUE_3_LIMIT_STATUS collection 4528 (std) +member PARAM254 parameter 3221266430 +member PARAM255 parameter 3221266431 +member PARAM256 parameter 3221266432 +member PARAM257 parameter 3221266433 +member PARAM258 parameter 3221266434 +member PARAM259 parameter 3221266435 +variable si_control enumerated 4698 (std,imp) +member DEVICE_SPECIFIC_STATUS_23_LATCHED_VALUE collection 4689 (std) +member PARAM260 parameter 3221266436 +member PARAM261 parameter 3221266437 +member PARAM262 parameter 3221266438 +variable int8_var_blk_char integer 2147548931 +member PARAM263 parameter 3221266439 +member TREND_VALUE_8_LIMIT_STATUS collection 4548 (std) +member PARAM264 parameter 3221266440 +member CHANNEL collection 4261 (std) +member PARAM265 parameter 3221266441 +member PARAM266 parameter 3221266442 +member DAMPING_VALUE collection 198 (std) +member PARAM267 parameter 3221266443 +member PARAM268 parameter 3221266444 +member PARAM269 parameter 3221266445 +command read_unique_identifier_with_tag 179 (std,mand,imp) +member PARAM270 parameter 3221266446 +variable iout_type enumerated 16415 +member PARAM271 parameter 3221266447 +member PARAM272 parameter 3221266448 +member PARAM273 parameter 3221266449 +member TREND_VALUE_5_LIMIT_STATUS collection 4536 (std) +member PARAM274 parameter 3221266450 +member PARAM275 parameter 3221266451 +member PARAM276 parameter 3221266452 +member PARAM277 parameter 3221266453 +member PARAM278 parameter 3221266454 +member PARAM279 parameter 3221266455 +member PARAM280 parameter 3221266456 +member PARAM281 parameter 3221266457 +member PARAM282 parameter 3221266458 +member PARAM283 parameter 3221266459 +member PARAM284 parameter 3221266460 +member ANALOG_CHANNEL_SATURATED1_LATCHED_VALUE collection 4676 (std) +member PARAM285 parameter 3221266461 +member PARAM286 parameter 3221266462 +member PARAM287 parameter 3221266463 +member PARAM288 parameter 3221266464 +member PARAM289 parameter 3221266465 +variable __input_transfer_function enumerated 966 (std) +member PARAM110 parameter 3221266286 +member PARAM111 parameter 3221266287 +member PARAM112 parameter 3221266288 +member PARAM113 parameter 3221266289 +member PARAM114 parameter 3221266290 +menu upload_variables 1003 (std) +member PARAM115 parameter 3221266291 +member PARAM1 parameter 3221266176 +member PARAM116 parameter 3221266292 +member PARAM2 parameter 3221266177 +member TREND_VALUE_10 collection 4554 (std) +member PARAM117 parameter 3221266293 +member PARAM3 parameter 3221266178 +member TREND_VALUE_11 collection 4558 (std) +member PARAM118 parameter 3221266294 +member PARAM4 parameter 3221266179 +member PARAM119 parameter 3221266295 +member PARAM5 parameter 3221266180 +member PARAM6 parameter 3221266181 +member PARAM7 parameter 3221266182 +member PARAM8 parameter 3221266183 +member DEVICE_SPECIFIC_STATUS_3_MASK collection 4645 (std) +member PARAM9 parameter 3221266184 +variable manual_trim enumerated 16432 +member PARAM120 parameter 3221266296 +member PARAM121 parameter 3221266297 +member PARAM122 parameter 3221266298 +member PARAM123 parameter 3221266299 +member PARAM124 parameter 3221266300 +member PARAM125 parameter 3221266301 +member PARAM126 parameter 3221266302 +member PARAM127 parameter 3221266303 +member PARAM128 parameter 3221266304 +member PARAM129 parameter 3221266305 +variable real_time_clock_flag_codes enumerated 2251 (std,imp) +member PARAM130 parameter 3221266306 +member PARAM131 parameter 3221266307 +member PARAM132 parameter 3221266308 +member PARAM133 parameter 3221266309 +member PARAM134 parameter 3221266310 +variable units_code enumerated 2025 (std,imp) +menu upload_from_device_root_menu 1024 (std) +member PARAM135 parameter 3221266311 +member PARAM136 parameter 3221266312 +member PARAM137 parameter 3221266313 +member PARAM138 parameter 3221266314 +member PARAM139 parameter 3221266315 +member PARAM140 parameter 3221266316 +member PARAM141 parameter 3221266317 +member PARAM142 parameter 3221266318 +member PARAM143 parameter 3221266319 +member PARAM144 parameter 3221266320 +member PARAM145 parameter 3221266321 +member PARAM146 parameter 3221266322 +member PARAM147 parameter 3221266323 +member PARAM148 parameter 3221266324 +member PARAM149 parameter 3221266325 +method leave_fixed_current_mode 4099 (std,imp) +variable probe_connection enumerated 16426 +member UPDATE_TIME_PERIOD collection 4701 (std,imp) +command write_polling_address 178 (std,mand,imp) +member DEVICE_FAMILY_STATUS collection 249 (std) +method SimulatePV 16385 +variable number_of_wires enumerated 16423 +unit __input_units_relation 976 (std) +member LOWER_TRIM_POINT collection 4198 (std) +edit-display __key_pad_rerange 954 (std) +variable __sensor_serial_number unsigned 955 (std) +variable two_wire_resistance float 16433 +member blk_dd_revision record 3221290757 +member PARAM100 parameter 3221266276 +member PARAM101 parameter 3221266277 +member PARAM102 parameter 3221266278 +member MANUFID collection 4262 (std) +variable probe_type enumerated 16422 +member PARAM103 parameter 3221266279 +member PARAM104 parameter 3221266280 +member PARAM105 parameter 3221266281 +member PARAM106 parameter 3221266282 +member PARAM107 parameter 3221266283 +member PARAM108 parameter 3221266284 +member PARAM109 parameter 3221266285 +variable temperatureUpperTrimPoint float 16406 +member MAX_UPDATE_TIME collection 4639 (std) +member DEVICE_SPECIFIC_STATUS_17_MASK collection 4658 (std) +member PARAM190 parameter 3221266366 +member PARAM191 parameter 3221266367 +member PARAM192 parameter 3221266368 +member PARAM193 parameter 3221266369 +command enter_exit_fixed_pv_current_mode 4013 (std,imp) +member PARAM194 parameter 3221266370 +member PARAM195 parameter 3221266371 +array burst_event_array collection 4575 (std) +member PARAM196 parameter 3221266372 +variable temperatureMinimumUTP float 16410 +member PARAM197 parameter 3221266373 +member PARAM198 parameter 3221266374 +member PARAM199 parameter 3221266375 +member STANDARDIZED_STATUS_0_MASK collection 4649 (std) +member QUALITY_STATUS collection 4774 (std,imp) +record hart_blk_char 2147548929 +variable percentRange float 233 (std,imp) +member SOURCE_COMMAND_NUMBER 4216 (std) +variable temperatureFamily enumerated 16394 +variable device_variable_trim_code index 4223 (std,imp) +menu Menu_Main_Specialist 1012 (std) +member PARAM150 parameter 3221266326 +collection analog_io variable 240 (std,imp) +member PARAM151 parameter 3221266327 +member PARAM152 parameter 3221266328 +command trim_pv_current_dac_zero 4018 (std,imp) +member PARAM153 parameter 3221266329 +member PARAM154 parameter 3221266330 +member ANALOG_CHANNEL_FIXED1_MASK collection 4654 (std) +member PARAM155 parameter 3221266331 +member PARAM156 parameter 3221266332 +command cmd128 16418 +member PARAM157 parameter 3221266333 +command cmd129 16419 +member PARAM158 parameter 3221266334 +member PARAM159 parameter 3221266335 +member DEVICE_SPECIFIC_STATUS_22_LATCHED_VALUE collection 4688 (std) +member PARAM160 parameter 3221266336 +member PARAM161 parameter 3221266337 +member PARAM162 parameter 3221266338 +member PARAM163 parameter 3221266339 +member DEVICE_SPECIFIC_STATUS_1_LATCHED_VALUE collection 4668 (std) +member PARAM164 parameter 3221266340 +command cmd136 16420 +member PARAM165 parameter 3221266341 +member PARAM166 parameter 3221266342 +member PARAM167 parameter 3221266343 +member PARAM168 parameter 3221266344 +member PARAM169 parameter 3221266345 +member PARAM170 parameter 3221266346 +command cmd142 16438 +member PARAM171 parameter 3221266347 +member TREND_VALUE_11_LIMIT_STATUS collection 4560 (std) +command cmd143 16439 +member PARAM172 parameter 3221266348 +member PARAM173 parameter 3221266349 +member PARAM174 parameter 3221266350 +member PARAM175 parameter 3221266351 +member PARAM176 parameter 3221266352 +member ANALOG_CHANNEL 212 (std) +member PARAM177 parameter 3221266353 +member PARAM178 parameter 3221266354 +member PARAM179 parameter 3221266355 +variable __sensor_damping_value float 964 (std) +member LOWER_SENSOR_LIMIT collection 202 (std) +member PARAM180 parameter 3221266356 +member MAX_UPDATE_PERIOD collection 4384 (std) +member PARAM181 parameter 3221266357 +member PARAM182 parameter 3221266358 +member DEVICE_STATUS 216 (std) +member PARAM183 parameter 3221266359 +member PARAM184 parameter 3221266360 +member PARAM185 parameter 3221266361 +member PARAM186 parameter 3221266362 +member SENSOR_UPPER_TRIM 6007 (std) +member PARAM187 parameter 3221266363 +member PARAM188 parameter 3221266364 +member PARAM189 parameter 3221266365 +member DEVICE_FAMILY collection 253 (std) +command cmd160 16440 +command cmd161 16441 +command cmd162 16442 +member UPPER_RANGE_VALUE collection 204 (std) +command cmd163 16443 +variable temperatureMinimumTrimDiff float 16412 +method post_send_configuration_method 1008 (std) +menu hot_key 1001 (std) +variable hart_pass_local_new unsigned 16391 +member SUB_DEVICE_MAPPING collection 4801 (std,imp) +variable device_variable_family_codes enumerated 2049 (std,imp) +member BURST_TRIGGER_MODE collection 4394 (std) +member TRANSFER_FUNCTION collection 197 (std) +variable extended_fld_device_status bit-enumerated 2055 (std,imp) +variable __new_digital_units enumerated 970 (std) +member TREND_VALUE_10_LIMIT_STATUS collection 4556 (std) +variable device_status bit-enumerated 151 (std,mand,imp) +member blk_index_dis record 3221290762 +block _MNG_HART_BLOCK 2147548928 +member BURST_CLASSIFICATION collection 4395 (std) +member ANALOG_UNITS 4070 (std) +variable device_variable_code index 4167 (std,imp) +variable real_time_clock_flag bit-enumerated 4444 (std,imp) +member DEVICE_VARIABLE collection 211 (std) +variable thermocouple_coldjunction_type enumerated 16429 +member STANDARDIZED_STATUS_1_LATCHED_VALUE collection 4675 (std) +array factory_protection_array variable 1006 (std) +variable uint32_var_blk_char unsigned 2147548934 +variable temperatureDampingValue float 16402 +command read_message 180 (std,mand,imp) +command read_device_variables_and_status 221 (std,imp) +array IO_card_channel array 4356 (std) +variable __input_lower_value float 969 (std) +variable __input_units enumerated 967 (std) +member DEVICE_SPECIFIC_STATUS_23_MASK collection 4664 (std) +member DUMMY 246 (std) +variable response_code enumerated 150 (std,mand,imp) +menu fms_menu 2147548944 +variable flag_assignment enumerated 2034 (std,imp) +variable __sensor_value float 956 (std) +variable temperatureTrimSupport enumerated 16407 +member SENSOR_UNITS 200 (std) +menu analog_output 7017 (std,imp) +variable master_mode_code enumerated 2261 (std,imp) +member CONFIG_CHANGE_COUNTER_LATCHED_VALUE collection 4691 (std) +variable temperaturePDQ enumerated 16396 +variable hart_functions bit-enumerated 1005 (std) +member LONG_TAG collection 4266 (std) +variable polling_address unsigned 162 (std,mand,imp) +collection tertiary_variable collection 244 (std,imp) +member DEVICE_SPECIFIC_STATUS_15_LATCHED_VALUE collection 4681 (std) +collection __pv variable 974 (std) +command read_tag_descriptor_date 181 (std,mand,imp) +member RANGING collection 214 (std) +member DEV_ID collection 4264 (std) +variable __input_upper_value float 968 (std) +command read_additional_device_status 4163 (std,imp) +member DEVICE_SPECIFIC_STATUS_4_LATCHED_VALUE collection 4671 (std) +menu Table_Main_Maintenance 1011 (std) +member MAXIMUM_UPPER_TRIM_POINT collection 4201 (std) +refresh __x1 972 (std) +refresh __x2 973 (std) +member BURST_VAR_0 collection 4386 (std) +member BURST_VAR_1 collection 4387 (std) +member BURST_VAR_2 collection 4388 (std) +member BURST_VAR_3 collection 4389 (std) +member ACK_RECEIVED collection 4332 (std) +member PARAM310 parameter 3221266486 +member BURST_VAR_4 collection 4390 (std) +member PARAM311 parameter 3221266487 +member BURST_VAR_5 collection 4391 (std) +member PARAM312 parameter 3221266488 +member BURST_VAR_6 collection 4392 (std) +member PARAM313 parameter 3221266489 +member BURST_VAR_7 collection 4393 (std) +member PARAM314 parameter 3221266490 +member PARAM315 parameter 3221266491 +member TREND_0_DATE_STAMP collection 4512 (std) +member PARAM316 parameter 3221266492 +member PARAM317 parameter 3221266493 +array burst_message_array collection 4434 (std) +member PARAM318 parameter 3221266494 +member PARAM319 parameter 3221266495 +variable standardized_status_2_codes enumerated 2243 (std,imp) +member PARAM320 parameter 3221266496 +member PARAM321 parameter 3221266497 +member PARAM322 parameter 3221266498 +member PARAM323 parameter 3221266499 +member TREND_SAMPLE_INTERVAL collection 4511 (std) +member PARAM324 parameter 3221266500 +member PARAM325 parameter 3221266501 +member PARAM326 parameter 3221266502 +member PARAM327 parameter 3221266503 +member PARAM328 parameter 3221266504 +member PARAM329 parameter 3221266505 +member DEVICE_SPECIFIC_STATUS_4_MASK collection 4646 (std) +variable loopCurrent float 237 (std,imp) +method device_master_reset 4210 (std,imp) +member PARAM330 parameter 3221266506 +member LOWER_RANGE_VALUE collection 205 (std) +member blk_profile record 3221290758 +member PVS source 16453 +command perform_device_reset 4138 (std,imp) +variable capture_mode_codes enumerated 2051 (std,imp) +member DEVICE_SPECIFIC_STATUS_14_MASK collection 4655 (std) +member MINIMUM_UPPER_TRIM_POINT collection 4200 (std) +variable temperatureSerialNumber unsigned 16404 +member TREND_VALUE collection 4842 (std,imp) +command write_number_of_response_preambles 4067 (std,imp) +method transmitter_dac_trim 4132 (std,imp) +member STANDARDIZED_STATUS_0_LATCHED_VALUE collection 4674 (std) +member IO_CARD collection 4260 (std) +command read_pv_sensor_info 182 (std,mand,imp) +variable temperatureValue float 16392 +variable extended_device_status_codes enumerated 2042 (std,imp) +member PARAM300 parameter 3221266476 +member PARAM301 parameter 3221266477 +member PARAM302 parameter 3221266478 +image device_icon 1021 (std) +member PARAM303 parameter 3221266479 +member PARAM304 parameter 3221266480 +member PARAM305 parameter 3221266481 +command cmd1024 16434 +member PARAM306 parameter 3221266482 +command cmd1025 16435 +member PARAM307 parameter 3221266483 +command cmd1026 16436 +member PARAM308 parameter 3221266484 +menu status_display 7000 (std,imp) +member PARAM309 parameter 3221266485 +member TRIM_POINT_UNITS collection 4121 (std) +variable si_control_codes enumerated 2255 (std,imp) +member ANALOG_DAMPING 4078 (std) +member OSTX_RECEIVED collection 4333 (std) +member DEVICE_SPECIFIC_STATUS_18_MASK collection 4659 (std) +variable standardized_status_0 bit-enumerated 260 (std,imp) +method device_variable_trim 4224 (std,imp) +variable standardized_status_1 bit-enumerated 261 (std,imp) +member DEVICE_CLASS 215 (std) +variable standardized_status_2 bit-enumerated 262 (std,imp) +variable standardized_status_3 bit-enumerated 263 (std,imp) +array subdevice_burst_array variable 4321 (std) +command read_device_variable_trim_point 4166 (std,imp) +member DEV_OPERATING_MODE_MASK collection 4843 (std) +command read_device_variable_information 4183 (std,imp) +member STANDARDIZED_STATUS_1_MASK collection 4650 (std) +member TREND_VALUE_1_LIMIT_STATUS collection 4520 (std) +member UPPER_LIMIT_VALUE 4886 (std) +menu diagnostic_root_menu 1018 (std,mand) +member DEVICE_SPECIFIC_STATUS_14_LATCHED_VALUE collection 4680 (std) +member RANGE_UNITS collection 203 (std) +method std_CheckDeviceIdentification 1013 (std) +method __univ_self_test 953 (std) +member DEVICE_SPECIFIC_STATUS_24_LATCHED_VALUE collection 4690 (std) +menu root_menu 1000 (std,mand) +member UPPER_SENSOR_LIMIT collection 201 (std) +variable write_protect_code enumerated 2030 (std,imp) +member TREND_VALUE_7_DATA_QUALITY collection 4543 (std) +menu menu_sensor_info 7001 (std,imp) +command write_final_assembly_number 187 (std,mand,imp) +member EVENT_CONTROL collection 4758 (std,imp) +variable standardized_status_3_codes enumerated 2244 (std,imp) +member ANALOG_CHANNEL_FIXED1_LATCHED_VALUE collection 4679 (std) +variable temperature_device_family_status0 enumerated 16427 +menu output_conditioning 7013 (std,imp) +menu device_revisions 7016 (std,imp) +variable temperature_status_local bit-enumerated 16384 +member DEVICE_SPECIFIC_STATUS_0_MASK collection 4642 (std) +member EXP_DEV_TYPE collection 4263 (std) +command set_pv_upper_range_value 4008 (std,imp) +menu menu_sensor_trim 16447 +variable temperatureSimulated enumerated 16413 +variable manufacturer_id enumerated 153 (std,mand,imp) +unit __sensor_units_relation 975 (std) +member SIMULATED collection 4847 (std,imp) +variable write_protect enumerated 167 (std,mand,imp) +variable temperature_standart enumerated 16425 +member DEVICE_SPECIFIC_STATUS_20_MASK collection 4661 (std) +variable descriptor packed-ascii 165 (std,mand,imp) +member TRIM_POINT_SUPPORT collection 4120 (std) +variable request_preambles unsigned 155 (std,mand,imp) +collection sensor_a variable 6001 (std) +variable country_code_codes enumerated 2259 (std,imp) +variable temperatureUSL float 16399 +member TREND_0_TIME_STAMP collection 4513 (std) +variable standardized_status_1_codes enumerated 2242 (std,imp) +member STANDARDIZED_STATUS_3_LATCHED_VALUE collection 4678 (std) +variable upperRange_value float 234 (std,imp) +variable temperatureStatus bit-enumerated 16398 +menu Table_Main_Specialist 1010 (std) +command trim_pv_current_dac_gain 4019 (std,imp) +member EVENT_MASK collection 4760 (std,imp) +variable temperatureLowerTrimPoint float 16405 +member STAT_BACK_CNT collection 4269 (std) +member MINIMUM_SPAN collection 195 (std) +command read_device_variable_trim_guidelines 4170 (std,imp) +array loop_warning_variables variable 1004 (std) +member SENSOR_LOWER_TRIM 6005 (std) +variable iout_alarm_high_value float 16417 +member DEVICE_SPECIFIC_STATUS_24_MASK collection 4665 (std) +member COMMAND_NUMBER collection 4381 (std) +variable temperatureMinimumLTP float 16408 +source pvsrcg 16454 +member DEVICE_SPECIFIC_STATUS_17_LATCHED_VALUE collection 4683 (std) +variable analog_channel_fixed1 bit-enumerated 4160 (std,imp) +collection scaling variable 239 (std,imp) +variable config_change_counter unsigned 207 (std,imp) +member MINIMUM_TRIM_DIFFERENTIAL collection 4156 (std) +method applied_rerange 4103 (std,imp) +variable transfer_function_code enumerated 2026 (std,imp) +variable device_variable_code_1 index 225 (std,imp) +member EVENT_NOTIFICATION_RETRY_TIME collection 4638 (std) +variable device_variable_code_2 index 226 (std,imp) +variable device_variable_code_3 index 227 (std,imp) +variable device_variable_code_4 index 228 (std,imp) +variable device_variable_code_5 index 256 (std,imp) +variable device_variable_code_6 index 257 (std,imp) +variable device_variable_code_7 index 258 (std,imp) +variable __analog_alarm_code enumerated 962 (std) +variable device_variable_code_8 index 259 (std,imp) +command read_pv 175 (std,mand,imp) +variable __sensor_minimum_span float 960 (std) +member TREND_VALUE_11_DATA_QUALITY collection 4559 (std) +member DEVICE_SPECIFIC_STATUS_2_LATCHED_VALUE collection 4669 (std) +variable longTag ascii 210 (std,imp) +member TRIGGER_LEVEL collection 4397 (std) +command set_pv_lower_range_value 4009 (std,imp) +command read_pv_output_info 183 (std,mand,imp) +variable temperatureMaximumUTP float 16411 +variable process_data_status enumerated 2052 (std,imp) +member DEVICE_SPECIFIC_STATUS_5_MASK collection 4647 (std) +menu menu_temper_family 16444 +menu review 7007 (std,imp) +member TREND_VALUE_10_DATA_QUALITY collection 4555 (std) +menu download_to_device_root_menu 1023 (std) +command reset_device_variable_trim 4177 (std,imp) +menu device_service 16448 +member TREND_VALUES collection 4779 (std,imp) +member DEV_OPERATING_MODE_LATCHED_VALUE collection 4844 (std) +member TREND_VALUE_8_DEVICE_FAMILY_STATUS collection 4549 (std) +member TREND_CONTROL collection 4507 (std) +array subDevice collection 4220 (std) +variable temperatureMinimumSpan float 16401 +menu measuring_elements 7011 (std,imp) +variable device_profile enumerated 255 (std,imp) +member TREND_VALUE_3_DEVICE_FAMILY_STATUS collection 4529 (std) +menu device_info 7010 (std,imp) +member PROPERTIES 4887 (std) +member PERCENT_RANGE collection 194 (std) diff --git a/app/src/main/java/ru/kaptsov/hartmobile/MainActivity.kt b/app/src/main/java/ru/kaptsov/hartmobile/MainActivity.kt new file mode 100644 index 0000000..93b14e0 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/MainActivity.kt @@ -0,0 +1,50 @@ +package ru.kaptsov.hartmobile + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupWithNavController +import ru.kaptsov.hartmobile.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { /* разрешения запрошены, фрагмент сам обработает результат */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + + val navHost = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val navController = navHost.navController + val appBarConfig = AppBarConfiguration(setOf(R.id.scanFragment)) + binding.toolbar.setupWithNavController(navController, appBarConfig) + + requestBluetoothPermissions() + } + + private fun requestBluetoothPermissions() { + val needed = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) + != PackageManager.PERMISSION_GRANTED) + needed += Manifest.permission.BLUETOOTH_SCAN + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED) + needed += Manifest.permission.BLUETOOTH_CONNECT + } + if (needed.isNotEmpty()) permissionLauncher.launch(needed.toTypedArray()) + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/connection/BluetoothSppConnection.kt b/app/src/main/java/ru/kaptsov/hartmobile/connection/BluetoothSppConnection.kt new file mode 100644 index 0000000..2f8a387 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/connection/BluetoothSppConnection.kt @@ -0,0 +1,117 @@ +package ru.kaptsov.hartmobile.connection + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.util.Log +import java.io.IOException +import java.util.UUID + +@SuppressLint("MissingPermission") +class BluetoothSppConnection(private val device: BluetoothDevice) : IConnection { + + companion object { + private const val TAG = "HART_BT" + val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") + const val DEVICE_NAME_PREFIX = "BriC_BT_HART" + } + + private var socket: BluetoothSocket? = null + + override val isConnected: Boolean + get() = socket?.isConnected == true + + override fun connect() { + Log.i(TAG, "=== BT CONNECT START === device=${device.name} addr=${device.address}") + socket?.close() + socket = device.createRfcommSocketToServiceRecord(SPP_UUID) + Log.d(TAG, "Socket created (SPP UUID=$SPP_UUID)") + try { + socket!!.connect() + Log.i(TAG, "BT connected (primary method OK)") + } catch (e: IOException) { + Log.w(TAG, "Primary connect failed: ${e.message}, trying fallback via reflection...") + try { + val fallback = device::class.java + .getMethod("createRfcommSocket", Int::class.java) + .invoke(device, 1) as BluetoothSocket + socket?.close() + socket = fallback + socket!!.connect() + Log.i(TAG, "BT connected (fallback reflection OK)") + } catch (e2: Exception) { + Log.e(TAG, "Fallback connect also failed: ${e2.message}") + socket?.close() + socket = null + throw IOException("Не удалось подключиться по BT: ${e.message}") + } + } + Log.i(TAG, "=== BT CONNECT OK === isConnected=$isConnected") + } + + override fun write(data: ByteArray) { + Log.d(TAG, "BT WRITE ${data.size} bytes: ${data.toHex()}") + socket?.outputStream?.write(data) + ?: throw IOException("BT: сокет не открыт") + Log.d(TAG, "BT WRITE complete") + } + + override fun read(timeoutMs: Long): ByteArray { + val stream = socket?.inputStream + ?: throw IOException("BT: сокет не открыт") + + val buffer = ByteArray(256) + val result = mutableListOf() + val deadline = System.currentTimeMillis() + timeoutMs + var loopCount = 0 + + Log.d(TAG, "BT READ start (timeout=${timeoutMs}ms)") + + while (System.currentTimeMillis() < deadline) { + loopCount++ + val available = stream.available() + if (available > 0) { + val read = stream.read(buffer, 0, minOf(available, buffer.size)) + if (read > 0) { + result.addAll(buffer.take(read)) + Log.d(TAG, "BT READ chunk: $read bytes (total=${result.size}), data=${buffer.take(read).toByteArray().toHex()}") + val pause = System.currentTimeMillis() + 100 + while (System.currentTimeMillis() < pause && stream.available() == 0) { + Thread.sleep(10) + } + if (stream.available() == 0) { + Log.d(TAG, "BT READ no more data after 100ms pause, breaking") + break + } + } + } else { + Thread.sleep(20) + } + } + + val resultArray = result.toByteArray() + val elapsed = timeoutMs - (deadline - System.currentTimeMillis()) + Log.i(TAG, "BT READ done: ${resultArray.size} bytes in ~${elapsed}ms (loops=$loopCount): ${resultArray.toHex()}") + return resultArray + } + + override fun flushInput() { + try { + val stream = socket?.inputStream ?: return + val buf = ByteArray(256) + while (stream.available() > 0) { + val n = stream.read(buf) + Log.d(TAG, "BT FLUSH discarded $n bytes") + } + } catch (_: IOException) {} + } + + override fun close() { + Log.i(TAG, "BT CLOSE") + try { socket?.close() } catch (_: IOException) {} + socket = null + } +} + +private fun ByteArray.toHex(): String = + joinToString(" ") { "%02X".format(it) } diff --git a/app/src/main/java/ru/kaptsov/hartmobile/connection/IConnection.kt b/app/src/main/java/ru/kaptsov/hartmobile/connection/IConnection.kt new file mode 100644 index 0000000..76f5f45 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/connection/IConnection.kt @@ -0,0 +1,28 @@ +package ru.kaptsov.hartmobile.connection + +/** + * Единый интерфейс для BT и USB соединений. + * Оба предоставляют поток байт (HART-фреймы). + */ +interface IConnection { + val isConnected: Boolean + + /** Открыть соединение. Бросает IOException при ошибке. */ + fun connect() + + /** Отправить байты */ + fun write(data: ByteArray) + + /** + * Прочитать ответ с таймаутом. + * @param timeoutMs максимальное время ожидания в мс + * @return принятые байты или пустой массив при таймауте + */ + fun read(timeoutMs: Long = 1000L): ByteArray + + /** Очистить входной буфер (сбросить накопившиеся байты) */ + fun flushInput() + + /** Закрыть соединение */ + fun close() +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/connection/UsbSerialConnection.kt b/app/src/main/java/ru/kaptsov/hartmobile/connection/UsbSerialConnection.kt new file mode 100644 index 0000000..8725a11 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/connection/UsbSerialConnection.kt @@ -0,0 +1,121 @@ +package ru.kaptsov.hartmobile.connection + +import android.content.Context +import android.hardware.usb.UsbManager +import android.util.Log +import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.hoho.android.usbserial.driver.UsbSerialPort +import java.io.IOException + +class UsbSerialConnection( + private val context: Context, + private val driver: UsbSerialDriver +) : IConnection { + + companion object { + private const val TAG = "HART_USB" + const val BAUD_RATE = 1200 + const val DATA_BITS = 8 + val PARITY = UsbSerialPort.PARITY_NONE + val STOP_BITS = UsbSerialPort.STOPBITS_1 + const val READ_TIMEOUT_MS = 200 + } + + private var port: UsbSerialPort? = null + + override val isConnected: Boolean + get() = port?.isOpen == true + + override fun connect() { + val dev = driver.device + Log.i(TAG, "=== USB CONNECT START === vid=0x%04X pid=0x%04X name=%s".format( + dev.vendorId, dev.productId, dev.deviceName)) + + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val connection = usbManager.openDevice(driver.device) + ?: throw IOException("USB: нет разрешения или устройство отключено").also { + Log.e(TAG, "openDevice returned null — no permission or disconnected") + } + + Log.d(TAG, "USB device opened, ports count=${driver.ports.size}") + port = driver.ports[0] + port!!.open(connection) + Log.d(TAG, "USB port opened, setting params: baud=$BAUD_RATE data=$DATA_BITS parity=NONE stop=1") + port!!.setParameters(BAUD_RATE, DATA_BITS, STOP_BITS, PARITY) + Log.i(TAG, "=== USB CONNECT OK === isOpen=${port?.isOpen}") + } + + override fun write(data: ByteArray) { + Log.d(TAG, "USB WRITE ${data.size} bytes: ${data.toHex()}") + port?.write(data, 1000) + ?: throw IOException("USB: порт не открыт") + Log.d(TAG, "USB WRITE complete") + } + + override fun read(timeoutMs: Long): ByteArray { + val p = port ?: throw IOException("USB: порт не открыт") + val result = mutableListOf() + val buffer = ByteArray(256) + val deadline = System.currentTimeMillis() + timeoutMs + var loopCount = 0 + var totalRead = 0 + + Log.d(TAG, "USB READ start (timeout=${timeoutMs}ms)") + + while (System.currentTimeMillis() < deadline) { + loopCount++ + val read = try { + p.read(buffer, READ_TIMEOUT_MS) + } catch (e: Exception) { + Log.w(TAG, "USB READ exception in loop $loopCount: ${e.message}") + 0 + } + + if (read > 0) { + result.addAll(buffer.take(read)) + totalRead += read + Log.d(TAG, "USB READ chunk: $read bytes (total=$totalRead), data=${buffer.take(read).toByteArray().toHex()}") + if (read < buffer.size) { + Thread.sleep(50) + val more = try { p.read(buffer, READ_TIMEOUT_MS) } catch (_: Exception) { 0 } + if (more > 0) { + result.addAll(buffer.take(more)) + totalRead += more + Log.d(TAG, "USB READ extra: $more bytes (total=$totalRead)") + } + break + } + } else { + Thread.sleep(20) + } + } + + val resultArray = result.toByteArray() + val elapsed = timeoutMs - (deadline - System.currentTimeMillis()) + Log.i(TAG, "USB READ done: ${resultArray.size} bytes in ~${elapsed}ms (loops=$loopCount): ${resultArray.toHex()}") + return resultArray + } + + override fun flushInput() { + try { + val p = port ?: return + val buf = ByteArray(256) + var flushed = 0 + while (true) { + val n = try { p.read(buf, 50) } catch (_: Exception) { 0 } + if (n <= 0) break + flushed += n + } + if (flushed > 0) Log.d(TAG, "USB FLUSH discarded $flushed bytes") + } catch (_: IOException) {} + } + + override fun close() { + Log.i(TAG, "USB CLOSE") + try { port?.close() } catch (_: IOException) {} + port = null + } +} + +private fun ByteArray.toHex(): String = + joinToString(" ") { "%02X".format(it) } diff --git a/app/src/main/java/ru/kaptsov/hartmobile/dd/DdManager.kt b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdManager.kt new file mode 100644 index 0000000..a4e7255 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdManager.kt @@ -0,0 +1,170 @@ +package ru.kaptsov.hartmobile.dd + +import android.content.Context +import android.net.Uri +import java.io.File + +/** + * Управление DD-файлами: + * — хранение в filesDir/dd_files/ + * — импорт из любого URI (файловый менеджер, Downloads, etc.) + * — автопоиск DD для подключённого устройства по manufacturerId + deviceType + */ +object DdManager { + + private const val DIR = "dd_files" + + private fun ddDir(context: Context): File = + File(context.filesDir, DIR).also { it.mkdirs() } + + /** Список всех сохранённых DD файлов */ + fun listFiles(context: Context): List = + ddDir(context).listFiles()?.sortedBy { it.name } ?: emptyList() + + /** + * Импортировать DD файл из URI (пользователь выбрал через файловый менеджер). + * Файл копируется в локальное хранилище и сразу парсится. + * @return распарсенный документ или null при ошибке + */ + fun importFromUri(context: Context, uri: Uri, originalName: String): DdDocument? { + return try { + val source = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return null + val destFile = File(ddDir(context), sanitizeName(originalName)) + destFile.writeBytes(source) + parse(destFile) + } catch (e: Exception) { + null + } + } + + /** + * Сохранить DD для устройства с именем по manufacturerId + deviceType. + * Используется при автоматическом сохранении DD из папки test_dd или ассетов. + */ + fun saveForDevice(context: Context, source: ByteArray, manufacturerId: Int, deviceType: Int, ext: String = "sym"): DdDocument? { + return try { + val name = "%04X_%04X.%s".format(manufacturerId, deviceType, ext) + val destFile = File(ddDir(context), name) + destFile.writeBytes(source) + parse(destFile) + } catch (e: Exception) { + null + } + } + + /** Удалить DD файл */ + fun deleteFile(context: Context, fileName: String) { + File(ddDir(context), fileName).delete() + } + + /** + * Загрузить DD для устройства по manufacturerId + deviceType. + * Сначала ищет в локальном хранилище, затем в assets/dd/. + */ + fun findForDevice(context: Context, manufacturerId: Int, deviceType: Int): DdDocument? { + val files = listFiles(context) + // 1. Поиск по имени файла: 602A_E30E.sym или 00602A_E30E.sym + val keyShort = "%04X_%04X".format(manufacturerId, deviceType) + val keyLong = "%06X_%04X".format(manufacturerId, deviceType) + val byName = files.firstOrNull { f -> + val base = f.nameWithoutExtension.uppercase() + base == keyShort.uppercase() || base == keyLong.uppercase() + } + if (byName != null) { + val doc = parse(byName) + if (doc != null) return doc + } + // 2. Поиск по содержимому DEVICE_DEFINITION + val byContent = files.firstNotNullOfOrNull { file -> + val doc = parse(file) ?: return@firstNotNullOfOrNull null + if (doc.deviceInfo.manufacturerId == manufacturerId && + doc.deviceInfo.deviceType == deviceType) doc + else null + } + if (byContent != null) return byContent + + // 3. Поиск в assets/dd/ — копируем в локальное хранилище + return loadFromAssets(context, manufacturerId, deviceType) + } + + /** + * Ищет DD файл в assets/dd/ по manufacturerId + deviceType. + * Если найден — копирует в dd_files/ и возвращает распарсенный документ. + */ + private fun loadFromAssets(context: Context, manufacturerId: Int, deviceType: Int): DdDocument? { + val keyShort = "%04X_%04X".format(manufacturerId, deviceType) + val extensions = listOf("sym", "dd", "ddl") + for (ext in extensions) { + val assetName = "dd/$keyShort.$ext" + try { + val bytes = context.assets.open(assetName).use { it.readBytes() } + val destFile = File(ddDir(context), "$keyShort.$ext") + destFile.writeBytes(bytes) + val doc = parse(destFile) + if (doc != null) return doc + } catch (_: Exception) { + // Файл не найден в assets — пробуем следующее расширение + } + } + return null + } + + /** Загрузить конкретный файл по имени */ + fun load(context: Context, fileName: String): DdDocument? { + return try { parse(File(ddDir(context), fileName)) } catch (_: Exception) { null } + } + + // ---- Приватные ---- + + /** + * Определяет, является ли файл бинарным (не текстовым). + * Проверяет первые байты на наличие управляющих символов, исключая \r \n \t. + */ + fun isBinaryFile(file: File): Boolean { + return try { + val header = ByteArray(64) + val read = file.inputStream().use { it.read(header) } + (0 until read).any { i -> + val b = header[i].toInt() and 0xFF + b < 9 || b == 11 || b == 12 || b in 14..31 || b == 127 + } + } catch (_: Exception) { false } + } + + /** + * Парсит текстовый DD файл. Возвращает null для бинарных файлов или при ошибке. + * Автоматически определяет формат: .sym (таблица символов) или DDL (текстовый). + */ + fun parse(file: File): DdDocument? { + if (isBinaryFile(file)) return null + return try { + val text = file.readText(Charsets.UTF_8) + if (isSymFormat(file.name, text)) { + SymParser.parse(text, file.name) + } else { + DdParser.parse(text, file.name) + } + } catch (_: Exception) { null } + } + + /** + * Определяет, является ли файл таблицей символов (.sym). + * Проверяет расширение и содержимое (строки начинаются с variable/command/menu/method/member). + */ + private fun isSymFormat(fileName: String, text: String): Boolean { + if (fileName.endsWith(".sym", ignoreCase = true)) return true + // Эвристика: если первые 10 непустых строк начинаются с типичных .sym типов + val symTypes = setOf("variable", "command", "menu", "method", "member", "array", "record", "collection", "refresh") + val firstLines = text.lineSequence().filter { it.isNotBlank() }.take(10).toList() + if (firstLines.isEmpty()) return false + val matchCount = firstLines.count { line -> + val firstWord = line.trim().split(Regex("\\s+")).firstOrNull() ?: "" + firstWord in symTypes + } + return matchCount >= 5 + } + + private fun sanitizeName(name: String): String = + name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_") +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/dd/DdModel.kt b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdModel.kt new file mode 100644 index 0000000..5665a8a --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdModel.kt @@ -0,0 +1,69 @@ +package ru.kaptsov.hartmobile.dd + +/** Идентификация устройства из DEVICE_DEFINITION */ +data class DdDeviceInfo( + val manufacturerId: Int = -1, + val deviceType: Int = -1, + val deviceRevision: Int = -1 +) + +/** Переменная из блока VARIABLE */ +data class DdVariable( + val name: String, + val label: String, + val type: String = "", // FLOAT, INTEGER, ENUMERATED, etc. + val classDd: String = "", // DYNAMIC, STATIC, etc. + val help: String = "" +) + +/** Команда из блока COMMAND */ +data class DdCommand( + val number: Int, + val label: String, + val responseVars: List = emptyList() +) + +/** Метод из DD файла */ +data class DdMethod( + val name: String, + val label: String, + val isStandard: Boolean = false +) + +/** Элемент меню */ +sealed class DdMenuItem { + data class SubMenu(val name: String, var label: String = "") : DdMenuItem() + data class Var(val name: String, var label: String = "") : DdMenuItem() + data class Cmd(val number: Int, val label: String = "") : DdMenuItem() + data class Method(val name: String, val label: String = "") : DdMenuItem() +} + +/** Меню из блока MENU */ +data class DdMenu( + val name: String, + val label: String, + val items: List +) + +/** Весь распарсенный DD-документ */ +data class DdDocument( + val fileName: String, + val deviceInfo: DdDeviceInfo, + val menus: Map, + val variables: Map, + val commands: Map, + val rootMenuName: String? // имя первого MENU верхнего уровня +) { + /** Ключ для хранения: "manufacturerId_deviceType" в hex */ + val storageKey: String + get() = if (deviceInfo.manufacturerId >= 0 && deviceInfo.deviceType >= 0) + "%04X_%04X".format(deviceInfo.manufacturerId, deviceInfo.deviceType) + else fileName + + fun resolveItemLabel(item: DdMenuItem): String = when (item) { + is DdMenuItem.SubMenu -> menus[item.name]?.label?.ifBlank { item.name } ?: item.label.ifBlank { item.name } + is DdMenuItem.Var -> variables[item.name]?.label?.ifBlank { item.name } ?: item.label.ifBlank { item.name } + is DdMenuItem.Cmd -> commands[item.number]?.label?.ifBlank { "Cmd ${item.number}" } ?: item.label.ifBlank { "Cmd ${item.number}" } + is DdMenuItem.Method -> item.label.ifBlank { item.name } + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/dd/DdParser.kt b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdParser.kt new file mode 100644 index 0000000..1b8c6dd --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/dd/DdParser.kt @@ -0,0 +1,258 @@ +package ru.kaptsov.hartmobile.dd + +/** + * Парсер DDL (Device Description Language) файлов для HART. + * + * Поддерживаемый синтаксис: + * -- line comment + * /* block comment */ + * DEVICE_DEFINITION { manufacturer 0xXX; device_type 0xXX; device_revision N; } + * VARIABLE name { label "..."; class DYNAMIC; type FLOAT; help "..."; } + * COMMAND N { label "..."; response { VARIABLE name; } } + * MENU name { label "..."; items { MENU sub; VARIABLE v; } } + */ +object DdParser { + + fun parse(source: String, fileName: String): DdDocument { + val clean = removeComments(source) + val tokens = tokenize(clean) + return parseDocument(tokens, fileName) + } + + // ---- Препроцессинг ---- + + private fun removeComments(src: String): String { + val sb = StringBuilder(src.length) + var i = 0 + while (i < src.length) { + when { + // Блочный комментарий /* ... */ + i + 1 < src.length && src[i] == '/' && src[i + 1] == '*' -> { + i += 2 + while (i + 1 < src.length && !(src[i] == '*' && src[i + 1] == '/')) i++ + i += 2 + } + // Строчный комментарий -- ... + i + 1 < src.length && src[i] == '-' && src[i + 1] == '-' -> { + while (i < src.length && src[i] != '\n') i++ + } + else -> { sb.append(src[i]); i++ } + } + } + return sb.toString() + } + + // ---- Токенизатор ---- + + private fun tokenize(src: String): ArrayDeque { + val tokens = ArrayDeque() + var i = 0 + while (i < src.length) { + val c = src[i] + when { + c.isWhitespace() -> i++ + c == '"' -> { + // строковый литерал + i++ + val start = i + while (i < src.length && src[i] != '"') { + if (src[i] == '\\') i++ // escape + i++ + } + tokens.add("\"${src.substring(start, i)}\"") + i++ // closing " + } + c == '{' || c == '}' || c == ';' || c == ',' -> { + tokens.add(c.toString()); i++ + } + c.isLetterOrDigit() || c == '_' || c == '.' || c == '-' || c == '+' -> { + val start = i + while (i < src.length && (src[i].isLetterOrDigit() || src[i] == '_' || src[i] == '.' || src[i] == 'x' || src[i] == 'X')) i++ + if (i == start) i++ // символ-одиночка (-, +): пропустить, иначе бесконечный цикл + else tokens.add(src.substring(start, i)) + } + else -> i++ + } + } + return tokens + } + + // ---- Парсер ---- + + private fun parseDocument(tokens: ArrayDeque, fileName: String): DdDocument { + var deviceInfo = DdDeviceInfo() + val menus = mutableMapOf() + val variables = mutableMapOf() + val commands = mutableMapOf() + var rootMenuName: String? = null + + while (tokens.isNotEmpty()) { + val tok = tokens.removeFirst() + when (tok.uppercase()) { + "DEVICE_DEFINITION" -> deviceInfo = parseDeviceDefinition(tokens) + "VARIABLE" -> { + val v = parseVariable(tokens) + variables[v.name] = v + } + "COMMAND" -> { + val cmd = parseCommand(tokens) + commands[cmd.number] = cmd + } + "MENU" -> { + val m = parseMenu(tokens) + menus[m.name] = m + if (rootMenuName == null) rootMenuName = m.name + } + else -> skipToSemicolonOrBlock(tokens) + } + } + + return DdDocument(fileName, deviceInfo, menus, variables, commands, rootMenuName) + } + + private fun parseDeviceDefinition(tokens: ArrayDeque): DdDeviceInfo { + expectBrace(tokens, "{") + var mfr = -1; var dt = -1; var rev = -1 + var depth = 1 + while (tokens.isNotEmpty() && depth > 0) { + val tok = tokens.removeFirst() + when { + tok == "{" -> depth++ + tok == "}" -> depth-- + tok.equals("manufacturer", ignoreCase = true) -> mfr = nextInt(tokens) + tok.equals("device_type", ignoreCase = true) -> dt = nextInt(tokens) + tok.equals("device_revision", ignoreCase = true) -> rev = nextInt(tokens) + } + } + return DdDeviceInfo(mfr, dt, rev) + } + + private fun parseVariable(tokens: ArrayDeque): DdVariable { + val name = tokens.removeFirstOrNull() ?: "unknown" + expectBrace(tokens, "{") + var label = ""; var type = ""; var cls = ""; var help = "" + var depth = 1 + while (tokens.isNotEmpty() && depth > 0) { + val tok = tokens.removeFirst() + when { + tok == "{" -> depth++ + tok == "}" -> depth-- + tok.equals("label", ignoreCase = true) -> label = nextString(tokens) + tok.equals("type", ignoreCase = true) -> type = tokens.removeFirstOrNull() ?: "" + tok.equals("class", ignoreCase = true) -> cls = tokens.removeFirstOrNull() ?: "" + tok.equals("help", ignoreCase = true) -> help = nextString(tokens) + } + } + return DdVariable(name, label.ifBlank { name }, type, cls, help) + } + + private fun parseCommand(tokens: ArrayDeque): DdCommand { + val numStr = tokens.removeFirstOrNull() ?: "0" + val num = numStr.toIntOrNull() ?: 0 + expectBrace(tokens, "{") + var label = "" + val responseVars = mutableListOf() + var depth = 1 + while (tokens.isNotEmpty() && depth > 0) { + val tok = tokens.removeFirst() + when { + tok == "{" -> depth++ + tok == "}" -> depth-- + tok.equals("label", ignoreCase = true) -> label = nextString(tokens) + tok.equals("response", ignoreCase = true) || tok.equals("request", ignoreCase = true) -> { + if (tokens.firstOrNull() == "{") { + tokens.removeFirst() // { + var inner = 1 + while (tokens.isNotEmpty() && inner > 0) { + val t = tokens.removeFirst() + when { + t == "{" -> inner++ + t == "}" -> inner-- + t.equals("VARIABLE", ignoreCase = true) -> { + val vn = tokens.removeFirstOrNull() ?: "" + responseVars.add(vn.trimEnd(';')) + } + } + } + } + } + } + } + return DdCommand(num, label.ifBlank { "Команда $num" }, responseVars) + } + + private fun parseMenu(tokens: ArrayDeque): DdMenu { + val name = tokens.removeFirstOrNull() ?: "menu" + expectBrace(tokens, "{") + var label = "" + val items = mutableListOf() + var depth = 1 + while (tokens.isNotEmpty() && depth > 0) { + val tok = tokens.removeFirst() + when { + tok == "{" -> depth++ + tok == "}" -> depth-- + tok.equals("label", ignoreCase = true) -> label = nextString(tokens) + tok.equals("items", ignoreCase = true) -> { + if (tokens.firstOrNull() == "{") { + tokens.removeFirst() + var inner = 1 + while (tokens.isNotEmpty() && inner > 0) { + val t = tokens.removeFirst() + when { + t == "{" -> inner++ + t == "}" -> inner-- + t.equals("MENU", ignoreCase = true) -> { + val mn = tokens.removeFirstOrNull()?.trimEnd(';') ?: "" + items.add(DdMenuItem.SubMenu(mn)) + } + t.equals("VARIABLE", ignoreCase = true) -> { + val vn = tokens.removeFirstOrNull()?.trimEnd(';') ?: "" + items.add(DdMenuItem.Var(vn)) + } + } + } + } + } + } + } + return DdMenu(name, label.ifBlank { name }, items) + } + + // ---- Вспомогательные ---- + + private fun expectBrace(tokens: ArrayDeque, expected: String) { + while (tokens.isNotEmpty() && tokens.first() != expected) tokens.removeFirst() + if (tokens.firstOrNull() == expected) tokens.removeFirst() + } + + private fun skipToSemicolonOrBlock(tokens: ArrayDeque) { + if (tokens.firstOrNull() == "{") { + tokens.removeFirst() + var depth = 1 + while (tokens.isNotEmpty() && depth > 0) { + when (tokens.removeFirst()) { "{" -> depth++; "}" -> depth-- } + } + } else { + while (tokens.isNotEmpty() && tokens.first() != ";" && tokens.first() != "{" && tokens.first() != "}") { + tokens.removeFirst() + } + if (tokens.firstOrNull() == ";") tokens.removeFirst() + } + } + + private fun nextInt(tokens: ArrayDeque): Int { + while (tokens.isNotEmpty() && tokens.first() == ";") tokens.removeFirst() + val s = tokens.removeFirstOrNull() ?: return -1 + return when { + s.startsWith("0x", ignoreCase = true) -> s.substring(2).toIntOrNull(16) ?: -1 + else -> s.toIntOrNull() ?: -1 + } + } + + private fun nextString(tokens: ArrayDeque): String { + while (tokens.isNotEmpty() && tokens.first() == ";") tokens.removeFirst() + val s = tokens.removeFirstOrNull() ?: return "" + return if (s.startsWith("\"") && s.endsWith("\"")) s.substring(1, s.length - 1) else s + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/dd/SymParser.kt b/app/src/main/java/ru/kaptsov/hartmobile/dd/SymParser.kt new file mode 100644 index 0000000..39f960b --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/dd/SymParser.kt @@ -0,0 +1,257 @@ +package ru.kaptsov.hartmobile.dd + +/** + * Парсер .sym файлов (HART Device Description symbol tables). + * + * Формат строки: type name [datatype] id [(flags)] + * Типы: variable, command, menu, method, member, array, record, collection, refresh + */ +object SymParser { + + /** Стандартные имена команд → номер HART команды */ + private val STANDARD_COMMAND_NUMBERS = mapOf( + "read_unique_identifier" to 0, + "read_pv" to 1, + "read_pv_current_and_percent_range" to 2, + "read_dynamic_variables_and_pv_current" to 3, + "write_polling_address" to 6, + "read_loop_configuration" to 7, + "read_dynamic_variable_classification" to 8, + "read_device_variables" to 9, + "read_unique_identifier_with_tag" to 11, + "read_message" to 12, + "read_tag_descriptor_date" to 13, + "read_pv_sensor_info" to 14, + "read_pv_output_info" to 15, + "read_final_assembly_number" to 16, + "write_message" to 17, + "write_tag_descriptor_date" to 18, + "write_final_assembly_number" to 19, + "read_long_tag" to 20, + "read_unique_identifier_with_long_tag" to 21, + "write_long_tag" to 22, + "reset_configuration_change_flag" to 38, + "enter_exit_fixed_pv_current_mode" to 40, + "perform_device_reset" to 42, + "set_pv_upper_range_value" to 43, + "set_pv_lower_range_value" to 44, + "trim_pv_current_dac_zero" to 45, + "trim_pv_current_dac_gain" to 46, + "write_pv_range_values" to 35, + "write_pv_damping_value" to 34, + "read_device_variables_and_status" to 9, + "write_device_variable_trim_point" to 44, + "reset_device_variable_trim" to 45, + "read_device_variable_trim_point" to 44, + "read_device_variable_information" to 54, + "read_device_variable_trim_guidelines" to 53, + "write_device_variable" to 51, + "read_additional_device_status" to 48, + "write_number_of_response_preambles" to 59, + ) + + /** Команды, которые НЕЛЬЗЯ отправлять без данных (write/trim/reset/loop) */ + val WRITE_COMMANDS = setOf( + 6, // Write Polling Address + 9, // Read Device Variables (needs variable codes) + 17, // Write Tag/Descriptor/Date + 18, // Write Tag/Descriptor/Date (synonym) + 19, // Write Final Assembly Number + 22, // Write Long Tag + 34, // Write PV Damping Value + 35, // Write PV Range Values + 40, // Enter/Exit Fixed Current Mode + 42, // Perform Device Reset (DANGEROUS) + 43, // Set PV Upper Range Value + 44, // Set PV Lower Range Value / Write Device Variable Trim Point + 45, // Trim PV Current DAC Zero / Reset Device Variable Trim + 46, // Trim PV Current DAC Gain + 51, // Write Device Variable + 54, // Read Device Variable Information (needs var code) + 59, // Write Number of Response Preambles + ) + + /** Опасные команды, которые нельзя отправлять автоматически */ + val DANGEROUS_COMMANDS = setOf( + 42, // Perform Device Reset + ) + + fun parse(source: String, fileName: String): DdDocument { + val variables = mutableMapOf() + val commands = mutableMapOf() + val menus = mutableMapOf() + val methods = mutableListOf() + + for (line in source.lineSequence()) { + val trimmed = line.trim() + if (trimmed.isBlank()) continue + + val parts = trimmed.split(Regex("\\s+"), limit = 4) + if (parts.size < 2) continue + + val type = parts[0] + val name = parts[1] + val dataType = if (parts.size >= 3) parts[2] else "" + val rest = if (parts.size >= 4) parts[3] else "" + + // Извлекаем флаги из скобок + val flags = Regex("\\(([^)]+)\\)").find(rest)?.groupValues?.get(1) ?: "" + + when (type) { + "variable" -> { + variables[name] = DdVariable( + name = name, + label = humanize(name), + type = dataType, + classDd = if ("std" in flags) "STANDARD" else "DEVICE", + help = "" + ) + } + "command" -> { + val cmdNum = resolveCommandNumber(name) + if (cmdNum >= 0) { + commands[cmdNum] = DdCommand( + number = cmdNum, + label = humanize(name), + responseVars = emptyList() + ) + } + } + "menu" -> { + menus[name] = DdMenu( + name = name, + label = humanize(name), + items = emptyList() + ) + } + "method" -> { + methods.add(DdMethod( + name = name, + label = humanize(name), + isStandard = "std" in flags + )) + } + } + } + + // Строим корневое меню из категорий + val rootItems = mutableListOf() + + // Подменю: Устройство-специфичные команды + val deviceCmds = commands.filter { it.key >= 128 }.toSortedMap() + if (deviceCmds.isNotEmpty()) { + val cmdMenu = DdMenu( + name = "_device_commands", + label = "Команды устройства", + items = deviceCmds.map { (num, cmd) -> + DdMenuItem.Cmd(num, cmd.label) + } + ) + menus["_device_commands"] = cmdMenu + rootItems.add(DdMenuItem.SubMenu("_device_commands", "Команды устройства")) + } + + // Подменю: Стандартные команды (сгруппированные) + val stdCmds = commands.filter { it.key < 128 }.toSortedMap() + if (stdCmds.isNotEmpty()) { + val stdMenu = DdMenu( + name = "_standard_commands", + label = "Стандартные команды HART", + items = stdCmds.map { (num, cmd) -> + DdMenuItem.Cmd(num, cmd.label) + } + ) + menus["_standard_commands"] = stdMenu + rootItems.add(DdMenuItem.SubMenu("_standard_commands", "Стандартные команды HART")) + } + + // Подменю: Переменные температуры (если есть) + val tempVars = variables.filter { it.key.startsWith("temperature") } + if (tempVars.isNotEmpty()) { + val tempMenu = DdMenu( + name = "_temperature_vars", + label = "Температура", + items = tempVars.map { (name, v) -> DdMenuItem.Var(name, v.label) } + ) + menus["_temperature_vars"] = tempMenu + rootItems.add(DdMenuItem.SubMenu("_temperature_vars", "Температура")) + } + + // Подменю: Все переменные устройства (не стандартные) + val deviceVars = variables.filter { it.value.classDd == "DEVICE" && !it.key.startsWith("temperature") } + if (deviceVars.isNotEmpty()) { + val dvMenu = DdMenu( + name = "_device_variables", + label = "Переменные устройства", + items = deviceVars.map { (name, v) -> DdMenuItem.Var(name, v.label) } + ) + menus["_device_variables"] = dvMenu + rootItems.add(DdMenuItem.SubMenu("_device_variables", "Переменные устройства")) + } + + // Подменю: Стандартные переменные + val stdVars = variables.filter { it.value.classDd == "STANDARD" } + if (stdVars.isNotEmpty()) { + val svMenu = DdMenu( + name = "_standard_variables", + label = "Стандартные переменные", + items = stdVars.map { (name, v) -> DdMenuItem.Var(name, v.label) } + ) + menus["_standard_variables"] = svMenu + rootItems.add(DdMenuItem.SubMenu("_standard_variables", "Стандартные переменные")) + } + + // Подменю: Методы устройства + val deviceMethods = methods.filter { !it.isStandard } + if (deviceMethods.isNotEmpty()) { + val mMenu = DdMenu( + name = "_device_methods", + label = "Методы устройства", + items = deviceMethods.map { DdMenuItem.Method(it.name, it.label) } + ) + menus["_device_methods"] = mMenu + rootItems.add(DdMenuItem.SubMenu("_device_methods", "Методы устройства")) + } + + // Корневое меню + val rootMenu = DdMenu( + name = "_root", + label = "DD: ${fileName}", + items = rootItems + ) + menus["_root"] = rootMenu + + return DdDocument( + fileName = fileName, + deviceInfo = DdDeviceInfo(), + menus = menus, + variables = variables, + commands = commands, + rootMenuName = "_root" + ) + } + + /** Извлекает номер HART команды из имени символа */ + private fun resolveCommandNumber(name: String): Int { + // Формат cmd128, cmd1024 и т.д. + val numMatch = Regex("^cmd(\\d+)$").find(name) + if (numMatch != null) return numMatch.groupValues[1].toInt() + + // Стандартные команды по имени + return STANDARD_COMMAND_NUMBERS[name] ?: -1 + } + + /** Превращает snake_case/camelCase имя в читаемую строку */ + private fun humanize(name: String): String { + // cmd128 → "Cmd 128" + val cmdMatch = Regex("^cmd(\\d+)$").find(name) + if (cmdMatch != null) return "Cmd ${cmdMatch.groupValues[1]}" + + return name + .replace('_', ' ') + .replace(Regex("([a-z])([A-Z])")) { "${it.groupValues[1]} ${it.groupValues[2]}" } + .split(' ') + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } + .trim() + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseManager.kt b/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseManager.kt new file mode 100644 index 0000000..170951f --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseManager.kt @@ -0,0 +1,134 @@ +package ru.kaptsov.hartmobile.license + +import android.content.Context +import android.provider.Settings +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Управление лицензией HART Mobile. + * + * Схема кода: HMAC-SHA256(activationId + "|" + type, SECRET)[:8] в hex uppercase. + * activationId = SHA256(ANDROID_ID + "HartMobileApp")[0..3] → "XXXXXXXX" + * + * SECRET разбит на части (KP) для усложнения статического анализа APK. + * Генерация кодов: tools/gen_code.py + */ +class LicenseManager(private val context: Context) { + + companion object { + private const val PREFS = "hart_lic_v1" + private const val KEY_FIRST_LAUNCH = "fl" // Long — timestamp первого запуска + private const val KEY_LICENSE_TYPE = "lt" // String — "1Y" | "PRM" | null + private const val KEY_ACTIVATION_TS = "at" // Long — timestamp активации + + private const val TRIAL_DAYS = 5 + private val TRIAL_MS = TRIAL_DAYS.toLong() * 24 * 60 * 60 * 1000 + private val YEAR_MS = 365L * 24 * 60 * 60 * 1000 + + // Ключ разбит: при сборке с ProGuard/R8 строки перемешаются. + // ВАЖНО: должен совпадать с SECRET в tools/gen_code.py + private val KP = arrayOf("H4rT", "_M0b", "1L3_", "s3Cr", "3t!!") + } + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + init { + // Запоминаем первый запуск (один раз навсегда) + if (!prefs.contains(KEY_FIRST_LAUNCH)) { + prefs.edit().putLong(KEY_FIRST_LAUNCH, System.currentTimeMillis()).apply() + } + } + + // ---- Текущий статус ---- + + val status: LicenseStatus get() { + val lt = prefs.getString(KEY_LICENSE_TYPE, null) + if (lt == "PRM") return LicenseStatus.LICENSED_FOREVER + if (lt == "1Y") { + val ts = prefs.getLong(KEY_ACTIVATION_TS, 0L) + return if (System.currentTimeMillis() - ts < YEAR_MS) + LicenseStatus.LICENSED_YEAR else LicenseStatus.YEAR_EXPIRED + } + val firstLaunch = prefs.getLong(KEY_FIRST_LAUNCH, System.currentTimeMillis()) + return if (System.currentTimeMillis() - firstLaunch < TRIAL_MS) + LicenseStatus.TRIAL_ACTIVE else LicenseStatus.TRIAL_EXPIRED + } + + /** true — приложением можно пользоваться */ + val isAllowed: Boolean get() = when (status) { + LicenseStatus.TRIAL_ACTIVE, + LicenseStatus.LICENSED_YEAR, + LicenseStatus.LICENSED_FOREVER -> true + else -> false + } + + val trialDaysLeft: Int get() { + val elapsed = System.currentTimeMillis() - + prefs.getLong(KEY_FIRST_LAUNCH, System.currentTimeMillis()) + return ((TRIAL_MS - elapsed) / (24 * 60 * 60 * 1000L)).coerceAtLeast(0).toInt() + } + + val yearDaysLeft: Int get() { + val ts = prefs.getLong(KEY_ACTIVATION_TS, 0L) + return ((YEAR_MS - (System.currentTimeMillis() - ts)) / (24 * 60 * 60 * 1000L)) + .coerceAtLeast(0).toInt() + } + + // ---- ID устройства (показываем пользователю для покупки кода) ---- + + /** + * Уникальный ID устройства, привязанный к данному приложению. + * Формат: "XXXX-XXXX" (8 hex chars). + * Пользователь отправляет этот ID при покупке кода. + */ + val activationId: String get() { + val androidId = Settings.Secure.getString( + context.contentResolver, Settings.Secure.ANDROID_ID) ?: "unknown" + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest("$androidId:HartMobileApp".toByteArray()) + val hex = (0 until 4).joinToString("") { "%02X".format(hash[it].toInt() and 0xFF) } + return "${hex.substring(0, 4)}-${hex.substring(4, 8)}" + } + + // ---- Активация кода ---- + + /** + * Проверяет введённый код и активирует лицензию. + * Код валиден только для конкретного activationId этого устройства. + */ + fun activateCode(code: String): ActivationResult { + val rawId = activationId.replace("-", "") + val cleanCode = code.uppercase().replace("-", "").replace(" ", "") + + return when { + cleanCode == deriveCode(rawId, "1Y") -> { + prefs.edit() + .putString(KEY_LICENSE_TYPE, "1Y") + .putLong(KEY_ACTIVATION_TS, System.currentTimeMillis()) + .apply() + ActivationResult.SUCCESS_YEAR + } + cleanCode == deriveCode(rawId, "PRM") -> { + prefs.edit() + .putString(KEY_LICENSE_TYPE, "PRM") + .putLong(KEY_ACTIVATION_TS, System.currentTimeMillis()) + .apply() + ActivationResult.SUCCESS_FOREVER + } + else -> ActivationResult.INVALID_CODE + } + } + + // ---- HMAC вычисление ---- + + private fun deriveCode(activationId: String, type: String): String { + val key = KP.joinToString("").toByteArray() + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + val hash = mac.doFinal("$activationId|$type".toByteArray()) + // 8 байт = 16 hex chars = формат XXXX-XXXX-XXXX-XXXX + return (0 until 8).joinToString("") { "%02X".format(hash[it].toInt() and 0xFF) } + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseStatus.kt b/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseStatus.kt new file mode 100644 index 0000000..a17ab15 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/license/LicenseStatus.kt @@ -0,0 +1,15 @@ +package ru.kaptsov.hartmobile.license + +enum class LicenseStatus { + TRIAL_ACTIVE, + TRIAL_EXPIRED, + LICENSED_YEAR, + YEAR_EXPIRED, + LICENSED_FOREVER +} + +enum class ActivationResult { + SUCCESS_YEAR, + SUCCESS_FOREVER, + INVALID_CODE +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFileLogger.kt b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFileLogger.kt new file mode 100644 index 0000000..f703359 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFileLogger.kt @@ -0,0 +1,136 @@ +package ru.kaptsov.hartmobile.protocol + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.FileWriter +import java.io.PrintWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Логгер HART-коммуникации в файл. + * + * Файл: {externalFilesDir}/hart_log_{дата}.txt + * Доступ: через USB (Android/data/ru.kaptsov.hartmobile/files/) или кнопку "Отправить логи". + */ +object HartFileLogger { + + private const val TAG = "HART_FLOG" + private var writer: PrintWriter? = null + private var logFile: File? = null + private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) + + @Synchronized + fun init(context: Context) { + if (writer != null) return + try { + val dir = context.getExternalFilesDir(null) ?: context.filesDir + val file = File(dir, "hart_log_${dateFmt.format(Date())}.txt") + writer = PrintWriter(FileWriter(file, true), true) + logFile = file + log("=== HART File Logger started ===") + log("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}, Android ${android.os.Build.VERSION.RELEASE} (SDK ${android.os.Build.VERSION.SDK_INT})") + log("App: ru.kaptsov.hartmobile") + Log.i(TAG, "Log file: ${file.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to init file logger: ${e.message}", e) + } + } + + @Synchronized + fun log(message: String) { + val line = "${timeFmt.format(Date())} $message" + writer?.println(line) + Log.d(TAG, message) + } + + fun logTx(cmdName: String, frame: ByteArray) { + log("TX [$cmdName] ${frame.size} bytes: ${frame.toHexDump()}") + } + + fun logRx(rawBytes: ByteArray) { + if (rawBytes.isEmpty()) { + log("RX EMPTY (0 bytes) — no response from device") + } else { + log("RX ${rawBytes.size} bytes: ${rawBytes.toHexDump()}") + } + } + + fun logParsed(cmdNum: Int, response: HartResponse?) { + if (response == null) { + log("PARSE cmd=$cmdNum: FAILED (no valid HART frame found)") + return + } + log("PARSE cmd=${response.command}: status1=0x${"%02X".format(response.status1)} status2=0x${"%02X".format(response.status2)} dataLen=${response.data.size} commErr=${response.communicationError} cmdNotSupp=${response.commandNotSupported}") + if (response.data.isNotEmpty()) { + log("PARSE cmd=${response.command} data: ${response.data.toHexDump()}") + } + } + + fun logCmd0Result(info: HartDeviceInfo?) { + if (info == null) { + log("CMD0 RESULT: parseCmd0 returned NULL — not enough data or parse error") + return + } + log("CMD0 RESULT: manufacturer=${info.manufacturerName} (id=${info.manufacturerId}/0x${"%02X".format(info.manufacturerId)})") + log("CMD0 RESULT: deviceType=${info.deviceType}/0x${"%04X".format(info.deviceType)} deviceId=${info.deviceId}/0x${info.deviceIdHex}") + log("CMD0 RESULT: hartRev=${info.hartRevision} devRev=${info.deviceRevision} swRev=${info.softwareRevision}") + } + + fun logCmd3Result(vars: HartVariables?) { + if (vars == null) { + log("CMD3 RESULT: parseCmd3 returned NULL") + return + } + log("CMD3 RESULT: PV=${vars.pvStr} SV=${vars.svStr} TV=${vars.tvStr} QV=${vars.qvStr} current=${vars.currentStr} percent=${vars.percentStr}") + } + + fun logCmd13Result(tag: HartTagInfo?) { + if (tag == null) { + log("CMD13 RESULT: parseCmd13 returned NULL") + return + } + log("CMD13 RESULT: tag='${tag.tag}' descriptor='${tag.descriptor}' date=${tag.date}") + } + + fun logCmd9Result(variables: List) { + if (variables.isEmpty()) { + log("CMD9 RESULT: no variables parsed") + return + } + for (v in variables) { + log("CMD9 VAR: code=${v.code} class=${v.classification}(${v.classStr}) unit=${v.unitCode}(${v.unitStr}) value=${v.value} status=0x${"%02X".format(v.status)} valid=${v.isValid} label=${v.label}") + } + } + + fun logEvent(event: String) { + log("EVENT: $event") + } + + fun logError(context: String, error: Exception) { + log("ERROR [$context]: ${error.javaClass.simpleName}: ${error.message}") + } + + fun getLogFile(): File? = logFile + + /** Все файлы логов, отсортированные по дате (новые первые) */ + fun getAllLogFiles(context: Context): List { + val dir = context.getExternalFilesDir(null) ?: context.filesDir + return dir.listFiles { f -> f.name.startsWith("hart_log_") && f.name.endsWith(".txt") } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() + } + + @Synchronized + fun close() { + log("=== HART File Logger closed ===") + writer?.close() + writer = null + } + + private fun ByteArray.toHexDump(): String = + joinToString(" ") { "%02X".format(it) } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFrame.kt b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFrame.kt new file mode 100644 index 0000000..d84b74e --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFrame.kt @@ -0,0 +1,144 @@ +package ru.kaptsov.hartmobile.protocol + +/** + * Построитель HART-фреймов (HART 5/6/7, короткий адрес). + * + * Структура запроса: + * FF FF FF FF FF 02 addr cmd byteCount data... checksum + * + * Контрольная сумма = XOR(startDelimiter, address, command, byteCount, data...) + */ +object HartFrame { + + private val PREAMBLE = ByteArray(5) { 0xFF.toByte() } + private const val START_SHORT_MASTER = 0x02 // мастер→слейв, короткий адрес + private const val START_LONG_MASTER = 0x82 // мастер→слейв, длинный адрес + + /** + * Собирает HART-запрос с коротким адресом (0–15). + * @param command номер команды (0–255) + * @param address опросный адрес устройства (обычно 0) + * @param data байты данных команды (может быть пустым) + */ + fun buildRequest(command: Int, address: Int = 0, data: ByteArray = ByteArray(0)): ByteArray { + val totalSize = PREAMBLE.size + 1 + 1 + 1 + 1 + data.size + 1 + val frame = ByteArray(totalSize) + var i = 0 + + PREAMBLE.forEach { frame[i++] = it } + frame[i++] = START_SHORT_MASTER.toByte() + frame[i++] = address.toByte() + frame[i++] = command.toByte() + frame[i++] = data.size.toByte() + data.forEach { frame[i++] = it } + + var checksum = START_SHORT_MASTER xor address xor command xor data.size + data.forEach { checksum = checksum xor (it.toInt() and 0xFF) } + frame[i] = checksum.toByte() + + return frame + } + + /** + * Собирает HART-запрос с длинным адресом (5-байтовый unique address). + * Используется для всех команд кроме Cmd 0 после идентификации устройства. + * @param command номер команды (0–255) + * @param uniqueAddr 5-байтовый уникальный адрес устройства + * @param data байты данных команды (может быть пустым) + */ + fun buildLongFrameRequest(command: Int, uniqueAddr: ByteArray, data: ByteArray = ByteArray(0)): ByteArray { + require(uniqueAddr.size == 5) { "Unique address must be 5 bytes" } + val totalSize = PREAMBLE.size + 1 + 5 + 1 + 1 + data.size + 1 + val frame = ByteArray(totalSize) + var i = 0 + + PREAMBLE.forEach { frame[i++] = it } + frame[i++] = START_LONG_MASTER.toByte() + uniqueAddr.forEach { frame[i++] = it } + frame[i++] = command.toByte() + frame[i++] = data.size.toByte() + data.forEach { frame[i++] = it } + + var checksum = START_LONG_MASTER + uniqueAddr.forEach { checksum = checksum xor (it.toInt() and 0xFF) } + checksum = checksum xor command xor data.size + data.forEach { checksum = checksum xor (it.toInt() and 0xFF) } + frame[i] = checksum.toByte() + + return frame + } + + /** + * Строит 5-байтовый уникальный адрес для long frame из данных Cmd 0. + * Формат: [0x80 | mfr_id[5:0]] [device_type] [device_id_hi] [device_id_mid] [device_id_lo] + */ + fun buildUniqueAddress(manufacturerId: Int, deviceType: Int, deviceId: Int): ByteArray { + return byteArrayOf( + (0x80 or (manufacturerId and 0x3F)).toByte(), + (deviceType and 0xFF).toByte(), + ((deviceId shr 16) and 0xFF).toByte(), + ((deviceId shr 8) and 0xFF).toByte(), + (deviceId and 0xFF).toByte() + ) + } + + // ---- Стандартные команды HART ---- + + /** Команда 0: Чтение идентификатора устройства */ + fun cmd0ReadUniqueId(address: Int = 0) = buildRequest(0, address) + + /** Команда 1: Чтение первичной переменной (PV) */ + fun cmd1ReadPV(address: Int = 0) = buildRequest(1, address) + + /** Команда 2: Чтение тока петли и процента диапазона */ + fun cmd2ReadLoopCurrentAndPercent(address: Int = 0) = buildRequest(2, address) + + /** Команда 3: Чтение динамических переменных (PV, SV, TV, QV) и тока петли */ + fun cmd3ReadDynamicVariables(address: Int = 0) = buildRequest(3, address) + + /** Команда 13: Чтение тэга, дескриптора и даты */ + fun cmd13ReadTagDescriptorDate(address: Int = 0) = buildRequest(13, address) + + /** Команда 15: Чтение информации об устройстве (диапазон, единицы) */ + fun cmd15ReadDeviceInfo(address: Int = 0) = buildRequest(15, address) + + /** Команда 48: Чтение расширенного статуса устройства */ + fun cmd48ReadAdditionalStatus(address: Int = 0) = buildRequest(48, address) + + /** + * Команда 40: Установка фиксированного тока петли (Loop Test). + * @param address опросный адрес + * @param currentMa ток в мА (например 4.0, 12.0, 20.0) + */ + fun cmd40SetFixedCurrent(address: Int = 0, currentMa: Float): ByteArray { + val data = ByteArray(5) + data[0] = 0x01 // режим: 1 = войти в фиксированный режим + val bits = java.lang.Float.floatToIntBits(currentMa) + data[1] = (bits ushr 24).toByte() + data[2] = (bits ushr 16).toByte() + data[3] = (bits ushr 8).toByte() + data[4] = bits.toByte() + return buildRequest(40, address, data) + } + + /** + * Команда 40: Выход из режима фиксированного тока. + */ + fun cmd40ExitFixedCurrent(address: Int = 0): ByteArray { + val data = ByteArray(5) + data[0] = 0x00 // режим: 0 = выйти из фиксированного режима + // ток = 0 при выходе (не важно) + return buildRequest(40, address, data) + } + + /** + * Команда 9: Чтение переменных устройства с кодами (HART 5/6/7). + * @param varCodes до 4 кодов переменных. Незаполненные позиции = 250 (не используется). + * Стандартные коды: 0=PV, 1=SV, 2=TV, 3=QV. Коды 4-235 — специфичные для производителя. + */ + fun cmd9ReadDeviceVariables(address: Int = 0, varCodes: IntArray): ByteArray { + val data = ByteArray(4) { 250.toByte() } // 250 = код "не используется" + varCodes.take(4).forEachIndexed { i, code -> data[i] = code.toByte() } + return buildRequest(9, address, data) + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartParser.kt b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartParser.kt new file mode 100644 index 0000000..9df1da1 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/protocol/HartParser.kt @@ -0,0 +1,461 @@ +package ru.kaptsov.hartmobile.protocol + +import android.util.Log +import java.nio.ByteBuffer +import java.nio.ByteOrder + +// ---- Модели данных ---- + +data class HartDeviceInfo( + val manufacturerId: Int, // расширенный 16-бит (HART 7) или legacy 8-бит + val deviceType: Int, // расширенный 16-бит (HART 7) или legacy 8-бит + val deviceId: Int, // 24-битный уникальный ID + val hartRevision: Int, + val deviceRevision: Int, + val softwareRevision: Int, + val legacyMfrByte: Int = 0, // byte 1 Cmd0 — для unique address + val legacyDevTypeByte: Int = 0 // byte 2 Cmd0 — для unique address +) { + val manufacturerName: String get() = HartParser.MANUFACTURERS[manufacturerId] + ?: HartParser.MANUFACTURERS[legacyMfrByte] + ?: "Производитель #%X".format(manufacturerId) + val deviceIdHex: String get() = "%06X".format(deviceId) + val manufacturerIdHex: String get() = "%06X".format(manufacturerId) + val deviceTypeHex: String get() = "%04X".format(deviceType) +} + +data class HartVariables( + val pv: Float? = null, + val pvUnit: Int? = null, + val sv: Float? = null, + val svUnit: Int? = null, + val tv: Float? = null, + val tvUnit: Int? = null, + val qv: Float? = null, + val qvUnit: Int? = null, + val loopCurrentMa: Float? = null, + val percentRange: Float? = null +) { + val pvStr: String get() = formatVar(pv, pvUnit) + val svStr: String get() = formatVar(sv, svUnit) + val tvStr: String get() = formatVar(tv, tvUnit) + val qvStr: String get() = formatVar(qv, qvUnit) + val currentStr: String get() = loopCurrentMa?.let { "%.4f мА".format(it) } ?: "—" + val percentStr: String get() = percentRange?.let { "%.2f %%".format(it) } ?: "—" + + private fun formatVar(v: Float?, unit: Int?) = + if (v == null) "—" else "%.4f %s".format(v, unit?.let { HartParser.unitName(it) } ?: "") +} + +data class HartTagInfo( + val tag: String, + val descriptor: String, + val date: String +) + +data class HartRangeInfo( + val alarmCode: Int = 0, + val transferFunctionCode: Int = 0, + val unitCode: Int = 0, + val upperRange: Float = Float.NaN, + val lowerRange: Float = Float.NaN, + val dampingSeconds: Float = Float.NaN, + val writeProtect: Int = 0 +) { + val unitStr: String get() = HartParser.unitName(unitCode) +} + +/** + * Переменная устройства, обнаруженная через Command 9. + * @param code код переменной (0-235). 0=PV, 1=SV, 2=TV, 3=QV, 4+=специфичные для производителя. + * @param classification классификация (0=нет, 64=температура, 65=давление, 66=объём, 67=расход...) + * @param unitCode код единиц измерения + * @param value значение float32 + * @param status статус переменной (0=хорошее, 0xC0=плохое) + */ +data class DeviceVariable( + val code: Int, + val classification: Int, + val unitCode: Int, + val value: Float, + val status: Int +) { + val unitStr: String get() = HartParser.unitName(unitCode) + val classStr: String get() = HartParser.CLASSIFICATIONS[classification] ?: "" + val valueStr: String get() = if (value.isNaN()) "—" else "%.4f %s".format(value, unitStr) + val isValid: Boolean get() = !value.isNaN() && unitCode != 250 && code != 250 && (status and 0xC0) != 0xC0 + /** Название переменной: HART-стандартное или "Переменная #N" */ + val label: String get() = HartParser.STANDARD_VAR_NAMES[code] ?: "Переменная #$code" +} + +/** Результат разбора HART-ответа */ +data class HartResponse( + val command: Int, + val status1: Int, + val status2: Int, + val data: ByteArray +) { + val communicationError: Boolean get() = (status1 and 0x80) != 0 + /** Response code (0 = OK, >0 = ошибка): только когда нет communication error */ + val responseCode: Int get() = if (communicationError) 0 else (status1 and 0x7F) + /** Command Not Implemented: response code 64 */ + val commandNotSupported: Boolean get() = responseCode == 64 + /** Любая ошибка: communication error ИЛИ ненулевой response code */ + val hasError: Boolean get() = communicationError || responseCode != 0 + /** Configuration Changed (бит 6 status2) */ + val configurationChanged: Boolean get() = (status2 and 0x40) != 0 + /** More Status Available (бит 4 status2) */ + val moreStatusAvailable: Boolean get() = (status2 and 0x10) != 0 + + /** Человекочитаемое описание response code */ + val responseCodeName: String get() = when (responseCode) { + 0 -> "OK" + 2 -> "Неверный выбор" + 3 -> "Параметр слишком велик" + 4 -> "Параметр слишком мал" + 5 -> "Мало байтов данных" + 6 -> "Ошибка устройства" + 7 -> "Защита от записи" + 9 -> "Не в рабочем режиме" + 16 -> "Доступ ограничен" + 28 -> "Нет доступа к параметру" + 32 -> "Устройство занято" + 33 -> "Занято + предупреждение" + 64 -> "Команда не реализована" + else -> "Код ошибки $responseCode" + } +} + +// ---- Парсер ---- + +object HartParser { + + private const val TAG = "HART_PARSE" + + /** + * Ищет HART-ответ в сырых байтах (могут содержать мусор/преамбулу). + * Возвращает null, если фрейм не найден или неполный. + */ + fun parseResponse(raw: ByteArray): HartResponse? { + Log.d(TAG, "parseResponse: input ${raw.size} bytes: ${raw.joinToString(" ") { "%02X".format(it) }}") + + // Ищем стартовый байт ответа (0x06 = short, 0x86 = long) + var startIdx = -1 + var isLong = false + for (i in raw.indices) { + val b = raw[i].toInt() and 0xFF + if (b == 0x06) { startIdx = i; isLong = false; break } + if (b == 0x86) { startIdx = i; isLong = true; break } + } + if (startIdx < 0) { + Log.w(TAG, "parseResponse: NO start delimiter (0x06/0x86) found in ${raw.size} bytes!") + // Log all bytes for debugging + Log.w(TAG, "parseResponse: all bytes: ${raw.joinToString(" ") { "%02X".format(it) }}") + return null + } + Log.d(TAG, "parseResponse: found start=0x%02X at idx=$startIdx isLong=$isLong".format(if (isLong) 0x86 else 0x06)) + + val addrLen = if (isLong) 5 else 1 + val minLen = startIdx + 1 + addrLen + 1 + 1 + if (raw.size < minLen + 1) { + Log.w(TAG, "parseResponse: frame too short: need >$minLen, have ${raw.size}") + return null + } + + val cmdIdx = startIdx + 1 + addrLen + val command = raw[cmdIdx].toInt() and 0xFF + val byteCount = raw[cmdIdx + 1].toInt() and 0xFF + Log.d(TAG, "parseResponse: cmd=$command byteCount=$byteCount addrBytes=${raw.copyOfRange(startIdx + 1, startIdx + 1 + addrLen).joinToString(" ") { "%02X".format(it) }}") + + val dataStart = cmdIdx + 2 + if (raw.size < dataStart + byteCount + 1) { + Log.w(TAG, "parseResponse: incomplete frame: need ${dataStart + byteCount + 1}, have ${raw.size}") + return null + } + + val status1 = raw[dataStart].toInt() and 0xFF + val status2 = raw[dataStart + 1].toInt() and 0xFF + val data = raw.copyOfRange(dataStart + 2, dataStart + byteCount) + + // Verify checksum + var xor = 0 + for (idx in startIdx until dataStart + byteCount) { + xor = xor xor (raw[idx].toInt() and 0xFF) + } + val expectedChecksum = raw[dataStart + byteCount].toInt() and 0xFF + val checksumOk = xor == expectedChecksum + Log.d(TAG, "parseResponse: status1=0x%02X status2=0x%02X dataLen=${data.size} checksum=${if (checksumOk) "OK" else "MISMATCH(calc=0x%02X exp=0x%02X)"}".format(status1, status2, xor, expectedChecksum)) + + return HartResponse(command, status1, status2, data) + } + + /** Команда 0: Идентификатор устройства (12 байт данных) */ + fun parseCmd0(data: ByteArray): HartDeviceInfo? { + if (data.size < 12) return null + val byte0 = data[0].toInt() and 0xFF + val byte1 = data[1].toInt() and 0xFF // legacy mfr ID / device type MSB (expanded) + val byte2 = data[2].toInt() and 0xFF // legacy device type / device type LSB (expanded) + val expanded = (byte0 == 0xFE) + + // Для HART 7 (expanded): manufacturer ID в bytes 17-18, device type = byte1:byte2 + val manufacturerId: Int + val deviceType: Int + if (expanded && data.size >= 19) { + manufacturerId = ((data[17].toInt() and 0xFF) shl 8) or (data[18].toInt() and 0xFF) + deviceType = (byte1 shl 8) or byte2 + } else { + manufacturerId = byte1 + deviceType = byte2 + } + + return HartDeviceInfo( + manufacturerId = manufacturerId, + deviceType = deviceType, + hartRevision = data[4].toInt() and 0xFF, + deviceRevision = data[5].toInt() and 0xFF, + softwareRevision = data[6].toInt() and 0xFF, + deviceId = ((data[9].toInt() and 0xFF) shl 16) or + ((data[10].toInt() and 0xFF) shl 8) or + (data[11].toInt() and 0xFF), + legacyMfrByte = byte1, + legacyDevTypeByte = byte2 + ) + } + + /** Команда 1: PV (5 байт: unit + float32 big-endian) */ + fun parseCmd1(data: ByteArray): Pair? { + if (data.size < 5) return null + val unit = data[0].toInt() and 0xFF + val value = ByteBuffer.wrap(data, 1, 4).order(ByteOrder.BIG_ENDIAN).float + return Pair(unit, value) + } + + /** Команда 2: Ток петли + % диапазона (4 + 4 байта) */ + fun parseCmd2(data: ByteArray): Pair? { + if (data.size < 8) return null + val current = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.BIG_ENDIAN).float + val percent = ByteBuffer.wrap(data, 4, 4).order(ByteOrder.BIG_ENDIAN).float + return Pair(current, percent) + } + + /** Команда 3: Ток + PV + SV + TV + QV (поддерживает укороченный ответ — только ток+PV) */ + fun parseCmd3(data: ByteArray): HartVariables? { + if (data.size < 9) return null // минимум: ток(4) + PV unit(1) + PV(4) + var o = 0 + val loopCurrent = buf(data, o).also { o += 4 } + val pvUnit = int(data, o).also { o++ } + val pv = buf(data, o).also { o += 4 } + val svUnit = if (data.size >= o + 5) int(data, o).also { o++ } else null + val sv = if (svUnit != null && data.size >= o + 4) buf(data, o).also { o += 4 } else null + val tvUnit = if (data.size >= o + 5) int(data, o).also { o++ } else null + val tv = if (tvUnit != null && data.size >= o + 4) buf(data, o).also { o += 4 } else null + val qvUnit = if (data.size >= o + 5) int(data, o).also { o++ } else null + val qv = if (qvUnit != null && data.size >= o + 4) buf(data, o) else null + return HartVariables(pv, pvUnit, sv, svUnit, tv, tvUnit, qv, qvUnit, loopCurrent) + } + + /** Команда 13: Тэг (6 байт) + дескриптор (12 байт) + дата (3 байта) */ + fun parseCmd13(data: ByteArray): HartTagInfo? { + if (data.size < 21) return null + val tag = decodePacked(data, 0, 6).trim() + val descriptor = decodePacked(data, 6, 12).trim() + val day = data[18].toInt() and 0xFF + val month = data[19].toInt() and 0xFF + val year = (data[20].toInt() and 0xFF) + 1900 + return HartTagInfo(tag, descriptor, "%02d.%02d.%04d".format(day, month, year)) + } + + /** + * Команда 9: Чтение переменных устройства с кодами. + * Формат ответа: [extDevStatus(1)] + N × [code(1) + class(1) + units(1) + float32(4) + varStatus(1)] + * Возвращает только переменные с валидным значением (не NaN, статус не "плохой"). + */ + fun parseCmd9(data: ByteArray): List { + if (data.isEmpty()) return emptyList() + val result = mutableListOf() + var offset = 1 // пропускаем Extended Device Status + while (offset + 7 < data.size) { + val code = data[offset].toInt() and 0xFF + val classification = data[offset + 1].toInt() and 0xFF + val units = data[offset + 2].toInt() and 0xFF + val value = ByteBuffer.wrap(data, offset + 3, 4).order(ByteOrder.BIG_ENDIAN).float + val varStatus = data[offset + 7].toInt() and 0xFF + result.add(DeviceVariable(code, classification, units, value, varStatus)) + offset += 8 + } + return result + } + + /** Команда 15: Read PV Output Information + * Byte 0: Alarm Selection Code + * Byte 1: Transfer Function Code + * Byte 2: Units Code + * Bytes 3-6: Upper Range Value (float) + * Bytes 7-10: Lower Range Value (float) + * Bytes 11-14: Damping Value (float) + * Byte 15: Write Protect Code + * Byte 16: Private Label Distributor Code + */ + fun parseCmd15(data: ByteArray): HartRangeInfo? { + if (data.size < 15) return null + return HartRangeInfo( + alarmCode = data[0].toInt() and 0xFF, + transferFunctionCode = data[1].toInt() and 0xFF, + unitCode = data[2].toInt() and 0xFF, + upperRange = buf(data, 3), + lowerRange = buf(data, 7), + dampingSeconds = if (data.size >= 15) buf(data, 11) else Float.NaN, + writeProtect = if (data.size >= 16) data[15].toInt() and 0xFF else 0 + ) + } + + // ---- HART 6-bit packed ASCII ---- + + /** + * Декодирует HART packed ASCII (6-битный кодировка). + * 3 байта → 4 символа ASCII (пространство 0x20..0x5F). + */ + fun decodePacked(data: ByteArray, offset: Int, length: Int): String { + val sb = StringBuilder() + var i = offset + val end = offset + length + while (i + 2 < end) { + val b0 = data[i].toInt() and 0xFF + val b1 = data[i + 1].toInt() and 0xFF + val b2 = data[i + 2].toInt() and 0xFF + sb.append(packed(b0 ushr 2)) + sb.append(packed(((b0 and 0x03) shl 4) or (b1 ushr 4))) + sb.append(packed(((b1 and 0x0F) shl 2) or (b2 ushr 6))) + sb.append(packed(b2 and 0x3F)) + i += 3 + } + return sb.toString() + } + + private fun packed(c: Int): Char = if (c in 0..0x3F) (c + 0x20).toChar() else ' ' + + // ---- Вспомогательные ---- + + private fun buf(data: ByteArray, offset: Int) = + ByteBuffer.wrap(data, offset, 4).order(ByteOrder.BIG_ENDIAN).float + + private fun int(data: ByteArray, offset: Int) = data[offset].toInt() and 0xFF + + // ---- Справочники ---- + + fun unitName(code: Int) = UNITS[code] ?: "код$code" + + val UNITS = mapOf( + // HART Standard Unit Codes (Table 2 from HCF_SPEC-183) + 1 to "in H₂O 68°F", 2 to "in Hg 0°C", 3 to "ft H₂O 68°F", + 4 to "мм H₂O 68°F", 5 to "мм Hg 0°C", 6 to "psi", + 7 to "бар", 8 to "мбар", 9 to "атм", + 10 to "кПа", 11 to "МПа", 12 to "Па", + 13 to "кг/см²", 14 to "г/см²", 15 to "торр", + 16 to "at", + // Temperature + 32 to "°C", 33 to "°F", 34 to "°R", 35 to "K", + // Volumetric flow + 36 to "US gal/min", 37 to "US gal/h", 38 to "US gal/d", + 39 to "°C", 40 to "°F", 41 to "°R", 42 to "K", + // Current / percent / frequency + 43 to "мА", 44 to "Ом", + 45 to "Гц", 46 to "%", + // Volume + 57 to "°C", 58 to "°F", 59 to "°R", 60 to "K", + // Pressure (extended) + 237 to "кПа(а)", 238 to "МПа(а)", 239 to "бар(а)", + // Common flow/level/misc + 64 to "м³/ч", 65 to "м³/мин", 66 to "м³/с", 67 to "м³/д", + 71 to "л/мин", 72 to "л/ч", 73 to "л/д", 74 to "л/с", + 76 to "м", 77 to "см", 78 to "мм", + 80 to "м/с", + 84 to "кг/ч", 85 to "кг/мин", 86 to "кг/д", 87 to "кг/с", + 88 to "т/ч", 89 to "т/мин", 90 to "т/д", + 91 to "г/с", 92 to "г/мин", 93 to "г/ч", + 130 to "м³", 131 to "л", + 132 to "кг", 133 to "т", + // Density + 150 to "кг/м³", 151 to "г/см³", 152 to "г/л", + // Special + 250 to "нет", 251 to "неизв.", 252 to "спец.", 253 to "нет" + ) + + val MANUFACTURERS = mapOf( + // Legacy 8-bit IDs (0x01-0xDF) — FieldComm Group / Rockwell Automation reference + 0x01 to "Acromag", 0x02 to "Allen-Bradley", 0x03 to "Ametek", + 0x04 to "Analog Devices", 0x05 to "ABB", 0x06 to "Beckman", + 0x07 to "Bell Microsenser", 0x08 to "Bourns", 0x09 to "Bristol Babcock", + 0x0A to "Brooks Instrument", 0x0B to "Chessell", 0x0C to "Combustion Engineering", + 0x0D to "Daniel Industries", 0x0E to "Delta", 0x0F to "Dieterich Standard", + 0x10 to "Dohrmann", 0x11 to "Endress+Hauser", 0x12 to "ABB", + 0x13 to "Fisher Controls", 0x14 to "Foxboro", 0x15 to "Fuji", + 0x16 to "ABB", 0x17 to "Honeywell", 0x18 to "ITT Barton", + 0x19 to "Thermo MeasureTech", 0x1A to "ABB", 0x1B to "Leeds & Northrup", + 0x1C to "Leslie", 0x1D to "M-System Co.", 0x1E to "Measurex", + 0x1F to "Micro Motion", 0x20 to "Moore Industries", 0x21 to "PRIME Measurement", + 0x22 to "Ohkura Electric", 0x23 to "Paine", 0x24 to "Rochester Instruments", + 0x25 to "Ronan", 0x26 to "Rosemount", 0x27 to "Peek Measurement", + 0x28 to "Actaris Neptune", 0x29 to "Sensall", 0x2A to "Siemens", + 0x2B to "Weed", 0x2C to "Toshiba", 0x2D to "Transmation", + 0x2E to "Rosemount Analytic", 0x2F to "Metso Automation", 0x30 to "Flowserve", + 0x31 to "Varec", 0x32 to "Viatran", 0x33 to "Delta/Weed", + 0x34 to "Westinghouse", 0x35 to "Xomox", 0x36 to "Yamatake", + 0x37 to "Yokogawa", 0x38 to "Nuovo Pignone", 0x39 to "Promac", + 0x3A to "Exac", 0x3B to "Mobrey", 0x3C to "Arcom", + 0x3D to "Princo", 0x3E to "Smar", 0x3F to "Foxboro Eckardt", + 0x40 to "Measurement Technology", 0x41 to "Applied System Technologies", + 0x42 to "Samson", 0x43 to "Sparling Instruments", 0x44 to "Fireye", + 0x45 to "Krohne", 0x46 to "Betz", 0x47 to "Druck", + 0x48 to "SOR", 0x49 to "Elcon Instruments", 0x4A to "EMCO", + 0x4B to "Termiflex", 0x4C to "VAF Instruments", 0x4D to "Westlock Controls", + 0x4E to "Drexelbrook", 0x4F to "Saab Tank Control", 0x50 to "K-TEK", + 0x51 to "Sensidyne", 0x52 to "Draeger", 0x53 to "Raytek", + 0x54 to "Siemens Milltronics", 0x55 to "BTG", 0x56 to "Magnetrol", + 0x57 to "Metso Automation", 0x59 to "HELIOS", + 0x5A to "Anderson Instrument", 0x5B to "INOR", 0x5C to "Robertshaw", + 0x5D to "Pepperl+Fuchs", 0x5E to "Accutech", 0x5F to "Flow Measurement", + 0x61 to "Knick", 0x62 to "VEGA", 0x63 to "MTS Systems", + 0x64 to "Oval", 0x65 to "Masoneilan-Dresser", 0x67 to "Ohmart", + 0x6B to "WIKA", 0x6D to "PR Electronics", + 0x71 to "Apparatebau Hundsbach", 0x72 to "Dynisco", 0x73 to "Spriano", + 0x75 to "Klay Instruments", 0x78 to "Buerkert", + 0x7F to "LABOM", 0x80 to "Danfoss", 0x82 to "Tokyo Keiso", + 0x83 to "SMC", 0x84 to "Status Instruments", + 0x8E to "Mettler-Toledo", 0x8F to "Det-Tronics", + 0x93 to "Welltech Shanghai", 0x94 to "ENRAF", + 0x97 to "Nivelco", 0x99 to "Metran", 0x9C to "Turck", + 0x9D to "Panametrics", 0x9E to "R. Stahl", + 0xA1 to "Berthold", 0xA3 to "China BRICONTE", + 0xA5 to "Sierra Instruments", 0xA9 to "Invensys", + 0xB0 to "Phoenix Contact", 0xB4 to "YTC", + 0xB9 to "BD Sensors", 0xBC to "Aplisens", 0xBD to "Badger Meter", + 0xC7 to "SICK-MAIHAK", 0xD1 to "GE Energy", 0xD5 to "Hach Lange", + // Extended 16-bit IDs (HART 7, 0x6000+) + 0x6000 to "ExSaf", 0x6006 to "BARTEC", 0x6008 to "MSA", + 0x600A to "Etalon", 0x6010 to "SUPCON", 0x6012 to "Dwyer Instruments", + 0x6013 to "FineTek", 0x6015 to "Hoffer Flow Controls", + 0x6017 to "Forbes Marshall", 0x601E to "Microcyber", + 0x6020 to "ifm", 0x6021 to "FLEXIM", + 0x602A to "ELEMER", 0x602B to "Soft Tech Group" + ) + + /** Классификации переменных HART (HART 7 spec table) */ + val CLASSIFICATIONS = mapOf( + 0 to "", 64 to "Температура", 65 to "Давление", + 66 to "Объём", 67 to "Массовый расход", 68 to "Объёмный расход", + 69 to "Скорость", 70 to "Концентрация", 71 to "Уровень", + 72 to "Мощность", 73 to "Сила тока", 74 to "Частота", + 75 to "Сопротивление", 76 to "Угол", 77 to "Время", + 78 to "pH", 79 to "Проводимость", 80 to "Влажность", + 81 to "Вязкость", 82 to "Плотность", 83 to "Позиция", + 84 to "Анализатор", 85 to "Скоп. объём", 86 to "Масса" + ) + + /** Стандартные HART-переменные по коду */ + val STANDARD_VAR_NAMES = mapOf( + 0 to "PV (первичная)", 1 to "SV (вторичная)", + 2 to "TV (третичная)", 3 to "QV (четвертичная)", + 245 to "Процент диапазона", 246 to "Ток петли" + ) +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/DdMenuFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/DdMenuFragment.kt new file mode 100644 index 0000000..373afa5 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/DdMenuFragment.kt @@ -0,0 +1,344 @@ +package ru.kaptsov.hartmobile.ui + +import android.os.Bundle +import android.provider.OpenableColumns +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.kaptsov.hartmobile.databinding.FragmentDdMenuBinding +import ru.kaptsov.hartmobile.databinding.ItemDdMenuBinding +import ru.kaptsov.hartmobile.databinding.ItemDdFileBinding +import ru.kaptsov.hartmobile.dd.DdDocument +import ru.kaptsov.hartmobile.dd.DdManager +import ru.kaptsov.hartmobile.dd.DdMenu +import ru.kaptsov.hartmobile.dd.DdMenuItem + +class DdMenuFragment : Fragment() { + + private var _binding: FragmentDdMenuBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + + private var currentDoc: DdDocument? = null + private var menuStack: ArrayDeque = ArrayDeque() + private lateinit var menuAdapter: DdMenuAdapter + private lateinit var filesAdapter: DdFilesAdapter + + // Пикер файлов — любой тип + private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@registerForActivityResult + val ctx = requireContext() + val name = ctx.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + if (col >= 0) cursor.getString(col) else "dd_file.bin" + } ?: "dd_file.bin" + + binding.btnImportDd.isEnabled = false + lifecycleScope.launch { + val doc = withContext(Dispatchers.IO) { + DdManager.importFromUri(ctx, uri, name) + } + binding.btnImportDd.isEnabled = true + if (doc != null) { + Toast.makeText(ctx, + "Импортировано: $name\n${doc.menus.size} меню, ${doc.variables.size} переменных", + Toast.LENGTH_LONG).show() + refreshFilesList() + if (currentDoc == null) openDocument(doc) + } else { + refreshFilesList() + Toast.makeText(ctx, + "Файл сохранён, но структура не распознана.\n" + + "Поддерживаются текстовые DD (.sym, .dd). " + + "Бинарные (.fm8/.fm6) не поддерживаются.", + Toast.LENGTH_LONG).show() + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentDdMenuBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Список сохранённых файлов + filesAdapter = DdFilesAdapter( + onOpen = { fileName -> + val ctx = requireContext() + lifecycleScope.launch { + val doc = withContext(Dispatchers.IO) { DdManager.load(ctx, fileName) } + if (doc != null) openDocument(doc) + else { + val isBinary = withContext(Dispatchers.IO) { + val f = java.io.File(ctx.filesDir, "dd_files/$fileName") + DdManager.isBinaryFile(f) + } + val msg = if (isBinary) + "Бинарный DD файл (.fm8/.fm6) — чтение структуры недоступно" + else + "Ошибка чтения файла" + Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() + } + } + }, + onDelete = { fileName -> + DdManager.deleteFile(requireContext(), fileName) + if (currentDoc?.fileName == fileName) { + currentDoc = null + menuStack.clear() + showFilesList() + } + refreshFilesList() + } + ) + binding.recyclerFiles.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = filesAdapter + } + + // Дерево меню DD + menuAdapter = DdMenuAdapter { item -> + val doc = currentDoc ?: return@DdMenuAdapter + when (item) { + is DdMenuItem.SubMenu -> { + val sub = doc.menus[item.name] + if (sub != null) { + menuStack.addLast(sub) + refreshMenuView() + } + } + is DdMenuItem.Cmd -> { + // Повторное чтение одной команды по клику + viewModel.executeDdCommand(item.number) { _, _ -> } + } + is DdMenuItem.Method -> { + Toast.makeText(requireContext(), "Метод: ${item.label}\n(выполнение методов пока не реализовано)", Toast.LENGTH_SHORT).show() + } + is DdMenuItem.Var -> { + val v = doc.variables[item.name] + if (v != null) { + Toast.makeText(requireContext(), "${v.label}\nТип: ${v.type}\nКласс: ${v.classDd}", Toast.LENGTH_LONG).show() + } + } + } + } + binding.recyclerMenu.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = menuAdapter + } + + binding.btnImportDd.setOnClickListener { + filePicker.launch("*/*") + } + + binding.btnBack.setOnClickListener { + if (menuStack.size > 1) { + menuStack.removeLast() + refreshMenuView() + } else { + showFilesList() + } + } + + // Подписка на результаты DD команд — обновляем адаптер при каждом новом результате + viewModel.ddResults.observe(viewLifecycleOwner) { results -> + menuAdapter.updateResults(results) + } + + // Попытаться автоматически загрузить DD для подключённого устройства + val state = viewModel.state.value + val mfr = state?.deviceInfo?.manufacturerId ?: -1 + val dt = state?.deviceInfo?.deviceType ?: -1 + val ctx = requireContext() + if (mfr >= 0 && dt >= 0) { + lifecycleScope.launch { + val doc = withContext(Dispatchers.IO) { + DdManager.findForDevice(ctx, mfr, dt) + } + if (doc != null) openDocument(doc) else showFilesList() + } + } else { + showFilesList() + } + } + + private fun showFilesList() { + binding.layoutFiles.isVisible = true + binding.layoutMenu.isVisible = false + refreshFilesList() + } + + private fun refreshFilesList() { + val files = DdManager.listFiles(requireContext()) + binding.tvNoFiles.isVisible = files.isEmpty() + filesAdapter.submitList(files.map { it.name }) + } + + private fun openDocument(doc: DdDocument) { + currentDoc = doc + menuStack.clear() + val rootMenu = doc.rootMenuName?.let { doc.menus[it] } + ?: doc.menus.values.firstOrNull() + if (rootMenu != null) { + menuStack.addLast(rootMenu) + binding.layoutFiles.isVisible = false + binding.layoutMenu.isVisible = true + refreshMenuView() + } else { + Toast.makeText(requireContext(), "В файле нет меню", Toast.LENGTH_SHORT).show() + } + } + + private fun refreshMenuView() { + val doc = currentDoc ?: return + val menu = menuStack.lastOrNull() ?: return + binding.tvMenuTitle.text = menu.label.ifBlank { menu.name } + binding.btnBack.isVisible = menuStack.size > 1 + binding.tvDocInfo.text = doc.fileName + + if (doc.deviceInfo.manufacturerId >= 0) + " | mfr: %04X dev: %04X".format(doc.deviceInfo.manufacturerId, doc.deviceInfo.deviceType) + else "" + menuAdapter.submitList(menu.items, doc) + + // Автоматическое чтение команд — только если зашли в подменю с командами (не корневое) + if (menuStack.size > 1) { + val cmdNumbers = menu.items.filterIsInstance().map { it.number } + if (cmdNumbers.isNotEmpty()) { + viewModel.readDdCommands(cmdNumbers) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +// ---- Адаптер: дерево меню ---- + +class DdMenuAdapter( + private val onItemClick: (DdMenuItem) -> Unit +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + private var doc: DdDocument? = null + private var results: Map = emptyMap() + + fun submitList(list: List, document: DdDocument) { + items = list; doc = document; notifyDataSetChanged() + } + + fun updateResults(newResults: Map) { + results = newResults + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + VH(ItemDdMenuBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = items[position] + val d = doc ?: return + holder.bind(item, d, results) + holder.itemView.setOnClickListener { onItemClick(item) } + } + + override fun getItemCount() = items.size + + class VH(private val b: ItemDdMenuBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(item: DdMenuItem, doc: DdDocument, results: Map) { + when (item) { + is DdMenuItem.SubMenu -> { + val label = doc.menus[item.name]?.label?.ifBlank { item.name } ?: item.label.ifBlank { item.name } + b.tvLabel.text = label + b.tvType.text = "МЕНЮ >" + b.tvType.setTextColor(0xFF1565C0.toInt()) + b.tvHelp.isVisible = false + b.tvResult.isVisible = false + } + is DdMenuItem.Var -> { + val v = doc.variables[item.name] + b.tvLabel.text = v?.label?.ifBlank { item.name } ?: item.label.ifBlank { item.name } + b.tvType.text = if (v != null) "${v.classDd} ${v.type}".trim() else "VARIABLE" + b.tvType.setTextColor(0xFF555555.toInt()) + b.tvHelp.text = v?.help ?: "" + b.tvHelp.isVisible = !v?.help.isNullOrBlank() + b.tvResult.isVisible = false + } + is DdMenuItem.Cmd -> { + val cmd = doc.commands[item.number] + b.tvLabel.text = cmd?.label?.ifBlank { "Cmd ${item.number}" } ?: item.label.ifBlank { "Cmd ${item.number}" } + b.tvType.text = "CMD ${item.number}" + b.tvType.setTextColor(0xFF2E7D32.toInt()) + b.tvHelp.isVisible = false + + // Показываем результат выполнения команды + val result = results[item.number] + if (result != null) { + b.tvResult.text = result + b.tvResult.isVisible = true + b.tvResult.setTextColor( + if (result.startsWith("Ошибка") || result.startsWith("Нет ответа") || result.startsWith("Не поддерживается")) + 0xFFD32F2F.toInt() + else if (result == "Чтение...") + 0xFF757575.toInt() + else + 0xFF1B5E20.toInt() + ) + } else { + b.tvResult.isVisible = false + } + } + is DdMenuItem.Method -> { + b.tvLabel.text = item.label.ifBlank { item.name } + b.tvType.text = "МЕТОД" + b.tvType.setTextColor(0xFF6A1B9A.toInt()) + b.tvHelp.isVisible = false + b.tvResult.isVisible = false + } + } + } + } +} + +// ---- Адаптер: список файлов ---- + +class DdFilesAdapter( + private val onOpen: (String) -> Unit, + private val onDelete: (String) -> Unit +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitList(list: List) { items = list; notifyDataSetChanged() } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + VH(ItemDdFileBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position]) + override fun getItemCount() = items.size + + inner class VH(private val b: ItemDdFileBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(name: String) { + b.tvFileName.text = name + b.btnOpen.setOnClickListener { onOpen(name) } + b.btnDelete.setOnClickListener { onDelete(name) } + } + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceFragment.kt new file mode 100644 index 0000000..fb45159 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceFragment.kt @@ -0,0 +1,287 @@ +package ru.kaptsov.hartmobile.ui + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.content.FileProvider +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.navigation.fragment.findNavController +import ru.kaptsov.hartmobile.R +import ru.kaptsov.hartmobile.databinding.FragmentDeviceBinding +import ru.kaptsov.hartmobile.protocol.HartFileLogger + +class DeviceFragment : Fragment() { + + private var _binding: FragmentDeviceBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentDeviceBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupMenu() + + binding.btnReadOnce.setOnClickListener { + viewModel.readVariables() + } + + binding.btnTogglePolling.setOnClickListener { + val isPolling = viewModel.state.value?.isPolling == true + if (isPolling) viewModel.stopPolling() + else viewModel.startPolling(intervalMs = 2000L) + } + + binding.btnDeviceVariables.setOnClickListener { + findNavController().navigate(R.id.action_device_to_deviceVariables) + } + + binding.btnDdMenu.setOnClickListener { + findNavController().navigate(R.id.action_device_to_ddMenu) + } + + binding.btnLoopTest.setOnClickListener { + findNavController().navigate(R.id.action_device_to_loopTest) + } + + binding.btnPollScan.setOnClickListener { + findNavController().navigate(R.id.action_device_to_pollScan) + } + + // Trend navigation: click on variable value → open trend chart + binding.tvPv.setOnClickListener { navigateToTrend("pv") } + binding.tvSv.setOnClickListener { navigateToTrend("sv") } + binding.tvTv.setOnClickListener { navigateToTrend("tv") } + binding.tvQv.setOnClickListener { navigateToTrend("qv") } + binding.tvLoopCurrent.setOnClickListener { navigateToTrend("current") } + binding.tvPercent.setOnClickListener { navigateToTrend("percent") } + + binding.btnShareLog.setOnClickListener { + shareLogFile() + } + + binding.btnDisconnect.setOnClickListener { + viewModel.disconnect() + findNavController().navigateUp() + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + // Информация об устройстве + state.deviceInfo?.let { info -> + binding.tvManufacturer.text = "${info.manufacturerName} (0x${info.manufacturerIdHex})" + binding.tvDeviceId.text = "ID: ${info.deviceIdHex} Type: 0x${info.deviceTypeHex}" + binding.tvHartRevision.text = "HART rev: ${info.hartRevision}" + binding.tvDeviceRevision.text = "Dev rev: ${info.deviceRevision}" + } + state.tagInfo?.let { tag -> + binding.tvTagName.text = if (tag.tag.isBlank()) "(тэг не задан)" else tag.tag + binding.tvDescriptor.text = tag.descriptor + binding.tvDate.text = tag.date + } + + // Переменные + state.variables?.let { v -> + binding.tvPv.text = v.pvStr + binding.tvSv.text = v.svStr + binding.tvTv.text = v.tvStr + binding.tvQv.text = v.qvStr + binding.tvLoopCurrent.text = v.currentStr + binding.tvPercent.text = v.percentStr + } + + // Кнопка polling + binding.btnTogglePolling.text = if (state.isPolling) "Стоп автообновления" else "Автообновление (2 с)" + + // Статус подключения — цвет и текст зависят от реального ответа HART + val (statusText, statusColor) = when { + state.connectionState == ConnectionState.ERROR -> + "Ошибка: ${state.errorMessage}" to 0xFFD32F2F.toInt() + state.isPolling -> + "Опрос HART…" to 0xFF388E3C.toInt() + state.deviceInfo != null -> + "HART подключён · ${state.deviceInfo.manufacturerName}" to 0xFF388E3C.toInt() + state.connectionState == ConnectionState.CONNECTED -> + "Bluetooth OK, HART не отвечает — проверьте модем" to 0xFFE65100.toInt() + else -> + "Подключено" to 0xFF388E3C.toInt() + } + binding.tvConnectionStatus.text = statusText + binding.tvConnectionStatus.setTextColor(statusColor) + } + } + + private fun setupMenu() { + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.device_menu, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_address -> { + showAddressDialog() + true + } + R.id.action_write_tag -> { + showWriteTagDialog() + true + } + R.id.action_diagnostics -> { + viewModel.runDiagnostics() + android.widget.Toast.makeText(requireContext(), "Диагностика запущена — см. логи", android.widget.Toast.LENGTH_SHORT).show() + true + } + R.id.action_help -> { + showHelpDialog() + true + } + else -> false + } + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + private fun navigateToTrend(varName: String) { + // Only navigate if variable has data + val vars = viewModel.state.value?.variables ?: return + val hasData = when (varName) { + "pv" -> vars.pv != null + "sv" -> vars.sv != null + "tv" -> vars.tv != null + "qv" -> vars.qv != null + "current" -> vars.loopCurrentMa != null + "percent" -> vars.percentRange != null + else -> false + } + if (!hasData) { + android.widget.Toast.makeText(requireContext(), "Нет данных", android.widget.Toast.LENGTH_SHORT).show() + return + } + // Stop main polling to avoid conflicts + viewModel.stopPolling() + val args = Bundle().apply { putString("trendVar", varName) } + findNavController().navigate(R.id.action_device_to_trend, args) + } + + private fun showHelpDialog() { + android.app.AlertDialog.Builder(requireContext()) + .setTitle("HART Mobile — Помощь") + .setMessage(""" +Приложение для работы с HART-устройствами через модем BriC BT (Bluetooth SPP) или USB Type-C (CP210x). + +HART — промышленный протокол связи с датчиками. Скорость 1200 бод, протокол Bell 202 FSK. + +═══ ЭКРАН УСТРОЙСТВА ═══ +• Прочитать переменные — однократное чтение PV/SV/TV/QV и тока петли (Команды 1, 2, 3) +• Автообновление — циклическое чтение раз в 2 сек +• Адрес HART (меню ⋮) — изменить опросный адрес (0 = обычный, 1–15 = multi-drop) + +═══ ПЕРЕМЕННЫЕ УСТРОЙСТВА ═══ +Кнопка «Переменные устройства (Cmd 9)» запускает автоматическое сканирование кодов 0–50 через HART Команду 9. Это позволяет обнаружить все специфичные переменные производителя (например, для ELEMER РЭМ: объёмный расход, тотализаторы, температура и др.) без ручного задания кодов. + +═══ LOOP TEST ═══ +Управление токовой петлей 4–20 мА (HART Команда 40). Выбор из предустановок или ручной ввод. Обязательно нажмите «Выйти из Loop Test» по завершении! + +═══ ПОИСК УСТРОЙСТВ ═══ +Перебирает HART адреса 0–15 и находит все устройства на шине (Команда 0). + +═══ DD ФАЙЛЫ ═══ +Хранилище DD файлов производителя. Текстовые файлы (.sym, .dd) разбираются и показывают структуру меню. Бинарные файлы (.fm8, .fm6) можно хранить, но их содержимое недоступно для чтения (проприетарный формат FieldComm Group). + """.trimIndent()) + .setPositiveButton("Понятно", null) + .show() + } + + private fun showWriteTagDialog() { + val currentTag = viewModel.state.value?.tagInfo + val inputTag = android.widget.EditText(requireContext()).apply { + hint = "Тэг (до 32 символов)" + setText(currentTag?.tag?.trim() ?: "") + filters = arrayOf(android.text.InputFilter.LengthFilter(32)) + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS + } + val layout = android.widget.LinearLayout(requireContext()).apply { + orientation = android.widget.LinearLayout.VERTICAL + setPadding(48, 24, 48, 0) + addView(inputTag) + } + android.app.AlertDialog.Builder(requireContext()) + .setTitle("Записать тэг устройства") + .setMessage("HART Long Tag (Cmd 22): до 32 символов, A-Z, 0-9, пробел. Строчные будут заглавными.") + .setView(layout) + .setPositiveButton("Записать") { _, _ -> + val tag = inputTag.text.toString() + if (tag.isBlank()) { + android.widget.Toast.makeText(requireContext(), "Введите тэг", android.widget.Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + android.widget.Toast.makeText(requireContext(), "Записываю...", android.widget.Toast.LENGTH_SHORT).show() + viewModel.writeTag(tag, "") { _, msg -> + android.widget.Toast.makeText(requireContext(), msg, android.widget.Toast.LENGTH_LONG).show() + } + } + .setNegativeButton("Отмена", null) + .show() + } + + private fun showAddressDialog() { + val current = viewModel.state.value?.hartAddress ?: 0 + val input = android.widget.EditText(requireContext()).apply { + inputType = android.text.InputType.TYPE_CLASS_NUMBER + setText(current.toString()) + hint = "0–15" + } + android.app.AlertDialog.Builder(requireContext()) + .setTitle("HART опросный адрес") + .setMessage("Введите адрес устройства (0 = по умолчанию, 1–15 = multi-drop):") + .setView(input) + .setPositiveButton("OK") { _, _ -> + val addr = input.text.toString().toIntOrNull()?.coerceIn(0, 15) ?: 0 + viewModel.setHartAddress(addr) + viewModel.readVariables() + } + .setNegativeButton("Отмена", null) + .show() + } + + private fun shareLogFile() { + val logFile = HartFileLogger.getLogFile() + if (logFile == null || !logFile.exists()) { + android.widget.Toast.makeText(requireContext(), "Лог-файл не найден", android.widget.Toast.LENGTH_SHORT).show() + return + } + try { + val uri = FileProvider.getUriForFile( + requireContext(), + "${requireContext().packageName}.fileprovider", + logFile + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "HART Mobile Log") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(intent, "Отправить лог")) + } catch (e: Exception) { + android.widget.Toast.makeText(requireContext(), "Ошибка: ${e.message}", android.widget.Toast.LENGTH_LONG).show() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceVariablesFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceVariablesFragment.kt new file mode 100644 index 0000000..8129391 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceVariablesFragment.kt @@ -0,0 +1,95 @@ +package ru.kaptsov.hartmobile.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.kaptsov.hartmobile.databinding.FragmentDeviceVariablesBinding +import ru.kaptsov.hartmobile.databinding.ItemDeviceVariableBinding +import ru.kaptsov.hartmobile.protocol.DeviceVariable + +class DeviceVariablesFragment : Fragment() { + + private var _binding: FragmentDeviceVariablesBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + private val adapter = VariablesAdapter() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentDeviceVariablesBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.rvVariables.layoutManager = LinearLayoutManager(requireContext()) + binding.rvVariables.adapter = adapter + + binding.btnScan.setOnClickListener { + viewModel.scanDeviceVariables() + } + + binding.btnRefresh.setOnClickListener { + viewModel.refreshDeviceVariables() + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + val scanning = state.isVarScanning + binding.progressBar.visibility = if (scanning) View.VISIBLE else View.GONE + binding.btnScan.isEnabled = !scanning + binding.btnRefresh.isEnabled = !scanning && !state.discoveredVariables.isNullOrEmpty() + binding.tvScanStatus.text = state.varScanStatus + ?: "Нажмите «Сканировать» для поиска переменных (коды 0–50)" + + val vars = state.discoveredVariables + if (vars != null) { + adapter.submitList(vars) + } + } + + // Автозапуск сканирования если ещё не делали + if (viewModel.state.value?.discoveredVariables == null && + viewModel.state.value?.isVarScanning == false) { + viewModel.scanDeviceVariables() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +// ---- RecyclerView Adapter ---- + +private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: DeviceVariable, b: DeviceVariable) = a.code == b.code + override fun areContentsTheSame(a: DeviceVariable, b: DeviceVariable) = a == b +} + +class VariablesAdapter : ListAdapter(DIFF) { + + inner class VH(val binding: ItemDeviceVariableBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val b = ItemDeviceVariableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return VH(b) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val v = getItem(position) + holder.binding.tvVarLabel.text = v.label + holder.binding.tvVarClass.text = buildString { + append("Код: ${v.code}") + if (v.classStr.isNotEmpty()) append(" · ${v.classStr}") + } + holder.binding.tvVarValue.text = v.valueStr + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/LicenseFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/LicenseFragment.kt new file mode 100644 index 0000000..a435223 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/LicenseFragment.kt @@ -0,0 +1,129 @@ +package ru.kaptsov.hartmobile.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.addCallback +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import ru.kaptsov.hartmobile.R +import ru.kaptsov.hartmobile.databinding.FragmentLicenseBinding +import ru.kaptsov.hartmobile.license.ActivationResult +import ru.kaptsov.hartmobile.license.LicenseManager +import ru.kaptsov.hartmobile.license.LicenseStatus + +class LicenseFragment : Fragment() { + + private var _binding: FragmentLicenseBinding? = null + private val binding get() = _binding!! + private lateinit var licenseManager: LicenseManager + + private val TELEGRAM_BOT_URL = "https://t.me/HART_Mobile_bot" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentLicenseBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + licenseManager = LicenseManager(requireContext()) + + // Кнопка "Назад" минимизирует приложение (не возвращает к ScanFragment без активации) + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + requireActivity().moveTaskToBack(true) + } + + updateUi() + + // Копировать Activation ID в буфер обмена + binding.btnCopyId.setOnClickListener { + val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("Activation ID", licenseManager.activationId)) + Toast.makeText(requireContext(), "ID скопирован в буфер обмена", Toast.LENGTH_SHORT).show() + } + + // Открыть Telegram бот + binding.btnTelegram.setOnClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(TELEGRAM_BOT_URL))) + } + + // Активация кода + binding.btnActivate.setOnClickListener { + val code = binding.etCode.text?.toString()?.trim() ?: "" + if (code.length < 10) { + Toast.makeText(requireContext(), "Введите код активации", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + when (licenseManager.activateCode(code)) { + ActivationResult.SUCCESS_YEAR -> + onActivationSuccess("Лицензия активирована на 1 год!") + ActivationResult.SUCCESS_FOREVER -> + onActivationSuccess("Постоянная лицензия активирована!") + ActivationResult.INVALID_CODE -> + Toast.makeText(requireContext(), + "Неверный код. Проверьте правильность ввода и соответствие ID устройства.", + Toast.LENGTH_LONG).show() + } + } + + // Продолжить пробный период (скрыта если уже истёк) + binding.btnContinueTrial.setOnClickListener { + findNavController().navigateUp() + } + } + + private fun updateUi() { + val id = licenseManager.activationId + binding.tvActivationId.text = id + + when (licenseManager.status) { + LicenseStatus.TRIAL_ACTIVE -> { + val days = licenseManager.trialDaysLeft + binding.tvLicenseStatus.text = "Пробный период: осталось $days ${daysWord(days)}" + binding.tvLicenseStatus.setTextColor(0xFF388E3C.toInt()) + binding.btnContinueTrial.isVisible = true + binding.btnContinueTrial.text = "Продолжить пробный период (осталось $days ${daysWord(days)})" + } + LicenseStatus.TRIAL_EXPIRED -> { + binding.tvLicenseStatus.text = "Пробный период завершён" + binding.tvLicenseStatus.setTextColor(0xFFD32F2F.toInt()) + binding.btnContinueTrial.isVisible = false + } + LicenseStatus.YEAR_EXPIRED -> { + binding.tvLicenseStatus.text = "Срок годовой лицензии истёк — требуется обновление" + binding.tvLicenseStatus.setTextColor(0xFFE65100.toInt()) + binding.btnContinueTrial.isVisible = false + } + LicenseStatus.LICENSED_YEAR, LicenseStatus.LICENSED_FOREVER -> { + // Не должны попасть сюда — ScanFragment не навигирует при активной лицензии + findNavController().navigateUp() + } + } + } + + private fun onActivationSuccess(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + findNavController().navigateUp() + } + + private fun daysWord(n: Int): String = when { + n % 10 == 1 && n % 100 != 11 -> "день" + n % 10 in 2..4 && n % 100 !in 12..14 -> "дня" + else -> "дней" + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/LoopTestFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/LoopTestFragment.kt new file mode 100644 index 0000000..1c61373 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/LoopTestFragment.kt @@ -0,0 +1,92 @@ +package ru.kaptsov.hartmobile.ui + +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ru.kaptsov.hartmobile.databinding.FragmentLoopTestBinding + +class LoopTestFragment : Fragment() { + + private var _binding: FragmentLoopTestBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + + // Фиксированные значения токов (мА) + private val presets = listOf(3.6f, 4.0f, 8.0f, 12.0f, 16.0f, 20.0f, 21.5f) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentLoopTestBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Кнопки предустановленных токов + val presetButtons = listOf( + binding.btn3p6, binding.btn4, binding.btn8, + binding.btn12, binding.btn16, binding.btn20, binding.btn21p5 + ) + presetButtons.forEachIndexed { i, btn -> + btn.text = "%.1f мА".format(presets[i]) + btn.setOnClickListener { + binding.etManual.setText("") + sendCurrent(presets[i]) + } + } + + // Кнопка "Задать" для ручного ввода + binding.btnSetManual.setOnClickListener { + val text = binding.etManual.text.toString().replace(',', '.') + val value = text.toFloatOrNull() + when { + value == null -> Toast.makeText(requireContext(), "Введите число", Toast.LENGTH_SHORT).show() + value < 3.6f || value > 21.5f -> Toast.makeText( + requireContext(), "Допустимый диапазон: 3.6 – 21.5 мА", Toast.LENGTH_SHORT).show() + else -> sendCurrent(value) + } + } + + // Кнопка "Стоп / Выход из Loop Test" + binding.btnExitLoop.setOnClickListener { + viewModel.loopTestExit() + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + val active = state.loopTestState == LoopTestState.ACTIVE + binding.tvActiveCurrent.text = if (active && state.loopTestCurrentMa != null) + "Активный ток: %.3f мА".format(state.loopTestCurrentMa) + else + "Loop Test не активен" + binding.tvActiveCurrent.setTextColor( + if (active) 0xFF388E3C.toInt() else 0xFF757575.toInt() + ) + + binding.btnExitLoop.isEnabled = state.loopTestState != LoopTestState.IDLE + binding.tvMessage.text = state.loopTestMessage ?: "" + binding.tvMessage.isVisible = !state.loopTestMessage.isNullOrBlank() + + // Блокируем кнопки пока идёт команда + val busy = state.loopTestState == LoopTestState.EXITING + presetButtons.forEach { it.isEnabled = !busy } + binding.btnSetManual.isEnabled = !busy + } + } + + private fun sendCurrent(ma: Float) { + viewModel.loopTestSet(ma) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/MainViewModel.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/MainViewModel.kt new file mode 100644 index 0000000..6f380cd --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/MainViewModel.kt @@ -0,0 +1,1157 @@ +package ru.kaptsov.hartmobile.ui + +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.content.Context +import android.hardware.usb.UsbManager +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.hoho.android.usbserial.driver.UsbSerialProber +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.kaptsov.hartmobile.connection.BluetoothSppConnection +import ru.kaptsov.hartmobile.connection.IConnection +import ru.kaptsov.hartmobile.connection.UsbSerialConnection +import ru.kaptsov.hartmobile.protocol.DeviceVariable +import ru.kaptsov.hartmobile.protocol.HartDeviceInfo +import ru.kaptsov.hartmobile.protocol.HartFileLogger +import ru.kaptsov.hartmobile.protocol.HartFrame +import ru.kaptsov.hartmobile.protocol.HartParser +import ru.kaptsov.hartmobile.protocol.HartTagInfo +import ru.kaptsov.hartmobile.protocol.HartVariables +import ru.kaptsov.hartmobile.dd.SymParser + +enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } + +enum class LoopTestState { IDLE, ACTIVE, EXITING } + +data class PollScanResult( + val address: Int, + val deviceId: String, + val tag: String, + val manufacturer: String +) + +data class UiState( + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val errorMessage: String? = null, + val deviceInfo: HartDeviceInfo? = null, + val tagInfo: HartTagInfo? = null, + val variables: HartVariables? = null, + val hartAddress: Int = 0, + val isPolling: Boolean = false, + // Loop Test + val loopTestState: LoopTestState = LoopTestState.IDLE, + val loopTestCurrentMa: Float? = null, + val loopTestMessage: String? = null, + // Poll Scan + val isPollScanning: Boolean = false, + val pollScanProgress: Int = 0, // 0–15 + val pollScanResults: List = emptyList(), + // Device Variables (Command 9 scan) + val isVarScanning: Boolean = false, + val varScanStatus: String? = null, + val discoveredVariables: List? = null +) + +class MainViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "HART_VM" + } + + init { + HartFileLogger.init(application) + HartFileLogger.logEvent("MainViewModel created") + } + + private val _state = MutableLiveData(UiState()) + val state: LiveData = _state + + // Bluetooth + private val _pairedDevices = MutableLiveData>(emptyList()) + val pairedDevices: LiveData> = _pairedDevices + + private var connection: IConnection? = null + private var pollingJob: Job? = null + private var uniqueAddress: ByteArray? = null // 5-байтовый адрес для long frame (после Cmd 0) + + // ---- Bluetooth ---- + + @SuppressLint("MissingPermission") + fun loadPairedDevices() { + val bm = getApplication().getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val adapter: BluetoothAdapter? = bm?.adapter + if (adapter == null || !adapter.isEnabled) { + _pairedDevices.value = emptyList() + return + } + _pairedDevices.value = adapter.bondedDevices.toList() + } + + fun connectBluetooth(device: BluetoothDevice) { + viewModelScope.launch(Dispatchers.IO) { + Log.i(TAG, ">>> connectBluetooth: name=${device.name} addr=${device.address}") + HartFileLogger.logEvent("connectBluetooth: name=${device.name} addr=${device.address}") + setConnectionState(ConnectionState.CONNECTING) + val conn = BluetoothSppConnection(device) + try { + conn.connect() + connection = conn + Log.i(TAG, "BT socket connected, now reading device identity...") + HartFileLogger.logEvent("BT socket connected OK, reading device identity...") + setConnectionState(ConnectionState.CONNECTED) + readDeviceIdentity() + } catch (e: Exception) { + Log.e(TAG, "connectBluetooth FAILED: ${e.message}", e) + HartFileLogger.logError("connectBluetooth", e) + setError("BT ошибка: ${e.message}") + conn.close() + } + } + } + + // ---- USB ---- + + fun getUsbDevices(): List { + val usbManager = getApplication() + .getSystemService(Context.USB_SERVICE) as UsbManager + return usbManager.deviceList.values.toList() + } + + fun connectUsb() { + viewModelScope.launch(Dispatchers.IO) { + Log.i(TAG, ">>> connectUsb start") + HartFileLogger.logEvent("connectUsb start") + setConnectionState(ConnectionState.CONNECTING) + val context: Context = getApplication() + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager) + + Log.i(TAG, "USB drivers found: ${availableDrivers.size}") + availableDrivers.forEachIndexed { idx, drv -> + val d = drv.device + Log.i(TAG, " driver[$idx]: vid=0x%04X pid=0x%04X name=%s ports=%d".format( + d.vendorId, d.productId, d.deviceName, drv.ports.size)) + } + + if (availableDrivers.isEmpty()) { + Log.e(TAG, "No USB drivers — cable not connected?") + setError("USB устройство не найдено. Проверьте подключение кабеля.") + return@launch + } + + val driver = availableDrivers[0] + if (!usbManager.hasPermission(driver.device)) { + Log.e(TAG, "No USB permission for device ${driver.device.deviceName}") + setError("Нет разрешения на USB. Подключите кабель и разрешите доступ.") + return@launch + } + + val conn = UsbSerialConnection(context, driver) + try { + conn.connect() + connection = conn + Log.i(TAG, "USB connected, now reading device identity...") + HartFileLogger.logEvent("USB connected OK, reading device identity...") + setConnectionState(ConnectionState.CONNECTED) + readDeviceIdentity() + } catch (e: Exception) { + Log.e(TAG, "connectUsb FAILED: ${e.message}", e) + HartFileLogger.logError("connectUsb", e) + setError("USB ошибка: ${e.message}") + conn.close() + } + } + } + + // ---- HART команды ---- + + private suspend fun readDeviceIdentity() = withContext(Dispatchers.IO) { + val addr = _state.value?.hartAddress ?: 0 + Log.i(TAG, "=== readDeviceIdentity START === address=$addr") + HartFileLogger.logEvent("=== readDeviceIdentity START === address=$addr") + try { + // Команда 0: Уникальный ID + Log.i(TAG, "Sending Cmd0 (Read Unique ID) to address $addr...") + HartFileLogger.logEvent("Sending Cmd0 (Read Unique ID) to address $addr") + var cmd0resp = sendCommand(HartFrame.cmd0ReadUniqueId(addr), "Cmd0_ReadUniqueId") + if (cmd0resp == null) { + Log.w(TAG, "Cmd0: NO RESPONSE — retrying...") + HartFileLogger.logEvent("Cmd0 no response, retry in 1s...") + delay(1000) + cmd0resp = sendCommand(HartFrame.cmd0ReadUniqueId(addr), "Cmd0_Retry") + } + if (cmd0resp == null) { + Log.w(TAG, "Cmd0: NO RESPONSE (null) — HART device not answering!") + } else { + Log.i(TAG, "Cmd0 response: cmd=${cmd0resp.command} status1=0x%02X status2=0x%02X dataLen=${cmd0resp.data.size} commErr=${cmd0resp.communicationError} cmdNotSupp=${cmd0resp.commandNotSupported}".format(cmd0resp.status1, cmd0resp.status2)) + Log.d(TAG, "Cmd0 data: ${cmd0resp.data.toHex()}") + } + HartFileLogger.logParsed(0, cmd0resp) + val deviceInfo = cmd0resp?.let { HartParser.parseCmd0(it.data) } + HartFileLogger.logCmd0Result(deviceInfo) + if (deviceInfo != null) { + Log.i(TAG, "Cmd0 parsed: mfr=${deviceInfo.manufacturerName}(${deviceInfo.manufacturerId}) type=0x%04X id=${deviceInfo.deviceIdHex} hartRev=${deviceInfo.hartRevision} devRev=${deviceInfo.deviceRevision} swRev=${deviceInfo.softwareRevision}".format(deviceInfo.deviceType)) + // Строим unique address для long frame + uniqueAddress = HartFrame.buildUniqueAddress(deviceInfo.legacyMfrByte, deviceInfo.legacyDevTypeByte, deviceInfo.deviceId) + Log.i(TAG, "Long frame unique address: ${uniqueAddress!!.joinToString(" ") { "%02X".format(it) }}") + HartFileLogger.logEvent("Long frame address set: ${uniqueAddress!!.joinToString(" ") { "%02X".format(it) }}") + } else { + Log.w(TAG, "Cmd0 parse FAILED — deviceInfo=null") + uniqueAddress = null + } + + // Команда 20: Long Tag (HART 6+), затем Cmd 13 для descriptor/date + Log.i(TAG, "Sending Cmd20 (Read Long Tag)...") + HartFileLogger.logEvent("Sending Cmd20 (Read Long Tag)") + val cmd20resp = sendCommand(buildFrame(20), "Cmd20_ReadLongTag") + HartFileLogger.logParsed(20, cmd20resp) + var longTag: String? = null + if (cmd20resp != null && !cmd20resp.hasError && cmd20resp.data.isNotEmpty()) { + longTag = String(cmd20resp.data, Charsets.US_ASCII).trim() + HartFileLogger.logEvent("Cmd20 Long Tag: '$longTag'") + } + + Log.i(TAG, "Sending Cmd13 (Read Tag/Descriptor)...") + HartFileLogger.logEvent("Sending Cmd13 (Read Tag/Descriptor)") + val cmd13resp = sendCommand(buildFrame(13), "Cmd13_ReadTag") + HartFileLogger.logParsed(13, cmd13resp) + val tagInfoRaw = cmd13resp?.let { HartParser.parseCmd13(it.data) } + HartFileLogger.logCmd13Result(tagInfoRaw) + + // Используем Long Tag если доступен, иначе Short Tag из Cmd 13 + val tagInfo = if (longTag != null && tagInfoRaw != null) { + HartTagInfo(tag = longTag, descriptor = tagInfoRaw.descriptor, date = tagInfoRaw.date) + } else if (longTag != null) { + HartTagInfo(tag = longTag, descriptor = "", date = "") + } else { + tagInfoRaw + } + if (tagInfo != null) { + Log.i(TAG, "Tag result: tag='${tagInfo.tag}' desc='${tagInfo.descriptor}' date=${tagInfo.date}") + } + + withContext(Dispatchers.Main) { + _state.value = _state.value?.copy(deviceInfo = deviceInfo, tagInfo = tagInfo) + } + Log.i(TAG, "=== readDeviceIdentity DONE === deviceInfo=${deviceInfo != null} tagInfo=${tagInfo != null}") + HartFileLogger.logEvent("=== readDeviceIdentity DONE === deviceInfo=${deviceInfo != null} tagInfo=${tagInfo != null}") + + // После идентификации — читаем переменные + readVariables() + } catch (e: Exception) { + Log.e(TAG, "readDeviceIdentity EXCEPTION: ${e.message}", e) + HartFileLogger.logError("readDeviceIdentity", e) + setError("Ошибка чтения устройства: ${e.message}") + } + } + + fun readVariables() { + viewModelScope.launch(Dispatchers.IO) { + val addr = _state.value?.hartAddress ?: 0 + HartFileLogger.logEvent("readVariables start, address=$addr, longFrame=${uniqueAddress != null}") + try { + // Команда 3: Все динамические переменные + val cmd3resp = sendCommand(buildFrame(3), "Cmd3_ReadDynVars") + HartFileLogger.logParsed(3, cmd3resp) + var vars = cmd3resp?.let { HartParser.parseCmd3(it.data) } + HartFileLogger.logCmd3Result(vars) + + // Если команда 3 не поддерживается — читаем по одной + if (vars == null || cmd3resp?.commandNotSupported == true) { + HartFileLogger.logEvent("Cmd3 failed/unsupported, falling back to Cmd1+Cmd2") + val cmd1 = sendCommand(buildFrame(1), "Cmd1_ReadPV") + HartFileLogger.logParsed(1, cmd1) + val pv = cmd1?.let { HartParser.parseCmd1(it.data) } + val cmd2 = sendCommand(buildFrame(2), "Cmd2_ReadLoop") + HartFileLogger.logParsed(2, cmd2) + val loop = cmd2?.let { HartParser.parseCmd2(it.data) } + vars = HartVariables( + pv = pv?.second, pvUnit = pv?.first, + loopCurrentMa = loop?.first, percentRange = loop?.second + ) + HartFileLogger.logCmd3Result(vars) + } + + withContext(Dispatchers.Main) { + _state.value = _state.value?.copy(variables = vars) + } + } catch (e: Exception) { + HartFileLogger.logError("readVariables", e) + } + } + } + + fun startPolling(intervalMs: Long = 2000L) { + stopPolling() + pollingJob = viewModelScope.launch(Dispatchers.IO) { + _state.postValue(_state.value?.copy(isPolling = true)) + while (isActive) { + readVariablesInternal() + delay(intervalMs) + } + } + } + + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + _state.value = _state.value?.copy(isPolling = false) + } + + private suspend fun readVariablesInternal() { + val addr = _state.value?.hartAddress ?: 0 + try { + val cmd3resp = sendCommand(buildFrame(3), "Cmd3_Poll") + HartFileLogger.logParsed(3, cmd3resp) + val vars = cmd3resp?.let { HartParser.parseCmd3(it.data) } ?: return + HartFileLogger.logCmd3Result(vars) + _state.postValue(_state.value?.copy(variables = vars)) + } catch (e: Exception) { + HartFileLogger.logError("readVariablesInternal", e) + } + } + + /** + * Read variables via Cmd 3 and return the result (for TrendFragment). + * Also updates the main UiState so DeviceFragment stays in sync. + * Must be called from IO dispatcher. + */ + suspend fun readVariablesForTrend(): HartVariables? { + return try { + val cmd3resp = sendCommand(buildFrame(3), "Cmd3_Trend") + val vars = cmd3resp?.let { HartParser.parseCmd3(it.data) } + if (vars != null) { + _state.postValue(_state.value?.copy(variables = vars)) + } + vars + } catch (e: Exception) { + HartFileLogger.logError("readVariablesForTrend", e) + null + } + } + + fun setHartAddress(address: Int) { + _state.value = _state.value?.copy(hartAddress = address.coerceIn(0, 15)) + } + + // ---- Loop Test ---- + + fun loopTestSet(currentMa: Float) { + val addr = _state.value?.hartAddress ?: 0 + viewModelScope.launch(Dispatchers.IO) { + _state.postValue(_state.value?.copy( + loopTestState = LoopTestState.ACTIVE, + loopTestCurrentMa = currentMa, + loopTestMessage = "Устанавливаю %.3f мА...".format(currentMa) + )) + val data40 = ByteArray(4).also { + val bits = java.lang.Float.floatToIntBits(currentMa) + it[0] = (bits ushr 24).toByte() + it[1] = (bits ushr 16).toByte() + it[2] = (bits ushr 8).toByte() + it[3] = bits.toByte() + } + val resp = sendCommand(buildFrame(40, data40), "Cmd40_SetCurrent_${currentMa}mA") + val msg = when { + resp == null -> "Нет ответа от устройства" + resp.hasError -> "Ошибка: ${resp.responseCodeName} (статус: %02X %02X)".format(resp.status1, resp.status2) + else -> "Ток установлен: %.3f мА".format(currentMa) + } + _state.postValue(_state.value?.copy(loopTestMessage = msg)) + } + } + + fun loopTestExit() { + val addr = _state.value?.hartAddress ?: 0 + viewModelScope.launch(Dispatchers.IO) { + _state.postValue(_state.value?.copy( + loopTestState = LoopTestState.EXITING, + loopTestMessage = "Выхожу из Loop Test..." + )) + val data40exit = ByteArray(4) // ток = 0.0 мА → выход из фиксированного режима + val resp = sendCommand(buildFrame(40, data40exit), "Cmd40_ExitLoop") + val msg = if (resp != null && !resp.hasError) + "Loop Test завершён. Устройство вернулось в нормальный режим." + else + "Команда отправлена (проверьте устройство)" + _state.postValue(_state.value?.copy( + loopTestState = LoopTestState.IDLE, + loopTestCurrentMa = null, + loopTestMessage = msg + )) + } + } + + // ---- Poll Scan (опрос адресов 0–15) ---- + + private var pollScanJob: Job? = null + + fun startPollScan() { + pollScanJob?.cancel() + pollScanJob = viewModelScope.launch(Dispatchers.IO) { + HartFileLogger.logEvent("=== PollScan START ===") + _state.postValue(_state.value?.copy( + isPollScanning = true, + pollScanProgress = 0, + pollScanResults = emptyList() + )) + val found = mutableListOf() + for (addr in 0..15) { + if (!isActive) break + _state.postValue(_state.value?.copy(pollScanProgress = addr)) + + HartFileLogger.logEvent("PollScan: probing address $addr") + val resp = sendCommand(HartFrame.cmd0ReadUniqueId(addr), "Cmd0_PollScan_addr$addr") + HartFileLogger.logParsed(0, resp) + if (resp == null) { + HartFileLogger.logEvent("PollScan addr=$addr: no response") + } else { + HartFileLogger.logEvent("PollScan addr=$addr: commErr=${resp.communicationError} cmdNotSupp=${resp.commandNotSupported} status1=0x${"%02X".format(resp.status1)} status2=0x${"%02X".format(resp.status2)}") + } + if (resp != null && !resp.communicationError) { + val info = HartParser.parseCmd0(resp.data) + HartFileLogger.logCmd0Result(info) + // Для Cmd 13 используем long frame с unique address найденного устройства + val scanUa = info?.let { HartFrame.buildUniqueAddress(it.legacyMfrByte, it.legacyDevTypeByte, it.deviceId) } + val cmd13frame = if (scanUa != null) HartFrame.buildLongFrameRequest(13, scanUa) else HartFrame.buildRequest(13, addr) + val tagResp = sendCommand(cmd13frame, "Cmd13_PollScan_addr$addr") + HartFileLogger.logParsed(13, tagResp) + val tag = tagResp?.let { HartParser.parseCmd13(it.data)?.tag } ?: "" + found.add(PollScanResult( + address = addr, + deviceId = info?.deviceIdHex ?: "??????", + tag = tag.ifBlank { "(без тэга)" }, + manufacturer = info?.manufacturerName ?: "Неизвестно" + )) + HartFileLogger.logEvent("PollScan addr=$addr: FOUND device id=${info?.deviceIdHex} mfr=${info?.manufacturerName}") + _state.postValue(_state.value?.copy(pollScanResults = found.toList())) + } + } + HartFileLogger.logEvent("=== PollScan DONE === found ${found.size} devices") + _state.postValue(_state.value?.copy( + isPollScanning = false, + pollScanProgress = 15, + pollScanResults = found.toList() + )) + } + } + + fun stopPollScan() { + pollScanJob?.cancel() + _state.value = _state.value?.copy(isPollScanning = false) + } + + // ---- Device Variables Scan (Command 9) ---- + + private var varScanJob: Job? = null + + /** + * Сканирует коды переменных устройства от 0 до 50 пакетами по 4 (Command 9). + * Обнаруженные переменные с валидными значениями сохраняются в UiState.discoveredVariables. + */ + fun scanDeviceVariables() { + varScanJob?.cancel() + varScanJob = viewModelScope.launch(Dispatchers.IO) { + val addr = _state.value?.hartAddress ?: 0 + HartFileLogger.logEvent("=== scanDeviceVariables START === address=$addr") + _state.postValue(_state.value?.copy( + isVarScanning = true, + varScanStatus = "Сканирование кодов 0–50...", + discoveredVariables = null + )) + + val found = mutableListOf() + var commandSupported = true + var code = 0 + + while (isActive && code <= 50 && commandSupported) { + val batch = IntArray(4) { if (code + it <= 50) code + it else 250 } + HartFileLogger.logEvent("Cmd9 scan: codes ${batch.joinToString(",")}") + val cmd9data = ByteArray(4) { 250.toByte() } + batch.take(4).forEachIndexed { i, c -> cmd9data[i] = c.toByte() } + val resp = sendCommand(buildFrame(9, cmd9data), "Cmd9_scan_${code}-${code+3}") + HartFileLogger.logParsed(9, resp) + when { + resp == null -> { + HartFileLogger.logEvent("Cmd9 scan codes $code-${code+3}: no response") + } + resp.commandNotSupported -> { + HartFileLogger.logEvent("Cmd9 scan: command not supported by device") + commandSupported = false + } + else -> { + val vars = HartParser.parseCmd9(resp.data) + HartFileLogger.logCmd9Result(vars) + for (v in vars) { + if (v.code <= 50 && v.isValid) { + if (found.none { it.code == v.code }) found.add(v) + } + } + _state.postValue(_state.value?.copy( + varScanStatus = "Проверка кодов $code–${(code + 3).coerceAtMost(50)}... (найдено ${found.size})" + )) + } + } + code += 4 + } + + val status = when { + !commandSupported -> "Command 9 не поддерживается устройством" + found.isEmpty() -> "Специфичные переменные не обнаружены" + else -> "Найдено переменных: ${found.size}" + } + _state.postValue(_state.value?.copy( + isVarScanning = false, + varScanStatus = status, + discoveredVariables = found + )) + } + } + + /** + * Обновляет значения уже обнаруженных переменных через Command 9 (без повторного сканирования). + */ + fun refreshDeviceVariables() { + val existing = _state.value?.discoveredVariables ?: return + if (existing.isEmpty()) return + val addr = _state.value?.hartAddress ?: 0 + + viewModelScope.launch(Dispatchers.IO) { + val updated = mutableListOf() + val codes = existing.map { it.code } + + var i = 0 + while (i < codes.size) { + val batch = IntArray(4) { if (i + it < codes.size) codes[i + it] else 250 } + val cmd9data = ByteArray(4) { 250.toByte() } + batch.take(4).forEachIndexed { i, c -> cmd9data[i] = c.toByte() } + val resp = sendCommand(buildFrame(9, cmd9data), "Cmd9_refresh_${batch.joinToString(",")}") + HartFileLogger.logParsed(9, resp) + if (resp != null && !resp.commandNotSupported) { + val vars = HartParser.parseCmd9(resp.data) + HartFileLogger.logCmd9Result(vars) + for (v in vars) { + if (v.code != 250) updated.add(v) + } + } + i += 4 + } + + if (updated.isNotEmpty()) { + // Сохраняем порядок из existing, заменяем значения + val merged = existing.map { old -> + updated.find { it.code == old.code } ?: old + } + _state.postValue(_state.value?.copy(discoveredVariables = merged)) + } + } + } + + fun connectToFoundDevice(result: PollScanResult) { + setHartAddress(result.address) + viewModelScope.launch(Dispatchers.IO) { + readDeviceIdentity() + } + } + + // ---- Write Tag / Descriptor (Cmd 17, HART 7 — 24 bytes) ---- + + fun writeTag(tag: String, descriptor: String, onResult: (Boolean, String) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + HartFileLogger.logEvent("=== writeTag START === tag='$tag' desc='$descriptor'") + + var success = false + val messages = mutableListOf() + + // 1. Cmd 22 (Write Long Tag, HART 6+): 32 байта Latin-1 — основной путь + val longTagData = tag.uppercase().padEnd(32).take(32).toByteArray(Charsets.US_ASCII) + HartFileLogger.logEvent("Cmd22 (Write Long Tag, 32B)") + val resp22 = sendCommand(buildFrame(22, longTagData), "Cmd22_WriteLongTag") + HartFileLogger.logParsed(22, resp22) + when { + resp22 == null -> messages.add("Cmd 22: нет ответа") + resp22.hasError -> { + messages.add("Cmd 22: ${resp22.responseCodeName}") + HartFileLogger.logEvent("Cmd22 FAILED: ${resp22.responseCodeName}") + } + else -> { + success = true + messages.add("Тэг записан") + HartFileLogger.logEvent("Cmd22 OK") + } + } + + // 2. Cmd 17 (Write Short Tag + Descriptor + Date, 24B HART 7) + if (descriptor.isNotBlank()) { + val tagBytes = encodePacked(tag.uppercase().padEnd(8).take(8), 6) + val descBytes = encodePacked(descriptor.uppercase().padEnd(16).take(16), 12) + val now = java.util.Calendar.getInstance() + val dateBytes = byteArrayOf( + now.get(java.util.Calendar.DAY_OF_MONTH).toByte(), + (now.get(java.util.Calendar.MONTH) + 1).toByte(), + (now.get(java.util.Calendar.YEAR) - 1900).toByte() + ) + val cmd17data = tagBytes + descBytes + dateBytes + byteArrayOf(0, 0, 0) + HartFileLogger.logEvent("Cmd17 (24B) for descriptor") + val resp17 = sendCommand(buildFrame(17, cmd17data), "Cmd17_WriteDesc") + HartFileLogger.logParsed(17, resp17) + if (resp17 != null && !resp17.hasError) { + messages.add("Дескриптор записан") + HartFileLogger.logEvent("Cmd17 OK") + } else { + HartFileLogger.logEvent("Cmd17: ${resp17?.responseCodeName ?: "нет ответа"}") + } + } + + // Пауза для EEPROM + delay(1500) + + // 3. Перечитываем — Cmd 20 (Long Tag) + Cmd 13 (Descriptor/Date) + val cmd20resp = sendCommand(buildFrame(20), "Cmd20_Verify") + HartFileLogger.logParsed(20, cmd20resp) + var readTag: String? = null + if (cmd20resp != null && !cmd20resp.hasError && cmd20resp.data.isNotEmpty()) { + readTag = String(cmd20resp.data, Charsets.US_ASCII).trim() + HartFileLogger.logEvent("Verify Cmd20: '$readTag'") + } + + val cmd13resp = sendCommand(buildFrame(13), "Cmd13_Verify") + HartFileLogger.logParsed(13, cmd13resp) + val tagInfo13 = cmd13resp?.let { HartParser.parseCmd13(it.data) } + if (tagInfo13 != null) { + HartFileLogger.logEvent("Verify Cmd13: tag='${tagInfo13.tag}' desc='${tagInfo13.descriptor}' date=${tagInfo13.date}") + } + + // Обновляем UI — Long Tag + descriptor из Cmd 13 + val finalTagInfo = HartTagInfo( + tag = readTag ?: tagInfo13?.tag ?: "", + descriptor = tagInfo13?.descriptor ?: "", + date = tagInfo13?.date ?: "" + ) + withContext(Dispatchers.Main) { + _state.value = _state.value?.copy(tagInfo = finalTagInfo) + } + withContext(Dispatchers.Main) { onResult(success, messages.joinToString(". ")) } + } + } + + // ---- Диагностика (тестирует все гипотезы, результаты в логе) ---- + + fun runDiagnostics() { + viewModelScope.launch(Dispatchers.IO) { + HartFileLogger.logEvent("=== DIAGNOSTICS START ===") + + // 1. Cmd 48: Additional Device Status + HartFileLogger.logEvent("DIAG: Cmd48 — Read Additional Status") + val cmd48resp = sendCommand(buildFrame(48), "Cmd48_AdditionalStatus") + HartFileLogger.logParsed(48, cmd48resp) + if (cmd48resp != null) { + HartFileLogger.logEvent("DIAG Cmd48: status1=0x${"%02X".format(cmd48resp.status1)} respCode=${cmd48resp.responseCode}(${cmd48resp.responseCodeName}) data=${cmd48resp.data.joinToString(" ") { "%02X".format(it) }}") + } + + // 2. Cmd 15: Read Range Info + HartFileLogger.logEvent("DIAG: Cmd15 — Read Range Info") + val cmd15resp = sendCommand(buildFrame(15), "Cmd15_RangeInfo") + HartFileLogger.logParsed(15, cmd15resp) + if (cmd15resp != null) { + val range = HartParser.parseCmd15(cmd15resp.data) + if (range != null) { + HartFileLogger.logEvent("DIAG Cmd15: unit=${range.unitStr} upper=${range.upperRange} lower=${range.lowerRange} damping=${range.dampingSeconds}") + } else { + HartFileLogger.logEvent("DIAG Cmd15: respCode=${cmd15resp.responseCode}(${cmd15resp.responseCodeName}) data=${cmd15resp.data.joinToString(" ") { "%02X".format(it) }}") + } + } + + // 3. Cmd 17: Write Tag/Descriptor/Date — тест с разными форматами + val tagBytes = encodePacked("HARTTEST", 6) // 8 chars → 6 bytes + val descBytes = encodePacked("DIAGNOSTIC TEST ", 12) // 16 chars → 12 bytes + val dateBytes = byteArrayOf(16, 3, 126) // 16 марта 2026 (126 = 2026-1900) + + // 3a. Стандарт: 21 байт (tag 6 + desc 12 + date 3) + val cmd17std = tagBytes + descBytes + dateBytes + HartFileLogger.logEvent("DIAG: Cmd17 std (${cmd17std.size}B): ${cmd17std.joinToString(" ") { "%02X".format(it) }}") + val resp17std = sendCommand(buildFrame(17, cmd17std), "Cmd17_std_21B") + HartFileLogger.logParsed(17, resp17std) + HartFileLogger.logEvent("DIAG Cmd17 std: respCode=${resp17std?.responseCode}(${resp17std?.responseCodeName})") + + // 3b. HART 7 расширенный: 24 байта (21 + final assembly 3 байта) + val cmd17ext = cmd17std + byteArrayOf(0, 0, 0) + HartFileLogger.logEvent("DIAG: Cmd17 ext (${cmd17ext.size}B): ${cmd17ext.joinToString(" ") { "%02X".format(it) }}") + val resp17ext = sendCommand(buildFrame(17, cmd17ext), "Cmd17_ext_24B") + HartFileLogger.logParsed(17, resp17ext) + HartFileLogger.logEvent("DIAG Cmd17 ext: respCode=${resp17ext?.responseCode}(${resp17ext?.responseCodeName})") + + // 3c. Только тэг: 6 байт + HartFileLogger.logEvent("DIAG: Cmd17 tagOnly (${tagBytes.size}B): ${tagBytes.joinToString(" ") { "%02X".format(it) }}") + val resp17tag = sendCommand(buildFrame(17, tagBytes), "Cmd17_tagOnly_6B") + HartFileLogger.logParsed(17, resp17tag) + HartFileLogger.logEvent("DIAG Cmd17 tagOnly: respCode=${resp17tag?.responseCode}(${resp17tag?.responseCodeName})") + + // 3d. Тэг + дескриптор без даты: 18 байт + val cmd17noDate = tagBytes + descBytes + HartFileLogger.logEvent("DIAG: Cmd17 noDate (${cmd17noDate.size}B): ${cmd17noDate.joinToString(" ") { "%02X".format(it) }}") + val resp17noDate = sendCommand(buildFrame(17, cmd17noDate), "Cmd17_noDate_18B") + HartFileLogger.logParsed(17, resp17noDate) + HartFileLogger.logEvent("DIAG Cmd17 noDate: respCode=${resp17noDate?.responseCode}(${resp17noDate?.responseCodeName})") + + // 4. Re-read Cmd 13 to verify tag was written + HartFileLogger.logEvent("DIAG: Cmd13 — Re-read Tag after write") + val cmd13resp = sendCommand(buildFrame(13), "Cmd13_VerifyTag") + HartFileLogger.logParsed(13, cmd13resp) + val tagInfo = cmd13resp?.let { HartParser.parseCmd13(it.data) } + if (tagInfo != null) { + HartFileLogger.logEvent("DIAG Cmd13: tag='${tagInfo.tag}' desc='${tagInfo.descriptor}' date=${tagInfo.date}") + } + + // 5. Cmd 40 variations — try different approaches + HartFileLogger.logEvent("DIAG: Cmd40 tests — trying different formats") + + // 5a. Standard: mode=1, current=12.0 + val data40std = byteArrayOf(0x01, 0x41, 0x40, 0x00, 0x00) + val resp40std = sendCommand(buildFrame(40, data40std), "Cmd40_std_12mA") + HartFileLogger.logEvent("DIAG Cmd40 std mode=1 12mA: respCode=${resp40std?.responseCode}(${resp40std?.responseCodeName})") + + // 5b. Without mode byte — just 4 bytes of current (some devices use this) + val data40noMode = byteArrayOf(0x41, 0x40, 0x00, 0x00) // 12.0 mA float only + val resp40noMode = sendCommand(buildFrame(40, data40noMode), "Cmd40_noMode_12mA") + HartFileLogger.logEvent("DIAG Cmd40 noMode 12mA: respCode=${resp40noMode?.responseCode}(${resp40noMode?.responseCodeName}) data=${resp40noMode?.data?.joinToString(" ") { "%02X".format(it) } ?: "null"}") + + // 5c. Exit — 4 bytes (0.0 mA) + val data40exit = ByteArray(4) // current=0.0 + val resp40exit = sendCommand(buildFrame(40, data40exit), "Cmd40_exit_diag") + HartFileLogger.logEvent("DIAG Cmd40 exit 0mA: respCode=${resp40exit?.responseCode}(${resp40exit?.responseCodeName})") + + // 6. Cmd 0 with long frame (to see if response differs) + HartFileLogger.logEvent("DIAG: Cmd0 via long frame") + val cmd0long = sendCommand(buildFrame(0), "Cmd0_LongFrame") + HartFileLogger.logParsed(0, cmd0long) + if (cmd0long != null) { + HartFileLogger.logEvent("DIAG Cmd0 long: respCode=${cmd0long.responseCode} data=${cmd0long.data.joinToString(" ") { "%02X".format(it) }}") + } + + HartFileLogger.logEvent("=== DIAGNOSTICS DONE ===") + } + } + + // ---- DD Command Execution ---- + + /** Результаты выполнения DD команд: cmdNumber → текстовый результат */ + private val _ddResults = MutableLiveData>(emptyMap()) + val ddResults: LiveData> = _ddResults + + /** + * Выполнить произвольную HART команду из DD файла (только чтение, без данных). + */ + fun executeDdCommand(cmdNumber: Int, onResult: (Boolean, String) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + HartFileLogger.logEvent("executeDdCommand: Cmd $cmdNumber") + val resp = sendCommand(buildFrame(cmdNumber), "DdCmd_$cmdNumber") + HartFileLogger.logParsed(cmdNumber, resp) + val parsed = parseCommandResponse(cmdNumber, resp) + // Сохраняем результат + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + map[cmdNumber] = parsed.second + _ddResults.value = map + } + withContext(Dispatchers.Main) { onResult(parsed.first, parsed.second) } + } + } + + private var ddReadJob: Job? = null + /** Счётчик подряд идущих ошибок — для детекции потери связи */ + private var ddConsecutiveErrors = 0 + + /** + * Пакетное чтение команд. Останавливается при 2+ ошибках подряд (Broken pipe). + * Пропускает команды >255 (расширенные, 2-байтовые — не поддержаны нашим фреймом). + * Пропускает команды, которые уже были успешно прочитаны. + */ + fun readDdCommands(commands: List) { + ddReadJob?.cancel() + ddReadJob = viewModelScope.launch(Dispatchers.IO) { + val existingResults = _ddResults.value.orEmpty() + val toRead = commands.filter { cmd -> + // Пропускаем расширенные команды (>255) — требуют 2-байтовый номер + if (cmd > 255) return@filter false + // Пропускаем write/dangerous команды — нельзя слать без данных + if (cmd in SymParser.WRITE_COMMANDS || cmd in SymParser.DANGEROUS_COMMANDS) return@filter false + val existing = existingResults[cmd] + existing == null || existing == "Чтение..." || existing == "Нет ответа" || existing == "Связь потеряна" + } + if (toRead.isEmpty()) { + // Для пропущенных команд — ставим пояснение + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + for (cmd in commands) { + if (cmd in map) continue + when { + cmd > 255 -> map[cmd] = "Расширенная команда (>255)" + cmd in SymParser.DANGEROUS_COMMANDS -> map[cmd] = "Не отправляется автоматически (опасно)" + cmd in SymParser.WRITE_COMMANDS -> map[cmd] = "Команда записи (нужны данные)" + } + } + _ddResults.value = map + } + return@launch + } + + HartFileLogger.logEvent("readDdCommands: ${toRead.size} commands: ${toRead.joinToString(",")}") + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + for (cmd in toRead) map[cmd] = "Чтение..." + // Пометить пропущенные команды + for (cmd in commands) { + if (cmd in map) continue + when { + cmd > 255 -> map[cmd] = "Расширенная команда (>255)" + cmd in SymParser.DANGEROUS_COMMANDS -> map[cmd] = "Не отправляется автоматически" + cmd in SymParser.WRITE_COMMANDS -> map[cmd] = "Команда записи (нужны данные)" + } + } + _ddResults.value = map + } + + ddConsecutiveErrors = 0 + for (cmdNumber in toRead) { + if (!isActive) break + if (connection == null || ddConsecutiveErrors >= 2) { + HartFileLogger.logEvent("readDdCommands: stopping (conn=${connection != null}, errors=$ddConsecutiveErrors)") + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + for (cmd in toRead) { + if (map[cmd] == "Чтение...") map[cmd] = "Связь потеряна" + } + _ddResults.value = map + } + break + } + + val resp = sendCommand(buildFrame(cmdNumber), "DdBatch_$cmdNumber") + HartFileLogger.logParsed(cmdNumber, resp) + + if (resp == null) { + ddConsecutiveErrors++ + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + map[cmdNumber] = if (ddConsecutiveErrors >= 2) "Связь потеряна" else "Нет ответа" + _ddResults.value = map + } + continue + } + + ddConsecutiveErrors = 0 + val parsed = parseCommandResponse(cmdNumber, resp) + withContext(Dispatchers.Main) { + val map = _ddResults.value.orEmpty().toMutableMap() + map[cmdNumber] = parsed.second + _ddResults.value = map + } + } + HartFileLogger.logEvent("readDdCommands: DONE (errors=$ddConsecutiveErrors)") + } + } + + /** + * Парсит ответ HART команды в читаемый текст. + * Для стандартных команд использует известные парсеры. + * Для остальных — извлекает float/string из данных. + */ + private fun parseCommandResponse(cmdNumber: Int, resp: ru.kaptsov.hartmobile.protocol.HartResponse?): Pair { + if (resp == null) return false to "Нет ответа" + if (resp.commandNotSupported) return false to "Не поддерживается" + // responseCode 5 = Too few bytes — команда требует данные (write cmd sent without data) + if (resp.responseCode == 5) return false to "Нужны входные данные" + if (resp.hasError) return false to "Ошибка: ${resp.responseCodeName} (код ${resp.responseCode})" + if (resp.data.isEmpty()) return true to "OK" + + val data = resp.data + // Стандартные команды с известными парсерами + return when (cmdNumber) { + 0 -> { + val info = HartParser.parseCmd0(data) + if (info != null) true to "${info.manufacturerName}, ID: ${info.deviceIdHex}, HART rev ${info.hartRevision}" + else true to smartParseData(data) + } + 1 -> { + val pv = HartParser.parseCmd1(data) + if (pv != null) true to "PV: ${pv.second} ${HartParser.UNITS[pv.first] ?: "unit${pv.first}"}" + else true to smartParseData(data) + } + 2 -> { + val loop = HartParser.parseCmd2(data) + if (loop != null) true to "Ток: ${loop.first} мА, Диапазон: ${loop.second}%" + else true to smartParseData(data) + } + 3 -> { + val vars = HartParser.parseCmd3(data) + if (vars != null) true to "PV=${vars.pvStr} SV=${vars.svStr} TV=${vars.tvStr} QV=${vars.qvStr}" + else true to smartParseData(data) + } + 9 -> { + val dvars = HartParser.parseCmd9(data) + if (dvars.isNotEmpty()) { + true to dvars.joinToString("; ") { "V${it.code}=${it.valueStr}" } + } else true to smartParseData(data) + } + 12 -> { + // Read Message: 24 bytes packed ASCII (32 chars) + if (data.size >= 24) { + true to "Message: '${HartParser.decodePacked(data, 0, 24).trim()}'" + } else true to smartParseData(data) + } + 13 -> { + val tag = HartParser.parseCmd13(data) + if (tag != null) true to "Тэг: '${tag.tag}' Дескр: '${tag.descriptor}' Дата: ${tag.date}" + else true to smartParseData(data) + } + 14 -> { + // Read PV Sensor Info: SN(3 bytes) + unit(1) + upperLimit(4) + lowerLimit(4) + minSpan(4) + if (data.size >= 4) { + val sn = ((data[0].toInt() and 0xFF) shl 16) or + ((data[1].toInt() and 0xFF) shl 8) or (data[2].toInt() and 0xFF) + val unit = data[3].toInt() and 0xFF + val unitName = HartParser.UNITS[unit] ?: "unit$unit" + val parts = mutableListOf("SN: $sn", unitName) + if (data.size >= 8) parts.add("Upper: %.1f".format(extractFloat(data, 4))) + if (data.size >= 12) parts.add("Lower: %.1f".format(extractFloat(data, 8))) + if (data.size >= 16) parts.add("MinSpan: %.1f".format(extractFloat(data, 12))) + true to parts.joinToString(", ") + } else true to smartParseData(data) + } + 15 -> { + val range = HartParser.parseCmd15(data) + if (range != null) true to "${range.unitStr}: ${range.lowerRange}..${range.upperRange}, Демпф: ${range.dampingSeconds}с" + else true to smartParseData(data) + } + 20 -> { + // Read Long Tag: 32 bytes Latin-1 + true to "Long Tag: '${String(data, Charsets.US_ASCII).trim()}'" + } + 16 -> { + // Read Final Assembly Number: 3 bytes + if (data.size >= 3) { + val num = ((data[0].toInt() and 0xFF) shl 16) or + ((data[1].toInt() and 0xFF) shl 8) or (data[2].toInt() and 0xFF) + true to "Final Assembly: $num" + } else true to smartParseData(data) + } + 7 -> { + // Read Loop Configuration: polling addr + loop current mode + if (data.size >= 2) { + val pollAddr = data[0].toInt() and 0xFF + val loopMode = data[1].toInt() and 0xFF + true to "Polling addr: $pollAddr, Loop mode: $loopMode" + } else true to smartParseData(data) + } + 8 -> { + // Read Dynamic Variable Classifications + true to smartParseData(data) + } + 38 -> { + // Reset Configuration Change Flag: 2 bytes config change counter + if (data.size >= 2) { + val counter = ((data[0].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) + true to "Config change counter: $counter" + } else true to smartParseData(data) + } + 48 -> { + // Additional Status: show hex with interpretation + if (data.size >= 6) { + val deviceStatus = data[0].toInt() and 0xFF + val extDevStatus = data[1].toInt() and 0xFF + true to "DevStatus: %02X, ExtStatus: %02X, Data: %s".format( + deviceStatus, extDevStatus, + data.drop(2).joinToString(" ") { "%02X".format(it) }) + } else true to "Status: ${data.joinToString(" ") { "%02X".format(it) }}" + } + 128 -> { + // ELEMER Cmd 128: 2 flags + 2 floats (current + temperature) + if (data.size >= 10) { + val flag1 = data[0].toInt() and 0xFF + val flag2 = data[1].toInt() and 0xFF + val f1 = extractFloat(data, 2) + val f2 = extractFloat(data, 6) + true to "Ток: %.4f мА, Темп: %.1f°C (flags: %02X %02X)".format(f1, f2, flag1, flag2) + } else true to smartParseData(data) + } + 142 -> { + // ELEMER Cmd 142: 1 byte status/mode + if (data.size >= 1) { + true to "Mode/Status: ${data[0].toInt() and 0xFF}" + } else true to "OK" + } + else -> { + // Для неизвестных команд — умный парсинг + true to smartParseData(data) + } + } + } + + /** + * Умный парсинг данных ответа: пытается извлечь float-значения и строки. + */ + private fun smartParseData(data: ByteArray): String { + if (data.isEmpty()) return "OK" + val parts = mutableListOf() + + // Если данные кратны 4+ байтам и начинаются с разумных float-значений + var offset = 0 + // Первые байты могут быть enum/status — пропускаем единичные байты + while (offset < data.size) { + val remaining = data.size - offset + if (remaining >= 4) { + val f = extractFloat(data, offset) + if (f != null && f.isFinite() && !f.isNaN()) { + parts.add("%.4f".format(f)) + offset += 4 + continue + } + } + // Байт — показываем как hex + parts.add("%02X".format(data[offset])) + offset++ + } + return parts.joinToString(" ") + } + + private fun extractFloat(data: ByteArray, offset: Int): Float? { + if (offset + 4 > data.size) return null + val bits = ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + return java.lang.Float.intBitsToFloat(bits) + } + + /** Encode string to HART packed ASCII (6-bit). outLen = number of output bytes. */ + private fun encodePacked(text: String, outLen: Int): ByteArray { + val chars = text.uppercase().padEnd((outLen / 3) * 4, ' ') + val result = ByteArray(outLen) + var bi = 0 + var ci = 0 + while (bi + 2 < outLen && ci + 3 < chars.length) { + val c0 = (chars[ci].code - 0x20).coerceIn(0, 0x3F) + val c1 = (chars[ci + 1].code - 0x20).coerceIn(0, 0x3F) + val c2 = (chars[ci + 2].code - 0x20).coerceIn(0, 0x3F) + val c3 = (chars[ci + 3].code - 0x20).coerceIn(0, 0x3F) + result[bi] = ((c0 shl 2) or (c1 shr 4)).toByte() + result[bi + 1] = (((c1 and 0x0F) shl 4) or (c2 shr 2)).toByte() + result[bi + 2] = (((c2 and 0x03) shl 6) or c3).toByte() + bi += 3 + ci += 4 + } + return result + } + + fun disconnect() { + stopPolling() + viewModelScope.launch(Dispatchers.IO) { + HartFileLogger.logEvent("=== DISCONNECT ===") + connection?.close() + connection = null + uniqueAddress = null + setConnectionState(ConnectionState.DISCONNECTED) + withContext(Dispatchers.Main) { + _state.value = UiState() + } + } + } + + // ---- Приватные утилиты ---- + + /** + * Строит HART-фрейм: long frame если uniqueAddress доступен, иначе short frame. + * Cmd 0 всегда отправляется short frame (polling). + */ + private fun buildFrame(command: Int, data: ByteArray = ByteArray(0)): ByteArray { + val ua = uniqueAddress + return if (ua != null) { + HartFrame.buildLongFrameRequest(command, ua, data) + } else { + val addr = _state.value?.hartAddress ?: 0 + HartFrame.buildRequest(command, addr, data) + } + } + + private fun sendCommand(frame: ByteArray, cmdName: String = "unknown"): ru.kaptsov.hartmobile.protocol.HartResponse? { + val conn = connection ?: run { + Log.w(TAG, "sendCommand: connection is null!") + HartFileLogger.logEvent("sendCommand [$cmdName]: ABORTED — connection is null!") + return null + } + val cmdByte = if (frame.size > 7) frame[7].toInt() and 0xFF else -1 + Log.d(TAG, "--- sendCommand cmd=$cmdByte frameLen=${frame.size} ---") + Log.d(TAG, "TX frame: ${frame.toHex()}") + HartFileLogger.logTx(cmdName, frame) + + // Очищаем входной буфер от остатков предыдущего ответа + conn.flushInput() + + try { + conn.write(frame) + } catch (e: Exception) { + Log.e(TAG, "sendCommand WRITE failed: ${e.message}", e) + HartFileLogger.logError("sendCommand_write [$cmdName]", e) + return null + } + + Log.d(TAG, "Waiting 500ms for HART response...") + Thread.sleep(500) + + val raw: ByteArray + try { + raw = conn.read(1500) + } catch (e: Exception) { + Log.e(TAG, "sendCommand READ failed: ${e.message}", e) + HartFileLogger.logError("sendCommand_read [$cmdName]", e) + return null + } + + HartFileLogger.logRx(raw) + + if (raw.isEmpty()) { + Log.w(TAG, "sendCommand cmd=$cmdByte: EMPTY response (0 bytes)") + return null + } + Log.d(TAG, "RX raw ${raw.size} bytes: ${raw.toHex()}") + + val resp = HartParser.parseResponse(raw) + if (resp == null) { + Log.w(TAG, "sendCommand cmd=$cmdByte: parseResponse returned null (no valid HART frame found in ${raw.size} bytes)") + } else { + Log.d(TAG, "sendCommand cmd=$cmdByte: parsed OK — respCmd=${resp.command} st1=0x%02X st2=0x%02X dataLen=${resp.data.size} err=${resp.hasError}".format(resp.status1, resp.status2)) + } + return resp + } + + private fun ByteArray.toHex(): String = + joinToString(" ") { "%02X".format(it) } + + private fun setConnectionState(state: ConnectionState) { + _state.postValue(_state.value?.copy(connectionState = state, errorMessage = null)) + } + + private fun setError(msg: String) { + _state.postValue(_state.value?.copy(connectionState = ConnectionState.ERROR, errorMessage = msg)) + } + + override fun onCleared() { + super.onCleared() + HartFileLogger.logEvent("MainViewModel onCleared — closing connection and logger") + connection?.close() + HartFileLogger.close() + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/PollScanFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/PollScanFragment.kt new file mode 100644 index 0000000..dffb86b --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/PollScanFragment.kt @@ -0,0 +1,109 @@ +package ru.kaptsov.hartmobile.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ru.kaptsov.hartmobile.R +import ru.kaptsov.hartmobile.databinding.FragmentPollScanBinding +import ru.kaptsov.hartmobile.databinding.ItemPollResultBinding + +class PollScanFragment : Fragment() { + + private var _binding: FragmentPollScanBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + private lateinit var resultsAdapter: PollResultAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentPollScanBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + resultsAdapter = PollResultAdapter { result -> + viewModel.connectToFoundDevice(result) + findNavController().navigate(R.id.action_pollScan_to_device) + } + binding.recyclerResults.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = resultsAdapter + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + + binding.btnStartScan.setOnClickListener { + if (viewModel.state.value?.isPollScanning == true) { + viewModel.stopPollScan() + } else { + viewModel.startPollScan() + } + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + val scanning = state.isPollScanning + binding.btnStartScan.text = if (scanning) "Остановить" else "Начать поиск" + binding.progressBar.isVisible = scanning + binding.tvProgress.isVisible = scanning + binding.tvProgress.text = "Опрашиваю адрес ${state.pollScanProgress} / 15..." + + binding.tvNoResults.isVisible = + !scanning && state.pollScanResults.isEmpty() + + resultsAdapter.submitList(state.pollScanResults) + + val count = state.pollScanResults.size + binding.tvResultCount.text = if (count > 0) + "Найдено устройств: $count" + else if (!scanning) "Устройства не найдены" + else "" + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +// ---- RecyclerView адаптер ---- + +class PollResultAdapter( + private val onClick: (PollScanResult) -> Unit +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitList(list: List) { + items = list + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val b = ItemPollResultBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return VH(b) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.bind(items[position]) + holder.itemView.setOnClickListener { onClick(items[position]) } + } + + override fun getItemCount() = items.size + + class VH(private val b: ItemPollResultBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(r: PollScanResult) { + b.tvAddress.text = "Адрес: ${r.address}" + b.tvTag.text = r.tag + b.tvManufacturer.text = "${r.manufacturer} | ID: ${r.deviceId}" + } + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/ScanFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/ScanFragment.kt new file mode 100644 index 0000000..55b395b --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/ScanFragment.kt @@ -0,0 +1,200 @@ +package ru.kaptsov.hartmobile.ui + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.hardware.usb.UsbManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.hoho.android.usbserial.driver.UsbSerialProber +import ru.kaptsov.hartmobile.R +import ru.kaptsov.hartmobile.databinding.FragmentScanBinding +import ru.kaptsov.hartmobile.databinding.ItemBluetoothDeviceBinding +import ru.kaptsov.hartmobile.license.LicenseManager +import ru.kaptsov.hartmobile.license.LicenseStatus + +class ScanFragment : Fragment() { + + private var _binding: FragmentScanBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + private lateinit var btAdapter: BtDeviceAdapter + private lateinit var licenseManager: LicenseManager + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentScanBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + licenseManager = LicenseManager(requireContext()) + + btAdapter = BtDeviceAdapter { device -> viewModel.connectBluetooth(device) } + binding.recyclerBt.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = btAdapter + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + + binding.btnRefreshBt.setOnClickListener { + viewModel.loadPairedDevices() + } + + binding.btnAbout.setOnClickListener { + android.app.AlertDialog.Builder(requireContext()) + .setTitle("О программе") + .setMessage( + "HART Mobile\n" + + "Приложение для работы с HART-устройствами\n" + + "через Bluetooth (BriC BT) или USB (CP210x)\n\n" + + "Программист:\n" + + "Капцов Александр Александрович\n\n" + + "Благодарности:\n" + + "Багаутдинов Малик Ильдарович" + ) + .setPositiveButton("OK", null) + .show() + } + + binding.btnConnectUsb.setOnClickListener { + val usbManager = requireContext().getSystemService(UsbManager::class.java) + val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager) + if (drivers.isEmpty()) { + Toast.makeText(requireContext(), + "USB устройство не обнаружено. Подключите кабель.", Toast.LENGTH_LONG).show() + return@setOnClickListener + } + if (!usbManager.hasPermission(drivers[0].device)) { + Toast.makeText(requireContext(), + "Разрешите доступ к USB в появившемся диалоге.", Toast.LENGTH_LONG).show() + // Разрешение будет запрошено автоматически через device_filter.xml + return@setOnClickListener + } + viewModel.connectUsb() + } + + viewModel.pairedDevices.observe(viewLifecycleOwner) { devices -> + btAdapter.submitList(devices) + binding.tvNoBtDevices.isVisible = devices.isEmpty() + } + + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state.connectionState) { + ConnectionState.CONNECTING -> { + binding.progressBar.isVisible = true + binding.tvStatus.text = "Подключение..." + } + ConnectionState.CONNECTED -> { + binding.progressBar.isVisible = false + findNavController().navigate(R.id.action_scan_to_device) + } + ConnectionState.ERROR -> { + binding.progressBar.isVisible = false + binding.tvStatus.text = state.errorMessage ?: "Ошибка" + } + ConnectionState.DISCONNECTED -> { + binding.progressBar.isVisible = false + binding.tvStatus.text = "" + } + } + } + + // Загружаем сопряжённые устройства при открытии + viewModel.loadPairedDevices() + } + + override fun onStart() { + super.onStart() + checkLicense() + } + + private fun checkLicense() { + if (!licenseManager.isAllowed) { + findNavController().navigate(R.id.action_scan_to_license) + return + } + updateTrialBanner() + } + + private fun updateTrialBanner() { + when (licenseManager.status) { + LicenseStatus.TRIAL_ACTIVE -> { + val days = licenseManager.trialDaysLeft + binding.trialBanner.isVisible = true + binding.tvTrialStatus.text = "Пробный период: осталось $days ${daysWord(days)}" + binding.btnActivateFromBanner.setOnClickListener { + findNavController().navigate(R.id.action_scan_to_license) + } + } + else -> binding.trialBanner.isVisible = false + } + } + + private fun daysWord(n: Int): String = when { + n % 10 == 1 && n % 100 != 11 -> "день" + n % 10 in 2..4 && n % 100 !in 12..14 -> "дня" + else -> "дней" + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +// ---- RecyclerView адаптер для BT устройств ---- + +@SuppressLint("MissingPermission") +class BtDeviceAdapter( + private val onConnect: (BluetoothDevice) -> Unit +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitList(list: List) { + items = list + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val b = ItemBluetoothDeviceBinding.inflate( + LayoutInflater.from(parent.context), parent, false) + return VH(b) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val device = items[position] + holder.bind(device) + holder.itemView.setOnClickListener { onConnect(device) } + } + + override fun getItemCount() = items.size + + class VH(private val b: ItemBluetoothDeviceBinding) : RecyclerView.ViewHolder(b.root) { + @SuppressLint("MissingPermission") + fun bind(device: BluetoothDevice) { + b.tvDeviceName.text = device.name ?: "Неизвестное устройство" + b.tvDeviceAddress.text = device.address + // Выделяем BriC устройства + val isBric = device.name?.contains("BriC", ignoreCase = true) == true + b.tvDeviceName.setTextColor( + ContextCompat.getColor( + b.root.context, + if (isBric) android.R.color.holo_blue_dark else android.R.color.black + ) + ) + } + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendFragment.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendFragment.kt new file mode 100644 index 0000000..8d7e73c --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendFragment.kt @@ -0,0 +1,119 @@ +package ru.kaptsov.hartmobile.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.kaptsov.hartmobile.databinding.FragmentTrendBinding +import ru.kaptsov.hartmobile.protocol.HartParser + +/** + * Real-time trend chart for HART variables. + * Argument "trendVar" selects which variable to plot: + * "pv", "sv", "tv", "qv", "current", "percent" + */ +class TrendFragment : Fragment() { + + private var _binding: FragmentTrendBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + + private var pollJob: Job? = null + private var varMin = Float.MAX_VALUE + private var varMax = Float.MIN_VALUE + private var pointCount = 0 + + private val trendVar: String by lazy { + arguments?.getString("trendVar") ?: "pv" + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View { + _binding = FragmentTrendBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val (title, unit) = when (trendVar) { + "pv" -> "PV (первичная переменная)" to "" + "sv" -> "SV (вторичная переменная)" to "" + "tv" -> "TV (третичная переменная)" to "" + "qv" -> "QV (четвертичная переменная)" to "" + "current" -> "Ток петли" to "мА" + "percent" -> "% диапазона" to "%" + else -> "Тренд" to "" + } + binding.tvTrendTitle.text = title + + binding.btnClearTrend.setOnClickListener { + binding.trendView.clear() + varMin = Float.MAX_VALUE + varMax = Float.MIN_VALUE + pointCount = 0 + binding.tvMinMax.text = "Min: — / Max: —" + binding.tvCurrentValue.text = "—" + } + + startPolling(unit) + } + + private fun startPolling(fixedUnit: String) { + pollJob = viewLifecycleOwner.lifecycleScope.launch { + while (isActive) { + // Read variables from device in IO thread + val result = withContext(Dispatchers.IO) { + viewModel.readVariablesForTrend() + } + + if (result != null) { + val (value, unitCode) = extractValue(result) + if (value != null && value.isFinite() && !value.isNaN()) { + val unitStr = if (fixedUnit.isNotEmpty()) fixedUnit + else unitCode?.let { HartParser.unitName(it) } ?: "" + + binding.trendView.unitLabel = unitStr + binding.trendView.addPoint(value) + + if (value < varMin) varMin = value + if (value > varMax) varMax = value + pointCount++ + + binding.tvCurrentValue.text = "%.4f %s".format(value, unitStr) + binding.tvMinMax.text = "Min: %.4f / Max: %.4f".format(varMin, varMax) + } + } + + delay(1000L) + } + } + } + + /** Extract the requested variable value and its unit code from HartVariables. */ + private fun extractValue(vars: ru.kaptsov.hartmobile.protocol.HartVariables): Pair { + return when (trendVar) { + "pv" -> vars.pv to vars.pvUnit + "sv" -> vars.sv to vars.svUnit + "tv" -> vars.tv to vars.tvUnit + "qv" -> vars.qv to vars.qvUnit + "current" -> vars.loopCurrentMa to null + "percent" -> vars.percentRange to null + else -> null to null + } + } + + override fun onDestroyView() { + pollJob?.cancel() + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendView.kt b/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendView.kt new file mode 100644 index 0000000..0613343 --- /dev/null +++ b/app/src/main/java/ru/kaptsov/hartmobile/ui/TrendView.kt @@ -0,0 +1,191 @@ +package ru.kaptsov.hartmobile.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.View + +/** + * Real-time trend chart view. + * Draws a scrolling line graph with auto-scaling Y axis, grid, and labels. + */ +class TrendView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + data class Point(val timeMs: Long, val value: Float) + + private val points = mutableListOf() + private val maxPoints = 300 // 5 min at 1 sec interval + + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#1565C0") + strokeWidth = 4f + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#201565C0") + style = Paint.Style.FILL + } + + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#E0E0E0") + strokeWidth = 1f + style = Paint.Style.STROKE + } + + private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#757575") + textSize = 28f + } + + private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#1565C0") + textSize = 40f + isFakeBoldText = true + } + + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#1565C0") + style = Paint.Style.FILL + } + + // Margins for axis labels + private val marginLeft = 120f + private val marginRight = 20f + private val marginTop = 60f + private val marginBottom = 50f + + var unitLabel: String = "" + + fun addPoint(value: Float) { + points.add(Point(System.currentTimeMillis(), value)) + if (points.size > maxPoints) { + points.removeAt(0) + } + invalidate() + } + + fun clear() { + points.clear() + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val w = width.toFloat() + val h = height.toFloat() + val chartLeft = marginLeft + val chartRight = w - marginRight + val chartTop = marginTop + val chartBottom = h - marginBottom + val chartW = chartRight - chartLeft + val chartH = chartBottom - chartTop + + if (chartW <= 0 || chartH <= 0) return + + // Compute Y range + if (points.isEmpty()) { + canvas.drawText("Ожидание данных...", chartLeft + 20, chartTop + chartH / 2, labelPaint) + return + } + + var yMin = points.minOf { it.value } + var yMax = points.maxOf { it.value } + + // Add 10% padding, handle flat line + val span = yMax - yMin + if (span < 0.001f) { + yMin -= 1f + yMax += 1f + } else { + yMin -= span * 0.1f + yMax += span * 0.1f + } + + // Draw current value at top + val lastVal = points.last().value + val valText = "%.4f %s".format(lastVal, unitLabel) + canvas.drawText(valText, chartLeft, marginTop - 16, valuePaint) + + // Grid lines (5 horizontal) + val gridLines = 5 + for (i in 0..gridLines) { + val frac = i.toFloat() / gridLines + val y = chartTop + frac * chartH + canvas.drawLine(chartLeft, y, chartRight, y, gridPaint) + + val yVal = yMax - frac * (yMax - yMin) + val label = if (kotlin.math.abs(yVal) >= 100) "%.1f".format(yVal) + else if (kotlin.math.abs(yVal) >= 1) "%.2f".format(yVal) + else "%.4f".format(yVal) + canvas.drawText(label, 4f, y + 10f, labelPaint) + } + + // Time axis + val timeMin = points.first().timeMs + val timeMax = points.last().timeMs + val timeSpan = (timeMax - timeMin).coerceAtLeast(1L) + + // Map points to screen coordinates + fun mapX(t: Long) = chartLeft + ((t - timeMin).toFloat() / timeSpan) * chartW + fun mapY(v: Float) = chartTop + ((yMax - v) / (yMax - yMin)) * chartH + + // Draw fill under curve + if (points.size >= 2) { + val fillPath = Path() + fillPath.moveTo(mapX(points.first().timeMs), chartBottom) + for (p in points) { + fillPath.lineTo(mapX(p.timeMs), mapY(p.value)) + } + fillPath.lineTo(mapX(points.last().timeMs), chartBottom) + fillPath.close() + canvas.drawPath(fillPath, fillPaint) + } + + // Draw line + if (points.size >= 2) { + val linePath = Path() + linePath.moveTo(mapX(points[0].timeMs), mapY(points[0].value)) + for (i in 1 until points.size) { + linePath.lineTo(mapX(points[i].timeMs), mapY(points[i].value)) + } + canvas.drawPath(linePath, linePaint) + } + + // Draw last point dot + val lastX = mapX(points.last().timeMs) + val lastY = mapY(points.last().value) + canvas.drawCircle(lastX, lastY, 8f, dotPaint) + + // Time labels at bottom + val elapsed = (timeMax - timeMin) / 1000 + val timeLabel = if (elapsed < 60) "${elapsed}с" else "${elapsed / 60}м ${elapsed % 60}с" + canvas.drawText(timeLabel, chartRight - labelPaint.measureText(timeLabel), h - 8f, labelPaint) + canvas.drawText("0с", chartLeft, h - 8f, labelPaint) + + // Points count + val countText = "${points.size} точек" + val countPaint = Paint(labelPaint).apply { textSize = 22f } + canvas.drawText(countText, chartRight - countPaint.measureText(countText), marginTop - 20, countPaint) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desiredHeight = 500 + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + val h = when (hMode) { + MeasureSpec.EXACTLY -> hSize + MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(hSize) + else -> desiredHeight + } + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), h) + } +} diff --git a/app/src/main/res/drawable/bg_activation_id.xml b/app/src/main/res/drawable/bg_activation_id.xml new file mode 100644 index 0000000..b6c5eae --- /dev/null +++ b/app/src/main/res/drawable/bg_activation_id.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3dfe20b --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_dd_menu.xml b/app/src/main/res/layout/fragment_dd_menu.xml new file mode 100644 index 0000000..dd5827c --- /dev/null +++ b/app/src/main/res/layout/fragment_dd_menu.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_device.xml b/app/src/main/res/layout/fragment_device.xml new file mode 100644 index 0000000..d8b50d9 --- /dev/null +++ b/app/src/main/res/layout/fragment_device.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_device_variables.xml b/app/src/main/res/layout/fragment_device_variables.xml new file mode 100644 index 0000000..f546594 --- /dev/null +++ b/app/src/main/res/layout/fragment_device_variables.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_license.xml b/app/src/main/res/layout/fragment_license.xml new file mode 100644 index 0000000..b27903b --- /dev/null +++ b/app/src/main/res/layout/fragment_license.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_loop_test.xml b/app/src/main/res/layout/fragment_loop_test.xml new file mode 100644 index 0000000..af601c2 --- /dev/null +++ b/app/src/main/res/layout/fragment_loop_test.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_poll_scan.xml b/app/src/main/res/layout/fragment_poll_scan.xml new file mode 100644 index 0000000..1ee0b58 --- /dev/null +++ b/app/src/main/res/layout/fragment_poll_scan.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml new file mode 100644 index 0000000..68f6ed6 --- /dev/null +++ b/app/src/main/res/layout/fragment_scan.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_trend.xml b/app/src/main/res/layout/fragment_trend.xml new file mode 100644 index 0000000..17e49aa --- /dev/null +++ b/app/src/main/res/layout/fragment_trend.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_bluetooth_device.xml b/app/src/main/res/layout/item_bluetooth_device.xml new file mode 100644 index 0000000..0e21931 --- /dev/null +++ b/app/src/main/res/layout/item_bluetooth_device.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_dd_file.xml b/app/src/main/res/layout/item_dd_file.xml new file mode 100644 index 0000000..32431cb --- /dev/null +++ b/app/src/main/res/layout/item_dd_file.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_dd_menu.xml b/app/src/main/res/layout/item_dd_menu.xml new file mode 100644 index 0000000..1e4ef71 --- /dev/null +++ b/app/src/main/res/layout/item_dd_menu.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_device_variable.xml b/app/src/main/res/layout/item_device_variable.xml new file mode 100644 index 0000000..251c906 --- /dev/null +++ b/app/src/main/res/layout/item_device_variable.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_poll_result.xml b/app/src/main/res/layout/item_poll_result.xml new file mode 100644 index 0000000..9f8befe --- /dev/null +++ b/app/src/main/res/layout/item_poll_result.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/device_menu.xml b/app/src/main/res/menu/device_menu.xml new file mode 100644 index 0000000..55ee407 --- /dev/null +++ b/app/src/main/res/menu/device_menu.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..68deaf226bda635a8e1fc0dec16552b4d3c4ebb3 GIT binary patch literal 5054 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±PNJ#q6>MsMW1wNj0rLML% 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a2f3a597c8165f2aea2f4af96bf7a40f1ac89711 GIT binary patch literal 3054 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..05901c72d1f6fc58e0028a3aea371f8ecff88979 GIT binary patch literal 7449 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 XUV0Ql6>w)>00000NkvXXu0mjfoC}k~ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..e36a77b3c7e267540c3ebecea1b4c30b8e02395d GIT binary patch literal 12320 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..15b94a67fe2ad927605263d7eda37d4b5e88a7d2 GIT binary patch literal 16954 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 Rwfg`7002ovPDHLkV1jK26zu>2 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/store_icon_512.png b/app/src/main/res/mipmap-xxxhdpi/store_icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..15c31ddf0acc56ddc38f965ef8b361b4a4cddc3e GIT binary patch literal 48431 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..7d2611f --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-sw600dp/dimens.xml b/app/src/main/res/values-sw600dp/dimens.xml new file mode 100644 index 0000000..dcc11b5 --- /dev/null +++ b/app/src/main/res/values-sw600dp/dimens.xml @@ -0,0 +1,6 @@ + + + + 56dp + 640dp + diff --git a/app/src/main/res/values-sw600dp/styles.xml b/app/src/main/res/values-sw600dp/styles.xml new file mode 100644 index 0000000..6ff171b --- /dev/null +++ b/app/src/main/res/values-sw600dp/styles.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..52ae77f --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + + 16dp + + 640dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9bf72ce --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + HART Mobile + Подключение к HART-устройству + Параметры устройства + Loop Test + Поиск устройств + DD файл производителя + Переменные устройства + Тренд + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..38c761c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/device_filter.xml b/app/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..fc5ccfa --- /dev/null +++ b/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..3ffbddb --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0836ea8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/gen_icon.py b/gen_icon.py new file mode 100644 index 0000000..eafaa6a --- /dev/null +++ b/gen_icon.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate HART Mobile app icon with smooth FSK waveform.""" + +import math +from PIL import Image, ImageDraw + +SIZES = { + "mdpi": 48, + "hdpi": 72, + "xhdpi": 96, + "xxhdpi": 144, + "xxxhdpi": 192, +} + +BG_COLOR = (25, 82, 148) +WAVE_COLOR = (255, 255, 255) +CORNER_RADIUS_RATIO = 0.22 + + +def fsk_points(s, num=2000): + """Generate centerline points of FSK waveform on canvas size s.""" + margin_x = s * 0.10 + margin_y = s * 0.28 + w = s - 2 * margin_x + cy = s / 2 + amplitude = (s - 2 * margin_y) * 0.45 + + cycles_fast = 3.0 + cycles_slow = 1.5 + transition = cycles_fast / (cycles_fast + cycles_slow * 2) + + pts = [] + for i in range(num + 1): + t = i / num + x = margin_x + t * w + if t <= transition: + phase = 2 * math.pi * cycles_fast * (t / transition) + else: + local_t = (t - transition) / (1 - transition) + phase = 2 * math.pi * cycles_fast + 2 * math.pi * cycles_slow * local_t + y = cy - amplitude * math.sin(phase) + pts.append((x, y)) + return pts + + +def thick_curve_polygon(pts, thickness): + """Build a filled polygon representing a thick smooth curve.""" + half = thickness / 2.0 + upper = [] + lower = [] + for i in range(len(pts)): + # Compute tangent direction + if i == 0: + dx, dy = pts[1][0] - pts[0][0], pts[1][1] - pts[0][1] + elif i == len(pts) - 1: + dx, dy = pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1] + else: + dx, dy = pts[i + 1][0] - pts[i - 1][0], pts[i + 1][1] - pts[i - 1][1] + length = math.sqrt(dx * dx + dy * dy) + if length < 1e-9: + nx, ny = 0, 1 + else: + nx, ny = -dy / length, dx / length + x, y = pts[i] + upper.append((x + nx * half, y + ny * half)) + lower.append((x - nx * half, y - ny * half)) + + # Polygon: upper forward + lower backward + return upper + lower[::-1] + + +def generate_icon(size, round_mask=False): + scale = 4 + s = size * scale + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + radius = int(s * CORNER_RADIUS_RATIO) + draw.rounded_rectangle([0, 0, s - 1, s - 1], radius=radius, fill=BG_COLOR) + + pts = fsk_points(s) + thickness = s * 0.055 + poly = thick_curve_polygon(pts, thickness) + draw.polygon(poly, fill=WAVE_COLOR) + + # Round the endpoints with circles + for p in [pts[0], pts[-1]]: + r = thickness / 2 + draw.ellipse([p[0] - r, p[1] - r, p[0] + r, p[1] + r], fill=WAVE_COLOR) + + img = img.resize((size, size), Image.LANCZOS) + + if round_mask: + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).ellipse([0, 0, size - 1, size - 1], fill=255) + img.putalpha(mask) + + return img + + +def main(): + base = "app/src/main/res" + for density, size in SIZES.items(): + icon = generate_icon(size) + icon.save(f"{base}/mipmap-{density}/ic_launcher.png") + icon_round = generate_icon(size, round_mask=True) + icon_round.save(f"{base}/mipmap-{density}/ic_launcher_round.png") + print(f" mipmap-{density}: {size}x{size}") + + store = generate_icon(512) + store.save(f"{base}/mipmap-xxxhdpi/store_icon_512.png") + print(" store_icon_512.png (512x512)") + + +if __name__ == "__main__": + main() diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..760e307 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.enableJetifier=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..033e24c4cdf41af1ab109bc7f253b2b887023340 GIT binary patch literal 63375 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≫L+crb^Bz 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..13b05b5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "HartMobile" +include(":app") diff --git a/store/reestr_po/components.md b/store/reestr_po/components.md new file mode 100644 index 0000000..75b6b21 --- /dev/null +++ b/store/reestr_po/components.md @@ -0,0 +1,31 @@ +# Перечень используемых компонентов HART Mobile + +## Язык и платформа + +| Компонент | Версия | Лицензия | Правообладатель | +|---|---|---|---| +| Kotlin | 1.9.x | Apache 2.0 | JetBrains s.r.o. | +| Android SDK (compileSdk 34) | API 34 | Apache 2.0 | Google LLC | + +## Библиотеки (зависимости Gradle) + +| Компонент | Версия | Лицензия | Назначение | +|---|---|---|---| +| androidx.core:core-ktx | 1.12.0 | Apache 2.0 | Базовые расширения Android KTX | +| androidx.appcompat:appcompat | 1.6.1 | Apache 2.0 | Обратная совместимость UI | +| com.google.android.material:material | 1.11.0 | Apache 2.0 | Material Design компоненты | +| androidx.constraintlayout:constraintlayout | 2.1.4 | Apache 2.0 | Компоновка элементов интерфейса | +| androidx.lifecycle:lifecycle-viewmodel-ktx | 2.7.0 | Apache 2.0 | Архитектурный компонент ViewModel | +| androidx.lifecycle:lifecycle-livedata-ktx | 2.7.0 | Apache 2.0 | Реактивные данные LiveData | +| androidx.navigation:navigation-fragment-ktx | 2.7.6 | Apache 2.0 | Навигация между экранами | +| androidx.navigation:navigation-ui-ktx | 2.7.6 | Apache 2.0 | UI-компоненты навигации | +| org.jetbrains.kotlinx:kotlinx-coroutines-android | 1.7.3 | Apache 2.0 | Асинхронное выполнение | +| com.github.mik3y:usb-serial-for-android | 3.7.0 | LGPL 2.1 | Драйвер USB-Serial (CP210x) | + +## Примечания + +1. Все используемые библиотеки распространяются под свободными лицензиями (Apache 2.0 и LGPL 2.1), не ограничивающими коммерческое использование. +2. Библиотека `usb-serial-for-android` используется под лицензией LGPL 2.1 в виде динамически подключаемой зависимости, что соответствует условиям лицензии. +3. Весь оригинальный код приложения (протокол HART, парсеры, пользовательский интерфейс, логика лицензирования) написан автором самостоятельно. +4. Приложение не содержит проприетарных компонентов иностранного происхождения. +5. Все зависимости загружаются из публичных репозиториев Maven Central и JitPack. diff --git a/store/reestr_po/functional_description.md b/store/reestr_po/functional_description.md new file mode 100644 index 0000000..0875bac --- /dev/null +++ b/store/reestr_po/functional_description.md @@ -0,0 +1,103 @@ +# Описание функциональных характеристик + +## Наименование ПО +HART Mobile + +## Правообладатель +Капцов Александр Александрович + +## Класс ПО +Прикладное программное обеспечение промышленного назначения. Средство диагностики и конфигурирования промышленных полевых устройств. + +## Код ОКПД 2 +62.01.11 — Оригиналы программного обеспечения + +## Назначение +HART Mobile — мобильное приложение для операционной системы Android, предназначенное для работы с промышленными полевыми устройствами (датчиками давления, температуры, расхода, уровня и исполнительными механизмами) по протоколу HART (Highway Addressable Remote Transducer Protocol). + +Приложение обеспечивает чтение параметров, диагностику, мониторинг и управление HART-совместимыми устройствами через беспроводное соединение Bluetooth Classic (SPP) или проводное соединение USB (CP210x). + +## Область применения +- Пусконаладочные работы на промышленных объектах +- Плановое техническое обслуживание полевого оборудования +- Диагностика неисправностей датчиков и исполнительных механизмов +- Калибровка и проверка измерительных каналов +- Мониторинг технологических параметров в реальном времени + +Целевые отрасли: нефтегазовая, химическая, энергетическая промышленность, водоснабжение и водоотведение, пищевая промышленность. + +## Целевая аудитория +Инженеры КИПиА (контрольно-измерительных приборов и автоматики), наладчики, метрологи, специалисты по автоматизации технологических процессов. + +## Функциональные характеристики + +### 1. Подключение к полевым устройствам +- **Bluetooth Classic (SPP)** — беспроводное подключение через HART-модем (BriC или аналогичный). UUID: 00001101-0000-1000-8000-00805F9B34FB. +- **USB Type-C (CP210x)** — проводное подключение через USB-адаптер на чипе Silicon Labs CP210x. VID=0x10C4, PID=0xEA60, 1200 бод, 8N1. +- Автоматическое обнаружение сопряжённых Bluetooth-устройств. +- Автоматическое обнаружение USB-адаптера при подключении кабеля. + +### 2. Идентификация устройства +- Чтение уникальной идентификации (HART Command 0): производитель, тип устройства, серийный номер, ревизия аппаратная и программная. +- Чтение тэга, дескриптора и даты устройства (HART Command 13). +- Поддержка HART Protocol Revision 5, 6 и 7 (включая расширенный формат с 16-битными идентификаторами производителя). + +### 3. Мониторинг технологических параметров +- Чтение первичной переменной PV и единиц измерения (Command 1). +- Чтение тока петли 4-20 мА и процента диапазона (Command 2). +- Чтение всех динамических переменных PV, SV, TV, QV (Command 3). +- Автоматическое обновление показаний в реальном времени с настраиваемым интервалом. +- Справочник единиц измерения по спецификации HCF_SPEC-183. + +### 4. Тренд-графики +- Построение графика изменения любой переменной (PV, SV, TV, QV, ток, %) в реальном времени. +- Автоматическое масштабирование оси Y. +- Отображение текущего, минимального и максимального значений. +- Буфер до 300 точек (5 минут наблюдения). + +### 5. Сканирование переменных устройства +- Автоматическое обнаружение всех доступных переменных (HART Command 9, коды 0-50). +- Отображение кода, названия, единицы измерения и текущего значения каждой переменной. + +### 6. Управление токовой петлёй (Loop Test) +- Установка фиксированного тока в диапазоне 3.600-21.500 мА с точностью 0.001 мА (HART Command 40). +- 7 предустановленных значений для быстрой проверки: 3.6, 4.0, 8.0, 12.0, 16.0, 20.0, 21.5 мА. +- Ручной ввод произвольного значения. +- Безопасный выход из режима Loop Test. + +### 7. Поиск устройств на шине +- Сканирование всех 16 адресов HART-шины (0-15) для обнаружения подключённых устройств (Poll Scan). +- Отображение производителя, серийного номера и тэга найденных устройств. + +### 8. Система Device Description (DD) +- Импорт и просмотр файлов описания устройств в форматах DDL и SYM. +- Автоматическое определение формата файла. +- Иерархическое меню переменных и команд устройства. +- Пакетное чтение команд с отображением результатов. +- Встроенные DD-файлы для поддерживаемых устройств. + +### 9. Логирование +- Запись всех HART-фреймов (TX/RX) в файл для диагностики. +- Экспорт логов через стандартный механизм Android Share. + +### 10. Адаптивный интерфейс +- Поддержка смартфонов и планшетов (7" и более, sw600dp). +- Интерфейс полностью на русском языке. +- Крупные элементы управления для работы в полевых условиях. + +## Системные требования +- Операционная система: Android 6.0 (API 23) и выше +- Bluetooth 2.0+ с поддержкой профиля SPP (для беспроводного подключения) +- USB Type-C (для проводного подключения через CP210x) +- Оперативная память: от 2 ГБ +- Свободное место: от 10 МБ + +## Совместимость +Приложение работает с любыми устройствами, поддерживающими протокол HART версий 5, 6 и 7. Протестировано с оборудованием производителей: Rosemount (Emerson), Yokogawa, ЭЛЕМЕР, Метран, Spriano, Sierra Instruments. + +## Используемые технологии +- Язык программирования: Kotlin +- Архитектура: MVVM (Model-View-ViewModel) +- UI-фреймворк: Android Jetpack (Navigation Component, ViewModel, LiveData) +- Графический интерфейс: Material Design Components +- Асинхронная обработка: Kotlin Coroutines diff --git a/store/reestr_po/install_guide.md b/store/reestr_po/install_guide.md new file mode 100644 index 0000000..1dbdc03 --- /dev/null +++ b/store/reestr_po/install_guide.md @@ -0,0 +1,65 @@ +# Инструкция по установке HART Mobile + +## Сведения для экспертов реестра + +### Установка приложения + +1. Скачайте APK-файл: `hart_mobile_v1.0.1.apk` +2. Перенесите файл на Android-устройство (USB, облако, email) +3. Откройте файл на устройстве +4. При запросе разрешите установку из неизвестных источников +5. Нажмите «Установить» +6. Запустите приложение «HART Mobile» из меню приложений + +### Системные требования +- Android 6.0 (API 23) и выше +- Минимум 10 МБ свободного места +- Bluetooth 2.0+ (для беспроводного подключения) или USB Type-C (для проводного) + +### Тестовый доступ + +При первом запуске автоматически начинается **пробный период (5 дней)** с полным доступом ко всем функциям. Для тестирования дополнительная активация не требуется. + +Если пробный период истёк, используйте тестовый код активации: +- **Activation ID** вашего устройства отображается на экране активации +- Для получения тестового кода обратитесь: kaptsov.aa@gmail.com + +### Тестирование без оборудования + +Приложение предназначено для работы с физическим оборудованием (HART-датчики + Bluetooth-модем или USB-адаптер). Без оборудования можно проверить: + +1. **Экран подключения** — отображение списка Bluetooth-устройств и USB-адаптеров +2. **Интерфейс** — все экраны на русском языке +3. **DD-файлы** — импорт и просмотр файлов описания устройств (встроенный файл доступен) +4. **О программе** — информация о версии (ссылка внизу экрана подключения) + +Для полноценного тестирования необходимо: +- HART Bluetooth-модем (например, BriC BT HART) или USB-адаптер CP210x +- HART-совместимый датчик в токовой петле 4-20 мА + +### Сборка из исходного кода + +Репозиторий: https://git.sweateratops.ru/kaptsov/HartMobile + +```bash +# Требуется Android Studio или JDK 11+ +export JAVA_HOME="/path/to/jdk" + +# Debug-сборка +./gradlew :app:assembleDebug +# APK: app/build/outputs/apk/debug/app-debug.apk + +# Release-сборка (требуется keystore) +./gradlew :app:assembleRelease +# APK: app/build/outputs/apk/release/app-release.apk +``` + +Зависимости (загружаются автоматически через Gradle): +- androidx.navigation 2.7.6 +- com.google.android.material 1.11.0 +- com.github.mik3y:usb-serial-for-android 3.7.0 +- kotlinx-coroutines-android 1.7.3 + +### Контакт для вопросов по тестированию +Email: kaptsov.aa@gmail.com +Telegram: @HART_Mobile_bot diff --git a/store/reestr_po/lifecycle.md b/store/reestr_po/lifecycle.md new file mode 100644 index 0000000..2b69b5e --- /dev/null +++ b/store/reestr_po/lifecycle.md @@ -0,0 +1,52 @@ +# Жизненный цикл ПО HART Mobile + +## Правообладатель и разработчик +Капцов Александр Александрович + +## Текущая версия +- Версия: 1.0.1 +- Дата выпуска: 18 марта 2026 г. + +## Этапы жизненного цикла + +### 1. Разработка +- Разработка ведётся на территории Российской Федерации +- Язык программирования: Kotlin +- Среда разработки: Android Studio +- Система контроля версий: Git +- Репозиторий исходного кода размещён на территории РФ + +### 2. Тестирование +- Функциональное тестирование на реальном промышленном оборудовании (HART-датчики производства ЭЛЕМЕР, Rosemount, Yokogawa, Метран, Spriano, Sierra Instruments) +- Тестирование на устройствах: смартфоны и планшеты под управлением Android 6.0-16 +- Тестирование обоих способов подключения: Bluetooth SPP и USB CP210x + +### 3. Выпуск и распространение +- Канал распространения: RuStore (rustore.ru) +- Формат: APK, подписанный ключом разработчика +- Обновления выпускаются по мере добавления новых функций и исправления ошибок + +### 4. Техническая поддержка +- **Telegram-бот:** @HART_Mobile_bot — приём обращений, выдача лицензий +- **Email:** kaptsov.aa@gmail.com +- Время реагирования: в течение 24 часов в рабочие дни +- Поддержка осуществляется на русском языке +- Поддержка оказывается на территории Российской Федерации + +### 5. Сопровождение и обновление +- Исправление обнаруженных ошибок +- Добавление поддержки новых HART-устройств и команд +- Расширение базы DD-файлов (описаний устройств) +- Улучшение пользовательского интерфейса +- Обновления распространяются через RuStore + +### 6. Планируемое развитие +- Автоматическое переподключение при потере связи +- Экспорт данных мониторинга в формате CSV +- Расширенная поддержка HART-команд (2-байтовые номера >255) +- Запись параметров в устройство (HART Write Commands) + +## Гарантии непрерывности +- Исходный код хранится в системе контроля версий на территории РФ +- Резервные копии создаются регулярно +- Разработка и поддержка не зависят от иностранных сервисов и инфраструктуры diff --git a/store/reestr_po/user_manual.md b/store/reestr_po/user_manual.md new file mode 100644 index 0000000..7c7ee8c --- /dev/null +++ b/store/reestr_po/user_manual.md @@ -0,0 +1,170 @@ +# Руководство пользователя HART Mobile + +## 1. Общие сведения + +HART Mobile — мобильное приложение для Android, предназначенное для работы с промышленными HART-датчиками через Bluetooth или USB. + +### Что вам понадобится +- Android-смартфон или планшет (Android 6.0+) +- HART Bluetooth-модем (BriC или аналог) **или** USB-адаптер CP210x с кабелем Type-C +- HART-совместимое полевое устройство, подключённое к токовой петле 4-20 мА + +### Физическая схема подключения +``` +Смартфон <--Bluetooth--> BriC модем <--токовая петля 4-20 мА--> HART-датчик + или +Смартфон <--USB Type-C--> CP210x адаптер <--токовая петля--> HART-датчик +``` + +## 2. Установка + +1. Скачайте APK-файл HART Mobile. +2. Откройте файл на устройстве. +3. Если появится запрос «Установка из неизвестных источников» — разрешите для данного файла. +4. Нажмите «Установить». + +При первом запуске начинается пробный период (5 дней с полным доступом). + +## 3. Экран подключения + +После запуска открывается экран **«Подключение к HART-устройству»**. + +### Подключение по Bluetooth +1. Убедитесь, что BriC-модем сопряжён с вашим телефоном (через настройки Bluetooth Android). +2. В списке «Сопряжённые BT устройства» нажмите на имя модема (например, `BRIC_BT_HART_SN_25070088`). +3. Приложение подключится к модему и автоматически отправит команду идентификации (Command 0) к HART-устройству. +4. При успешном ответе откроется экран параметров устройства. + +### Подключение по USB +1. Подключите CP210x-адаптер к телефону через кабель USB Type-C. +2. При обнаружении адаптера появится секция «Подключение по USB (Type-C)». +3. Нажмите **«Подключиться по USB»**. +4. Android может запросить разрешение на доступ к USB-устройству — нажмите «ОК». + +### Обновление списка +Нажмите **«Обновить»** для повторного поиска Bluetooth-устройств. + +## 4. Экран параметров устройства + +Главный экран после подключения. Отображает: + +### Блок «Устройство» +- **Тэг (Tag)** — идентификатор устройства на объекте +- **Дескриптор** — описание устройства +- **Дата** — дата последней конфигурации +- **Производитель** — название и код производителя +- **Device ID** — серийный номер и тип устройства +- **HART ревизия** — версия протокола HART +- **Dev ревизия** — версия прошивки устройства + +### Блок «Переменные» +- **PV** (первичная переменная) — основное измеряемое значение (температура, давление и т.д.) +- **SV** (вторичная), **TV** (третичная), **QV** (четвертичная) — дополнительные переменные +- **Ток петли** — ток в контуре 4-20 мА +- **% диапазона** — процент от настроенного диапазона + +Нажатие на любое значение переменной открывает **тренд-график**. + +### Кнопки действий +- **Прочитать переменные** — однократное чтение всех переменных +- **Автообновление (2 с)** — непрерывное обновление с интервалом 2 секунды +- **Loop Test** — переход к управлению токовой петлёй +- **Поиск (Poll)** — поиск устройств на шине +- **Переменные устройства (Cmd 9)** — расширенное сканирование переменных +- **DD файл (меню производителя)** — просмотр описания устройства + +### Меню (три точки справа вверху) +- **Адрес HART** — просмотр и изменение опросного адреса (0-15) +- **Помощь** — справочная информация + +## 5. Тренд-график + +Нажмите на значение любой переменной на экране параметров. + +- График обновляется каждую секунду. +- Ось Y масштабируется автоматически. +- Вверху отображается текущее значение крупным шрифтом. +- Внизу — минимальное и максимальное значения за период наблюдения. +- **Очистить** — сбросить график и начать заново. +- Кнопка «Назад» — вернуться к экрану параметров. + +## 6. Loop Test (управление токовой петлёй) + +Позволяет задать фиксированный ток в петле 4-20 мА для проверки вторичных приборов. + +### Быстрый выбор +Нажмите одну из кнопок: **3.6**, **4.0**, **8.0**, **12.0**, **16.0**, **20.0**, **21.5 мА**. + +### Ручной ввод +Введите значение в поле «мА» и нажмите **«Задать»**. +Диапазон: 3.600 — 21.500 мА. Точность: 0.001 мА. + +### Выход +Нажмите **«СТОП — Выйти из Loop Test»**. Устройство вернётся к нормальному режиму управления. + +**Внимание:** во время Loop Test устройство не отслеживает реальный технологический параметр. Используйте с осторожностью на действующем оборудовании. + +## 7. Поиск устройств (Poll Scan) + +Сканирует адреса 0-15 на HART-шине. + +1. Нажмите **«Начать поиск»**. +2. Приложение последовательно опрашивает каждый адрес. +3. Найденные устройства отображаются с указанием адреса, производителя, серийного номера и тэга. +4. Нажмите на устройство для подключения к нему. + +## 8. Переменные устройства (Command 9) + +Расширенное сканирование переменных по кодам 0-50. + +1. Нажмите **«Сканировать»** — приложение перебирает коды переменных пакетами по 4. +2. Найденные переменные отображаются в списке с кодом, названием, единицей измерения и значением. +3. **«Обновить»** — повторное чтение уже найденных переменных (без пересканирования). + +## 9. DD файл (меню производителя) + +Device Description — файл от производителя с описанием всех переменных, команд и меню устройства. + +- При подключении к устройству приложение автоматически ищет подходящий DD-файл. +- Если файл найден, открывается иерархическое меню. +- При входе в подменю с командами — автоматическое чтение всех доступных команд. +- Результаты отображаются под каждым элементом меню. + +### Импорт DD-файла +Поддерживаемые форматы: `.ddl`, `.dd` (текстовый DDL), `.sym` (таблица символов). +Бинарные форматы `.fm8` / `.fm6` не поддерживаются. + +## 10. Логирование + +На экране параметров нажмите **«Отправить логи»** — откроется стандартное меню Android для отправки файла лога (email, мессенджер, облако). + +Лог содержит все HART-фреймы (отправленные и полученные) с временными метками. + +## 11. Активация лицензии + +После окончания пробного периода (5 дней) откроется экран активации. + +1. Скопируйте **Activation ID** (формат `XXXX-XXXX`), отображённый на экране. +2. Нажмите **«Купить код в Telegram»** — откроется бот @HART_Mobile_bot. +3. Отправьте Activation ID боту, выберите тариф, оплатите картой. +4. Бот автоматически пришлёт код активации. +5. Введите полученный код в поле на экране активации и нажмите **«Активировать»**. + +Тарифы: +- 1 год — 1 000 руб. +- Навсегда — 5 000 руб. + +## 12. Устранение неполадок + +| Проблема | Решение | +|---|---| +| Bluetooth-устройство не в списке | Сначала выполните сопряжение в настройках Android | +| «HART не отвечает — проверьте модем» | Проверьте подключение модема к токовой петле и наличие питания 4-20 мА | +| Потеря связи через 15 секунд | BT-модем разрывает соединение при бездействии — включите автообновление | +| USB не обнаруживается | Убедитесь что адаптер на чипе CP210x, попробуйте другой кабель | +| NaN вместо значения | Устройство не поддерживает данную переменную | + +## 13. Контакты поддержки + +- Telegram: @HART_Mobile_bot +- Разработчик: Капцов Александр Александрович diff --git a/tools/process_screenshots.py b/tools/process_screenshots.py new file mode 100644 index 0000000..bce12b5 --- /dev/null +++ b/tools/process_screenshots.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Обработка скриншотов для RuStore. +- Масштабирование до 1080x1920 +- Добавление подписи сверху на синей полосе +- Вывод в PNG +""" + +from PIL import Image, ImageDraw, ImageFont +import os + +INPUT_DIR = os.path.join(os.path.dirname(__file__), '..', 'temp_screenshots') +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), '..', 'store_screenshots') + +TARGET_W = 1080 +TARGET_H = 1920 +HEADER_H = 160 # высота полосы с подписью +HEADER_COLOR = (55, 71, 161) # синий как в приложении (#3747A1) +TEXT_COLOR = (255, 255, 255) + +# Маппинг файлов → подписи (порядок по времени) +SCREENSHOTS = [ + ("photo_2026-03-17 23.08.50.jpeg", "Параметры устройства", "01_device.png"), + ("photo_2026-03-17 23.08.49.jpeg", "Тренд температуры", "02_trend.png"), + ("photo_2026-03-17 23.08.48.jpeg", "Loop Test", "03_loop_test.png"), + ("photo_2026-03-17 23.08.47.jpeg", "Поиск устройств", "04_poll_scan.png"), + ("photo_2026-03-17 23.08.45.jpeg", "Подключение", "05_scan.png"), +] + + +def find_font(size): + """Ищем подходящий шрифт.""" + font_paths = [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/SFNSDisplay.ttf", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial Bold.ttf", + ] + for p in font_paths: + if os.path.exists(p): + try: + return ImageFont.truetype(p, size) + except Exception: + continue + return ImageFont.load_default() + + +def process_screenshot(input_path, title, output_path): + img = Image.open(input_path).convert("RGB") + + # Область для скриншота: TARGET_W x (TARGET_H - HEADER_H) + content_h = TARGET_H - HEADER_H + + # Масштабируем скриншот, сохраняя пропорции, заполняя ширину + scale = TARGET_W / img.width + new_h = int(img.height * scale) + + img_scaled = img.resize((TARGET_W, new_h), Image.LANCZOS) + + # Обрезаем или центрируем по высоте + if new_h > content_h: + # Обрезаем снизу (статус-бар сверху важнее) + img_scaled = img_scaled.crop((0, 0, TARGET_W, content_h)) + elif new_h < content_h: + # Центрируем на белом фоне + bg = Image.new("RGB", (TARGET_W, content_h), (255, 255, 255)) + offset_y = (content_h - new_h) // 2 + bg.paste(img_scaled, (0, offset_y)) + img_scaled = bg + + # Создаём итоговое изображение + result = Image.new("RGB", (TARGET_W, TARGET_H), (255, 255, 255)) + + # Рисуем шапку + draw = ImageDraw.Draw(result) + draw.rectangle([(0, 0), (TARGET_W, HEADER_H)], fill=HEADER_COLOR) + + # Текст по центру шапки + font = find_font(48) + bbox = draw.textbbox((0, 0), title, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + tx = (TARGET_W - tw) // 2 + ty = (HEADER_H - th) // 2 + draw.text((tx, ty), title, fill=TEXT_COLOR, font=font) + + # Вставляем скриншот под шапку + result.paste(img_scaled, (0, HEADER_H)) + + result.save(output_path, "PNG", optimize=True) + print(f" OK: {output_path} ({TARGET_W}x{TARGET_H})") + + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + print(f"RuStore screenshots → {OUTPUT_DIR}\n") + + for filename, title, out_name in SCREENSHOTS: + input_path = os.path.join(INPUT_DIR, filename) + output_path = os.path.join(OUTPUT_DIR, out_name) + if not os.path.exists(input_path): + print(f" SKIP: {filename} not found") + continue + process_screenshot(input_path, title, output_path) + + print(f"\nГотово! {len(SCREENSHOTS)} скриншотов обработано.") + + +if __name__ == "__main__": + main()