commit 39662d323a4853c302da59f3954331e5dc758d03 Author: alexanderkaptsov Date: Wed Mar 18 23:23:18 2026 +0900 HART Mobile v1.0.1 — initial clean commit Android app for HART protocol field devices (Bluetooth SPP / USB CP210x). Kotlin, MVVM, Jetpack Navigation, Material Design. Co-Authored-By: Claude Opus 4.6 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 0000000..68deaf2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c25b3c9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ 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 0000000..a2f3a59 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ed1729c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..b142ed3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 0000000..05901c7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ 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 0000000..e36a77b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..aa3ae99 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..01c1961 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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 0000000..15b94a6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ 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 0000000..15c31dd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/store_icon_512.png differ 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 0000000..033e24c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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()