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 <noreply@anthropic.com>
This commit is contained in:
alexanderkaptsov 2026-03-18 23:23:18 +09:00
commit 39662d323a
77 changed files with 8347 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -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

61
app/build.gradle.kts Normal file
View File

@ -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")
}

2
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,2 @@
-keep class ru.kaptsov.hartmobile.** { *; }
-keep class com.hoho.android.usbserial.** { *; }

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Bluetooth Classic (SPP) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- USB -->
<uses-permission android:name="android.hardware.usb.host"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HartMobile">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Автозапуск при подключении USB устройства BriC -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter"/>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

View File

@ -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)

View File

@ -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<String>()
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())
}
}

View File

@ -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<Byte>()
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) }

View File

@ -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()
}

View File

@ -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<Byte>()
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) }

View File

@ -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<File> =
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._\\-]"), "_")
}

View File

@ -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<String> = 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<DdMenuItem>
)
/** Весь распарсенный DD-документ */
data class DdDocument(
val fileName: String,
val deviceInfo: DdDeviceInfo,
val menus: Map<String, DdMenu>,
val variables: Map<String, DdVariable>,
val commands: Map<Int, DdCommand>,
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 }
}
}

View File

@ -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<String> {
val tokens = ArrayDeque<String>()
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<String>, fileName: String): DdDocument {
var deviceInfo = DdDeviceInfo()
val menus = mutableMapOf<String, DdMenu>()
val variables = mutableMapOf<String, DdVariable>()
val commands = mutableMapOf<Int, DdCommand>()
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<String>): 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<String>): 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<String>): DdCommand {
val numStr = tokens.removeFirstOrNull() ?: "0"
val num = numStr.toIntOrNull() ?: 0
expectBrace(tokens, "{")
var label = ""
val responseVars = mutableListOf<String>()
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<String>): DdMenu {
val name = tokens.removeFirstOrNull() ?: "menu"
expectBrace(tokens, "{")
var label = ""
val items = mutableListOf<DdMenuItem>()
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<String>, expected: String) {
while (tokens.isNotEmpty() && tokens.first() != expected) tokens.removeFirst()
if (tokens.firstOrNull() == expected) tokens.removeFirst()
}
private fun skipToSemicolonOrBlock(tokens: ArrayDeque<String>) {
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<String>): 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>): 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
}
}

View File

@ -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<String, DdVariable>()
val commands = mutableMapOf<Int, DdCommand>()
val menus = mutableMapOf<String, DdMenu>()
val methods = mutableListOf<DdMethod>()
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<DdMenuItem>()
// Подменю: Устройство-специфичные команды
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()
}
}

View File

@ -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) }
}
}

View File

@ -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
}

View File

@ -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<DeviceVariable>) {
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<File> {
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) }
}

View File

@ -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-запрос с коротким адресом (015).
* @param command номер команды (0255)
* @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 номер команды (0255)
* @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)
}
}

View File

@ -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<Int, Float>? {
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<Float, Float>? {
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<DeviceVariable> {
if (data.isEmpty()) return emptyList()
val result = mutableListOf<DeviceVariable>()
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 "Ток петли"
)
}

View File

@ -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<DdMenu> = 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<DdMenuItem.Cmd>().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<DdMenuAdapter.VH>() {
private var items: List<DdMenuItem> = emptyList()
private var doc: DdDocument? = null
private var results: Map<Int, String> = emptyMap()
fun submitList(list: List<DdMenuItem>, document: DdDocument) {
items = list; doc = document; notifyDataSetChanged()
}
fun updateResults(newResults: Map<Int, String>) {
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<Int, String>) {
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<DdFilesAdapter.VH>() {
private var items: List<String> = emptyList()
fun submitList(list: List<String>) { 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) }
}
}
}

View File

@ -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 = обычный, 115 = multi-drop)
ПЕРЕМЕННЫЕ УСТРОЙСТВА
Кнопка «Переменные устройства (Cmd 9)» запускает автоматическое сканирование кодов 050 через HART Команду 9. Это позволяет обнаружить все специфичные переменные производителя (например, для ELEMER РЭМ: объёмный расход, тотализаторы, температура и др.) без ручного задания кодов.
LOOP TEST
Управление токовой петлей 420 мА (HART Команда 40). Выбор из предустановок или ручной ввод. Обязательно нажмите «Выйти из Loop Test» по завершении!
ПОИСК УСТРОЙСТВ
Перебирает HART адреса 015 и находит все устройства на шине (Команда 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 = "015"
}
android.app.AlertDialog.Builder(requireContext())
.setTitle("HART опросный адрес")
.setMessage("Введите адрес устройства (0 = по умолчанию, 115 = 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
}
}

View File

@ -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
?: "Нажмите «Сканировать» для поиска переменных (коды 050)"
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<DeviceVariable>() {
override fun areItemsTheSame(a: DeviceVariable, b: DeviceVariable) = a.code == b.code
override fun areContentsTheSame(a: DeviceVariable, b: DeviceVariable) = a == b
}
class VariablesAdapter : ListAdapter<DeviceVariable, VariablesAdapter.VH>(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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<PollResultAdapter.VH>() {
private var items: List<PollScanResult> = emptyList()
fun submitList(list: List<PollScanResult>) {
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}"
}
}
}

View File

@ -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<BtDeviceAdapter.VH>() {
private var items: List<BluetoothDevice> = emptyList()
fun submitList(list: List<BluetoothDevice>) {
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
)
)
}
}
}

View File

@ -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<Float?, Int?> {
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
}
}

View File

@ -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<Point>()
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)
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke android:width="2dp" android:color="#1565C0"/>
<corners android:radius="8dp"/>
<solid android:color="#E3F0FF"/>
</shape>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:tag="nav_host"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"/>
</LinearLayout>

View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- ===== ПАНЕЛЬ: СПИСОК ФАЙЛОВ ===== -->
<LinearLayout
android:id="@+id/layoutFiles"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="DD файлы на устройстве"
android:textStyle="bold"
android:textSize="16sp"
android:paddingBottom="4dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Импортируйте DD файл с памяти телефона. Текстовые файлы (.sym, .dd) будут разобраны и отображена структура меню. Бинарные файлы (.fm8, .fm6) будут сохранены, но не читаются."
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:paddingBottom="12dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnImportDd"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="Импортировать DD файл..."/>
<TextView
android:id="@+id/tvNoFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Нет сохранённых DD файлов.\nНажмите «Импортировать» чтобы загрузить файл с памяти телефона."
android:textColor="@android:color/darker_gray"
android:paddingTop="16dp"
android:gravity="center"
android:visibility="gone"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerFiles"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
<!-- ===== ПАНЕЛЬ: ДЕРЕВО МЕНЮ ===== -->
<LinearLayout
android:id="@+id/layoutMenu"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<!-- Заголовок меню -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="#E3F2FD">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" Назад"
android:visibility="gone"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="4dp">
<TextView
android:id="@+id/tvMenuTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/tvDocInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@android:color/darker_gray"/>
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerMenu"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,196 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingVertical="16dp">
<TextView
android:id="@+id/tvConnectionStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Подключено"
android:textColor="#388E3C"
android:textStyle="bold"
android:textSize="14sp"
android:paddingBottom="12dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="УСТРОЙСТВО"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:letterSpacing="0.1"/>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Тэг (Tag):"/>
<TextView android:id="@+id/tvTagName" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Дескриптор:"/>
<TextView android:id="@+id/tvDescriptor" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Дата:"/>
<TextView android:id="@+id/tvDate" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Производитель:"/>
<TextView android:id="@+id/tvManufacturer" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Device ID:"/>
<TextView android:id="@+id/tvDeviceId" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="HART ревизия:"/>
<TextView android:id="@+id/tvHartRevision" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Dev ревизия:"/>
<TextView android:id="@+id/tvDeviceRevision" style="@style/InfoValue" android:text="—"/>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp"
android:background="#E0E0E0" android:layout_marginVertical="12dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ПЕРЕМЕННЫЕ"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:letterSpacing="0.1"/>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="PV (первичная):"/>
<TextView android:id="@+id/tvPv" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="SV (вторичная):"/>
<TextView android:id="@+id/tvSv" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="TV (третичная):"/>
<TextView android:id="@+id/tvTv" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="QV (четвертичная):"/>
<TextView android:id="@+id/tvQv" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="Ток петли:"/>
<TextView android:id="@+id/tvLoopCurrent" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<LinearLayout style="@style/InfoRow">
<TextView style="@style/InfoLabel" android:text="% диапазона:"/>
<TextView android:id="@+id/tvPercent" style="@style/InfoValueBig" android:text="—"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp"
android:background="#E0E0E0" android:layout_marginVertical="12dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnReadOnce"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Прочитать переменные"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTogglePolling"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Автообновление (2 с)"/>
<!-- Ряд: Loop Test + Поиск -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLoopTest"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Loop Test"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPollScan"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Поиск (Poll)"/>
</LinearLayout>
<!-- Расширенные переменные устройства (Command 9) -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeviceVariables"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Переменные устройства (Cmd 9)"/>
<!-- DD файл производителя -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDdMenu"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="DD файл (меню производителя)"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShareLog"
style="@style/HartButton.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Отправить логи"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDisconnect"
style="@style/HartButton.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Отключиться"
android:textColor="#D32F2F"/>
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingVertical="16dp">
<!-- Статус сканирования -->
<TextView
android:id="@+id/tvScanStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingBottom="8dp"/>
<!-- Индикатор прогресса -->
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"/>
<!-- Кнопки управления -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScan"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Сканировать"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRefresh"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Обновить"
android:enabled="false"/>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#E0E0E0"
android:layout_marginBottom="8dp"/>
<!-- Список переменных -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvVariables"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>

View File

@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F8FF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingVertical="32dp"
android:gravity="center_horizontal">
<!-- Иконка приложения -->
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@mipmap/ic_launcher"
android:layout_marginBottom="12dp"
android:contentDescription="HART Mobile"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HART Mobile"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="#0D47A1"
android:layout_marginBottom="4dp"/>
<!-- Статус: меняется из кода (expired / trial days / year expires) -->
<TextView
android:id="@+id/tvLicenseStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Пробный период завершён"
android:textSize="15sp"
android:textColor="#D32F2F"
android:gravity="center"
android:paddingVertical="8dp"/>
<!-- ===== ID УСТРОЙСТВА ===== -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ваш ID устройства для покупки кода:"
android:textSize="13sp"
android:textColor="#555555"
android:gravity="center"
android:layout_marginTop="16dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="6dp"
android:layout_marginBottom="4dp">
<TextView
android:id="@+id/tvActivationId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="A3F7-B2C1"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#1565C0"
android:fontFamily="monospace"
android:background="@drawable/bg_activation_id"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCopyId"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Копировать"
android:textSize="12sp"/>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Скопируйте ID и отправьте его в Telegram для получения кода активации"
android:textSize="12sp"
android:textColor="#888888"
android:gravity="center"
android:layout_marginBottom="20dp"/>
<!-- ===== КНОПКА ТЕЛЕГРАМ ===== -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTelegram"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Купить код в Telegram"
android:layout_marginBottom="8dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1 год — 1 000 ₽"
android:textSize="13sp"
android:textColor="#555555"
android:paddingEnd="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Навсегда — 5 000 ₽"
android:textSize="13sp"
android:textColor="#555555"/>
</LinearLayout>
<!-- Разделитель -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<View android:layout_width="0dp" android:layout_height="1dp"
android:layout_weight="1" android:background="#CCCCCC"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" или введите код "
android:textColor="#999999" android:textSize="12sp"/>
<View android:layout_width="0dp" android:layout_height="1dp"
android:layout_weight="1" android:background="#CCCCCC"/>
</LinearLayout>
<!-- ===== ВВОД КОДА ===== -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Код активации (XXXX-XXXX-XXXX-XXXX)"
android:layout_marginBottom="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapCharacters"
android:fontFamily="monospace"
android:maxLength="24"
android:textSize="16sp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnActivate"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Активировать"
android:layout_marginBottom="16dp"/>
<!-- Кнопка "Продолжить пробный" — видна только во время триала -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnContinueTrial"
style="@style/HartButton.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Продолжить пробный период (осталось X дней)"
android:visibility="gone"/>
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingVertical="16dp">
<TextView
android:id="@+id/tvActiveCurrent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Loop Test не активен"
android:textSize="18sp"
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"
android:background="#F5F5F5"
android:layout_marginBottom="16dp"/>
<TextView
android:id="@+id/tvMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="#555555"
android:gravity="center"
android:paddingBottom="12dp"
android:visibility="gone"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="БЫСТРЫЙ ВЫБОР"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:letterSpacing="0.1"
android:paddingBottom="8dp"/>
<!-- Ряд 1: 3.6, 4, 8 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn3p6"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="3.6 мА"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn4"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="4.0 мА"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn8"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="8.0 мА"/>
</LinearLayout>
<!-- Ряд 2: 12, 16, 20 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn12"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="12.0 мА"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn16"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="16.0 мА"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn20"
style="@style/HartButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="20.0 мА"/>
</LinearLayout>
<!-- 21.5 — на всю ширину, т.к. текст длиннее -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn21p5"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="21.5 мА (сигнал неисправности)"/>
<View android:layout_width="match_parent" android:layout_height="1dp"
android:background="#E0E0E0" android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="РУЧНОЙ ВВОД ТОКА"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:letterSpacing="0.1"
android:paddingBottom="8dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="мА (например 12.345)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etManual"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:maxLength="8"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSetManual"
style="@style/HartButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Задать"/>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Диапазон: 3.600 21.500 мА | Точность: 0.001 мА"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="24dp"/>
<View android:layout_width="match_parent" android:layout_height="1dp"
android:background="#E0E0E0" android:layout_marginBottom="16dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnExitLoop"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="СТОП — Выйти из Loop Test"
android:backgroundTint="#D32F2F"
android:enabled="false"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Устройство вернётся к нормальному управлению"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:gravity="center"
android:paddingTop="4dp"/>
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingVertical="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Поиск HART-устройств по адресам 015"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:paddingBottom="12dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStartScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Начать поиск"
android:layout_marginBottom="12dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="15"
android:layout_marginEnd="8dp"
android:visibility="gone"/>
<TextView
android:id="@+id/tvProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:visibility="gone"/>
</LinearLayout>
<TextView
android:id="@+id/tvResultCount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:paddingBottom="8dp"/>
<TextView
android:id="@+id/tvNoResults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Устройства не найдены.\nУбедитесь что устройство подключено и включено."
android:textColor="@android:color/darker_gray"
android:visibility="gone"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerResults"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Нажмите на найденное устройство для подключения"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:gravity="center"
android:paddingTop="8dp"/>
</LinearLayout>

View File

@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Баннер пробного периода — full-width, без боковых отступов -->
<LinearLayout
android:id="@+id/trialBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:background="#FFF8E1"
android:visibility="gone">
<TextView
android:id="@+id/tvTrialStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="13sp"
android:textColor="#E65100"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnActivateFromBanner"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Активировать"
android:textSize="12sp"
android:textColor="#1565C0"/>
</LinearLayout>
<!-- Основной контент с горизонтальными отступами -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingTop="16dp"
android:paddingBottom="8dp">
<!-- USB секция -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Подключение по USB (Type-C)"
android:textStyle="bold"
android:textSize="16sp"
android:paddingBottom="8dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Silicon Labs CP210x · BriC USB-HART"
android:textColor="@android:color/darker_gray"
android:textSize="13sp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConnectUsb"
style="@style/HartButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="Подключиться по USB"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray"
android:layout_marginBottom="16dp"/>
<!-- Bluetooth секция -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Сопряжённые BT устройства"
android:textStyle="bold"
android:textSize="16sp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRefreshBt"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Обновить"/>
</LinearLayout>
<TextView
android:id="@+id/tvNoBtDevices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Нет сопряжённых устройств.\nСначала сопрягите BriC_BT_HART в настройках Bluetooth телефона."
android:textColor="@android:color/darker_gray"
android:paddingTop="8dp"
android:visibility="gone"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerBt"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"/>
<!-- Статус и прогресс -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:visibility="gone"/>
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#D32F2F"
android:paddingTop="4dp"/>
<!-- О программе — скромная кнопка внизу -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAbout"
style="@style/HartButton.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="О программе"
android:textColor="@android:color/darker_gray"
android:textSize="12sp"/>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="@dimen/content_padding_h"
android:paddingTop="12dp">
<TextView
android:id="@+id/tvTrendTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Тренд"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1B5E20"
android:paddingBottom="4dp"/>
<TextView
android:id="@+id/tvCurrentValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="—"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="#1565C0"
android:fontFamily="monospace"
android:paddingBottom="8dp"/>
<ru.kaptsov.hartmobile.ui.TrendView
android:id="@+id/trendView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#FAFAFA"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tvMinMax"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Min: — / Max: —"
android:textSize="13sp"
android:textColor="#757575"
android:fontFamily="monospace"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearTrend"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Очистить"/>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tvDeviceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/tvDeviceAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"/>
</LinearLayout>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp">
<TextView
android:id="@+id/tvFileName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:drawablePadding="8dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOpen"
style="@style/HartButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Открыть"
android:layout_marginEnd="4dp"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
style="@style/HartButton.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Удалить"
android:textColor="#D32F2F"/>
</LinearLayout>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp"
android:background="?attr/selectableItemBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="15sp"/>
<TextView
android:id="@+id/tvType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:paddingStart="8dp"/>
</LinearLayout>
<TextView
android:id="@+id/tvHelp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:paddingTop="2dp"
android:visibility="gone"/>
<TextView
android:id="@+id/tvResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="#1B5E20"
android:paddingTop="4dp"
android:fontFamily="monospace"
android:visibility="gone"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#F0F0F0"
android:layout_marginTop="8dp"/>
</LinearLayout>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvVarLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/tvVarClass"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"/>
</LinearLayout>
<TextView
android:id="@+id/tvVarValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textAlignment="textEnd"/>
</LinearLayout>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:background="?attr/selectableItemBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="#1565C0"
android:textStyle="bold"
android:layout_marginEnd="12dp"
android:background="#E3F2FD"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"/>
<TextView
android:id="@+id/tvTag"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="15sp"
android:textStyle="bold"/>
</LinearLayout>
<TextView
android:id="@+id/tvManufacturer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:paddingTop="2dp"/>
</LinearLayout>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_address"
android:title="Адрес HART"
app:showAsAction="never"/>
<item
android:id="@+id/action_write_tag"
android:title="Записать тэг/дескриптор"
app:showAsAction="never"/>
<item
android:id="@+id/action_diagnostics"
android:title="Диагностика"
app:showAsAction="never"/>
<item
android:id="@+id/action_help"
android:title="Помощь"
app:showAsAction="never"/>
</menu>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/scanFragment">
<fragment
android:id="@+id/scanFragment"
android:name="ru.kaptsov.hartmobile.ui.ScanFragment"
android:label="@string/title_scan">
<action
android:id="@+id/action_scan_to_device"
app:destination="@id/deviceFragment"
app:popUpTo="@id/scanFragment"/>
<action
android:id="@+id/action_scan_to_license"
app:destination="@id/licenseFragment"/>
</fragment>
<fragment
android:id="@+id/licenseFragment"
android:name="ru.kaptsov.hartmobile.ui.LicenseFragment"
android:label="Активация"/>
<fragment
android:id="@+id/deviceFragment"
android:name="ru.kaptsov.hartmobile.ui.DeviceFragment"
android:label="@string/title_device">
<action
android:id="@+id/action_device_to_ddMenu"
app:destination="@id/ddMenuFragment"/>
<action
android:id="@+id/action_device_to_loopTest"
app:destination="@id/loopTestFragment"/>
<action
android:id="@+id/action_device_to_pollScan"
app:destination="@id/pollScanFragment"/>
<action
android:id="@+id/action_device_to_deviceVariables"
app:destination="@id/deviceVariablesFragment"/>
<action
android:id="@+id/action_device_to_trend"
app:destination="@id/trendFragment"/>
</fragment>
<fragment
android:id="@+id/ddMenuFragment"
android:name="ru.kaptsov.hartmobile.ui.DdMenuFragment"
android:label="@string/title_dd_menu"/>
<fragment
android:id="@+id/loopTestFragment"
android:name="ru.kaptsov.hartmobile.ui.LoopTestFragment"
android:label="@string/title_loop_test"/>
<fragment
android:id="@+id/pollScanFragment"
android:name="ru.kaptsov.hartmobile.ui.PollScanFragment"
android:label="@string/title_poll_scan">
<action
android:id="@+id/action_pollScan_to_device"
app:destination="@id/deviceFragment"
app:popUpTo="@id/deviceFragment"
app:popUpToInclusive="true"/>
</fragment>
<fragment
android:id="@+id/deviceVariablesFragment"
android:name="ru.kaptsov.hartmobile.ui.DeviceVariablesFragment"
android:label="@string/title_device_variables"/>
<fragment
android:id="@+id/trendFragment"
android:name="ru.kaptsov.hartmobile.ui.TrendFragment"
android:label="@string/title_trend">
<argument
android:name="trendVar"
app:argType="string"
android:defaultValue="pv"/>
</fragment>
</navigation>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Планшет 7"+: бо́льшие горизонтальные отступы, контент по центру -->
<dimen name="content_padding_h">56dp</dimen>
<dimen name="content_max_width">640dp</dimen>
</resources>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Стили для планшетов (sw600dp = наименьший размер ≥600dp, т.е. 7"+ планшет) -->
<resources>
<style name="InfoLabel">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_weight">1</item>
<item name="android:textColor">@android:color/darker_gray</item>
<item name="android:textSize">15sp</item>
</style>
<style name="InfoValue">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_weight">1</item>
<item name="android:textSize">16sp</item>
<item name="android:gravity">end</item>
</style>
<style name="InfoValueBig" parent="InfoValue">
<item name="android:textSize">20sp</item>
<item name="android:textStyle">bold</item>
<item name="android:textColor">#1565C0</item>
</style>
<style name="HartButton" parent="Widget.MaterialComponents.Button">
<item name="android:textSize">16sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">56dp</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">16dp</item>
</style>
<style name="HartButton.Outlined" parent="Widget.MaterialComponents.Button.OutlinedButton">
<item name="android:textSize">16sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">56dp</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">16dp</item>
</style>
<style name="HartButton.Text" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:textSize">16sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">56dp</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Горизонтальные отступы контента (16dp на телефоне, 56dp на планшете) -->
<dimen name="content_padding_h">16dp</dimen>
<!-- Максимальная ширина контента (чтобы на широких экранах не растягивался) -->
<dimen name="content_max_width">640dp</dimen>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">HART Mobile</string>
<string name="title_scan">Подключение к HART-устройству</string>
<string name="title_device">Параметры устройства</string>
<string name="title_loop_test">Loop Test</string>
<string name="title_poll_scan">Поиск устройств</string>
<string name="title_dd_menu">DD файл производителя</string>
<string name="title_device_variables">Переменные устройства</string>
<string name="title_trend">Тренд</string>
</resources>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HartMobile" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">#1565C0</item>
<item name="colorPrimaryVariant">#0D47A1</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="colorSecondary">#0288D1</item>
<item name="colorOnSecondary">#FFFFFF</item>
</style>
<!-- Единый стиль для ВСЕХ кнопок: 14sp, не caps, минимальная высота 48dp -->
<style name="HartButton" parent="Widget.MaterialComponents.Button">
<item name="android:textSize">14sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">48dp</item>
<item name="android:paddingStart">12dp</item>
<item name="android:paddingEnd">12dp</item>
</style>
<style name="HartButton.Outlined" parent="Widget.MaterialComponents.Button.OutlinedButton">
<item name="android:textSize">14sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">48dp</item>
<item name="android:paddingStart">12dp</item>
<item name="android:paddingEnd">12dp</item>
</style>
<style name="HartButton.Text" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:textSize">14sp</item>
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">48dp</item>
</style>
<style name="InfoRow">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:orientation">horizontal</item>
<item name="android:paddingVertical">4dp</item>
<item name="android:gravity">center_vertical</item>
</style>
<style name="InfoLabel">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_weight">1</item>
<item name="android:textColor">@android:color/darker_gray</item>
<item name="android:textSize">13sp</item>
</style>
<style name="InfoValue">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_weight">1</item>
<item name="android:textSize">14sp</item>
<item name="android:gravity">end</item>
</style>
<style name="InfoValueBig" parent="InfoValue">
<item name="android:textSize">16sp</item>
<item name="android:textStyle">bold</item>
<item name="android:textColor">#1565C0</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- BriC USB-HART: Silicon Labs CP210x, VID=0x10C4, PID=0xEA60 -->
<resources>
<usb-device vendor-id="4292" product-id="60000"/>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="logs" path="." />
</paths>

4
build.gradle.kts Normal file
View File

@ -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
}

116
gen_icon.py Normal file
View File

@ -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()

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

248
gradlew vendored Executable file
View File

@ -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" "$@"

92
gradlew.bat vendored Normal file
View File

@ -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

18
settings.gradle.kts Normal file
View File

@ -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")

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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)
## Гарантии непрерывности
- Исходный код хранится в системе контроля версий на территории РФ
- Резервные копии создаются регулярно
- Разработка и поддержка не зависят от иностранных сервисов и инфраструктуры

View File

@ -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
- Разработчик: Капцов Александр Александрович

View File

@ -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()