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>
26
.gitignore
vendored
Normal 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
@ -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
@ -0,0 +1,2 @@
|
||||
-keep class ru.kaptsov.hartmobile.** { *; }
|
||||
-keep class com.hoho.android.usbserial.** { *; }
|
||||
54
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
989
app/src/main/assets/dd/602A_E30E.sym
Normal 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)
|
||||
50
app/src/main/java/ru/kaptsov/hartmobile/MainActivity.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
@ -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()
|
||||
}
|
||||
@ -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) }
|
||||
170
app/src/main/java/ru/kaptsov/hartmobile/dd/DdManager.kt
Normal 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._\\-]"), "_")
|
||||
}
|
||||
69
app/src/main/java/ru/kaptsov/hartmobile/dd/DdModel.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
258
app/src/main/java/ru/kaptsov/hartmobile/dd/DdParser.kt
Normal 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
|
||||
}
|
||||
}
|
||||
257
app/src/main/java/ru/kaptsov/hartmobile/dd/SymParser.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
144
app/src/main/java/ru/kaptsov/hartmobile/protocol/HartFrame.kt
Normal 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-запрос с коротким адресом (0–15).
|
||||
* @param command номер команды (0–255)
|
||||
* @param address опросный адрес устройства (обычно 0)
|
||||
* @param data байты данных команды (может быть пустым)
|
||||
*/
|
||||
fun buildRequest(command: Int, address: Int = 0, data: ByteArray = ByteArray(0)): ByteArray {
|
||||
val totalSize = PREAMBLE.size + 1 + 1 + 1 + 1 + data.size + 1
|
||||
val frame = ByteArray(totalSize)
|
||||
var i = 0
|
||||
|
||||
PREAMBLE.forEach { frame[i++] = it }
|
||||
frame[i++] = START_SHORT_MASTER.toByte()
|
||||
frame[i++] = address.toByte()
|
||||
frame[i++] = command.toByte()
|
||||
frame[i++] = data.size.toByte()
|
||||
data.forEach { frame[i++] = it }
|
||||
|
||||
var checksum = START_SHORT_MASTER xor address xor command xor data.size
|
||||
data.forEach { checksum = checksum xor (it.toInt() and 0xFF) }
|
||||
frame[i] = checksum.toByte()
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает HART-запрос с длинным адресом (5-байтовый unique address).
|
||||
* Используется для всех команд кроме Cmd 0 после идентификации устройства.
|
||||
* @param command номер команды (0–255)
|
||||
* @param uniqueAddr 5-байтовый уникальный адрес устройства
|
||||
* @param data байты данных команды (может быть пустым)
|
||||
*/
|
||||
fun buildLongFrameRequest(command: Int, uniqueAddr: ByteArray, data: ByteArray = ByteArray(0)): ByteArray {
|
||||
require(uniqueAddr.size == 5) { "Unique address must be 5 bytes" }
|
||||
val totalSize = PREAMBLE.size + 1 + 5 + 1 + 1 + data.size + 1
|
||||
val frame = ByteArray(totalSize)
|
||||
var i = 0
|
||||
|
||||
PREAMBLE.forEach { frame[i++] = it }
|
||||
frame[i++] = START_LONG_MASTER.toByte()
|
||||
uniqueAddr.forEach { frame[i++] = it }
|
||||
frame[i++] = command.toByte()
|
||||
frame[i++] = data.size.toByte()
|
||||
data.forEach { frame[i++] = it }
|
||||
|
||||
var checksum = START_LONG_MASTER
|
||||
uniqueAddr.forEach { checksum = checksum xor (it.toInt() and 0xFF) }
|
||||
checksum = checksum xor command xor data.size
|
||||
data.forEach { checksum = checksum xor (it.toInt() and 0xFF) }
|
||||
frame[i] = checksum.toByte()
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит 5-байтовый уникальный адрес для long frame из данных Cmd 0.
|
||||
* Формат: [0x80 | mfr_id[5:0]] [device_type] [device_id_hi] [device_id_mid] [device_id_lo]
|
||||
*/
|
||||
fun buildUniqueAddress(manufacturerId: Int, deviceType: Int, deviceId: Int): ByteArray {
|
||||
return byteArrayOf(
|
||||
(0x80 or (manufacturerId and 0x3F)).toByte(),
|
||||
(deviceType and 0xFF).toByte(),
|
||||
((deviceId shr 16) and 0xFF).toByte(),
|
||||
((deviceId shr 8) and 0xFF).toByte(),
|
||||
(deviceId and 0xFF).toByte()
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Стандартные команды HART ----
|
||||
|
||||
/** Команда 0: Чтение идентификатора устройства */
|
||||
fun cmd0ReadUniqueId(address: Int = 0) = buildRequest(0, address)
|
||||
|
||||
/** Команда 1: Чтение первичной переменной (PV) */
|
||||
fun cmd1ReadPV(address: Int = 0) = buildRequest(1, address)
|
||||
|
||||
/** Команда 2: Чтение тока петли и процента диапазона */
|
||||
fun cmd2ReadLoopCurrentAndPercent(address: Int = 0) = buildRequest(2, address)
|
||||
|
||||
/** Команда 3: Чтение динамических переменных (PV, SV, TV, QV) и тока петли */
|
||||
fun cmd3ReadDynamicVariables(address: Int = 0) = buildRequest(3, address)
|
||||
|
||||
/** Команда 13: Чтение тэга, дескриптора и даты */
|
||||
fun cmd13ReadTagDescriptorDate(address: Int = 0) = buildRequest(13, address)
|
||||
|
||||
/** Команда 15: Чтение информации об устройстве (диапазон, единицы) */
|
||||
fun cmd15ReadDeviceInfo(address: Int = 0) = buildRequest(15, address)
|
||||
|
||||
/** Команда 48: Чтение расширенного статуса устройства */
|
||||
fun cmd48ReadAdditionalStatus(address: Int = 0) = buildRequest(48, address)
|
||||
|
||||
/**
|
||||
* Команда 40: Установка фиксированного тока петли (Loop Test).
|
||||
* @param address опросный адрес
|
||||
* @param currentMa ток в мА (например 4.0, 12.0, 20.0)
|
||||
*/
|
||||
fun cmd40SetFixedCurrent(address: Int = 0, currentMa: Float): ByteArray {
|
||||
val data = ByteArray(5)
|
||||
data[0] = 0x01 // режим: 1 = войти в фиксированный режим
|
||||
val bits = java.lang.Float.floatToIntBits(currentMa)
|
||||
data[1] = (bits ushr 24).toByte()
|
||||
data[2] = (bits ushr 16).toByte()
|
||||
data[3] = (bits ushr 8).toByte()
|
||||
data[4] = bits.toByte()
|
||||
return buildRequest(40, address, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Команда 40: Выход из режима фиксированного тока.
|
||||
*/
|
||||
fun cmd40ExitFixedCurrent(address: Int = 0): ByteArray {
|
||||
val data = ByteArray(5)
|
||||
data[0] = 0x00 // режим: 0 = выйти из фиксированного режима
|
||||
// ток = 0 при выходе (не важно)
|
||||
return buildRequest(40, address, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Команда 9: Чтение переменных устройства с кодами (HART 5/6/7).
|
||||
* @param varCodes до 4 кодов переменных. Незаполненные позиции = 250 (не используется).
|
||||
* Стандартные коды: 0=PV, 1=SV, 2=TV, 3=QV. Коды 4-235 — специфичные для производителя.
|
||||
*/
|
||||
fun cmd9ReadDeviceVariables(address: Int = 0, varCodes: IntArray): ByteArray {
|
||||
val data = ByteArray(4) { 250.toByte() } // 250 = код "не используется"
|
||||
varCodes.take(4).forEachIndexed { i, code -> data[i] = code.toByte() }
|
||||
return buildRequest(9, address, data)
|
||||
}
|
||||
}
|
||||
461
app/src/main/java/ru/kaptsov/hartmobile/protocol/HartParser.kt
Normal 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 "Ток петли"
|
||||
)
|
||||
}
|
||||
344
app/src/main/java/ru/kaptsov/hartmobile/ui/DdMenuFragment.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
287
app/src/main/java/ru/kaptsov/hartmobile/ui/DeviceFragment.kt
Normal 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 = обычный, 1–15 = multi-drop)
|
||||
|
||||
═══ ПЕРЕМЕННЫЕ УСТРОЙСТВА ═══
|
||||
Кнопка «Переменные устройства (Cmd 9)» запускает автоматическое сканирование кодов 0–50 через HART Команду 9. Это позволяет обнаружить все специфичные переменные производителя (например, для ELEMER РЭМ: объёмный расход, тотализаторы, температура и др.) без ручного задания кодов.
|
||||
|
||||
═══ LOOP TEST ═══
|
||||
Управление токовой петлей 4–20 мА (HART Команда 40). Выбор из предустановок или ручной ввод. Обязательно нажмите «Выйти из Loop Test» по завершении!
|
||||
|
||||
═══ ПОИСК УСТРОЙСТВ ═══
|
||||
Перебирает HART адреса 0–15 и находит все устройства на шине (Команда 0).
|
||||
|
||||
═══ DD ФАЙЛЫ ═══
|
||||
Хранилище DD файлов производителя. Текстовые файлы (.sym, .dd) разбираются и показывают структуру меню. Бинарные файлы (.fm8, .fm6) можно хранить, но их содержимое недоступно для чтения (проприетарный формат FieldComm Group).
|
||||
""".trimIndent())
|
||||
.setPositiveButton("Понятно", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showWriteTagDialog() {
|
||||
val currentTag = viewModel.state.value?.tagInfo
|
||||
val inputTag = android.widget.EditText(requireContext()).apply {
|
||||
hint = "Тэг (до 32 символов)"
|
||||
setText(currentTag?.tag?.trim() ?: "")
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(32))
|
||||
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
|
||||
}
|
||||
val layout = android.widget.LinearLayout(requireContext()).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(48, 24, 48, 0)
|
||||
addView(inputTag)
|
||||
}
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("Записать тэг устройства")
|
||||
.setMessage("HART Long Tag (Cmd 22): до 32 символов, A-Z, 0-9, пробел. Строчные будут заглавными.")
|
||||
.setView(layout)
|
||||
.setPositiveButton("Записать") { _, _ ->
|
||||
val tag = inputTag.text.toString()
|
||||
if (tag.isBlank()) {
|
||||
android.widget.Toast.makeText(requireContext(), "Введите тэг", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return@setPositiveButton
|
||||
}
|
||||
android.widget.Toast.makeText(requireContext(), "Записываю...", android.widget.Toast.LENGTH_SHORT).show()
|
||||
viewModel.writeTag(tag, "") { _, msg ->
|
||||
android.widget.Toast.makeText(requireContext(), msg, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showAddressDialog() {
|
||||
val current = viewModel.state.value?.hartAddress ?: 0
|
||||
val input = android.widget.EditText(requireContext()).apply {
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
setText(current.toString())
|
||||
hint = "0–15"
|
||||
}
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("HART опросный адрес")
|
||||
.setMessage("Введите адрес устройства (0 = по умолчанию, 1–15 = multi-drop):")
|
||||
.setView(input)
|
||||
.setPositiveButton("OK") { _, _ ->
|
||||
val addr = input.text.toString().toIntOrNull()?.coerceIn(0, 15) ?: 0
|
||||
viewModel.setHartAddress(addr)
|
||||
viewModel.readVariables()
|
||||
}
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun shareLogFile() {
|
||||
val logFile = HartFileLogger.getLogFile()
|
||||
if (logFile == null || !logFile.exists()) {
|
||||
android.widget.Toast.makeText(requireContext(), "Лог-файл не найден", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
requireContext(),
|
||||
"${requireContext().packageName}.fileprovider",
|
||||
logFile
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "HART Mobile Log")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Отправить лог"))
|
||||
} catch (e: Exception) {
|
||||
android.widget.Toast.makeText(requireContext(), "Ошибка: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package ru.kaptsov.hartmobile.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ru.kaptsov.hartmobile.databinding.FragmentDeviceVariablesBinding
|
||||
import ru.kaptsov.hartmobile.databinding.ItemDeviceVariableBinding
|
||||
import ru.kaptsov.hartmobile.protocol.DeviceVariable
|
||||
|
||||
class DeviceVariablesFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentDeviceVariablesBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: MainViewModel by activityViewModels()
|
||||
private val adapter = VariablesAdapter()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
|
||||
_binding = FragmentDeviceVariablesBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.rvVariables.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.rvVariables.adapter = adapter
|
||||
|
||||
binding.btnScan.setOnClickListener {
|
||||
viewModel.scanDeviceVariables()
|
||||
}
|
||||
|
||||
binding.btnRefresh.setOnClickListener {
|
||||
viewModel.refreshDeviceVariables()
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
val scanning = state.isVarScanning
|
||||
binding.progressBar.visibility = if (scanning) View.VISIBLE else View.GONE
|
||||
binding.btnScan.isEnabled = !scanning
|
||||
binding.btnRefresh.isEnabled = !scanning && !state.discoveredVariables.isNullOrEmpty()
|
||||
binding.tvScanStatus.text = state.varScanStatus
|
||||
?: "Нажмите «Сканировать» для поиска переменных (коды 0–50)"
|
||||
|
||||
val vars = state.discoveredVariables
|
||||
if (vars != null) {
|
||||
adapter.submitList(vars)
|
||||
}
|
||||
}
|
||||
|
||||
// Автозапуск сканирования если ещё не делали
|
||||
if (viewModel.state.value?.discoveredVariables == null &&
|
||||
viewModel.state.value?.isVarScanning == false) {
|
||||
viewModel.scanDeviceVariables()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RecyclerView Adapter ----
|
||||
|
||||
private val DIFF = object : DiffUtil.ItemCallback<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
|
||||
}
|
||||
}
|
||||
129
app/src/main/java/ru/kaptsov/hartmobile/ui/LicenseFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
1157
app/src/main/java/ru/kaptsov/hartmobile/ui/MainViewModel.kt
Normal file
109
app/src/main/java/ru/kaptsov/hartmobile/ui/PollScanFragment.kt
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
200
app/src/main/java/ru/kaptsov/hartmobile/ui/ScanFragment.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/ru/kaptsov/hartmobile/ui/TrendFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
191
app/src/main/java/ru/kaptsov/hartmobile/ui/TrendView.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
7
app/src/main/res/drawable/bg_activation_id.xml
Normal 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>
|
||||
26
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
113
app/src/main/res/layout/fragment_dd_menu.xml
Normal 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>
|
||||
196
app/src/main/res/layout/fragment_device.xml
Normal 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>
|
||||
67
app/src/main/res/layout/fragment_device_variables.xml
Normal 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>
|
||||
179
app/src/main/res/layout/fragment_license.xml
Normal 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>
|
||||
200
app/src/main/res/layout/fragment_loop_test.xml
Normal 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>
|
||||
80
app/src/main/res/layout/fragment_poll_scan.xml
Normal 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-устройств по адресам 0–15"
|
||||
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>
|
||||
145
app/src/main/res/layout/fragment_scan.xml
Normal 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>
|
||||
62
app/src/main/res/layout/fragment_trend.xml
Normal 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>
|
||||
23
app/src/main/res/layout/item_bluetooth_device.xml
Normal 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>
|
||||
33
app/src/main/res/layout/item_dd_file.xml
Normal 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>
|
||||
54
app/src/main/res/layout/item_dd_menu.xml
Normal 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>
|
||||
39
app/src/main/res/layout/item_device_variable.xml
Normal 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>
|
||||
44
app/src/main/res/layout/item_poll_result.xml
Normal 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>
|
||||
20
app/src/main/res/menu/device_menu.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/store_icon_512.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
82
app/src/main/res/navigation/nav_graph.xml
Normal 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>
|
||||
6
app/src/main/res/values-sw600dp/dimens.xml
Normal 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>
|
||||
47
app/src/main/res/values-sw600dp/styles.xml
Normal 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>
|
||||
7
app/src/main/res/values/dimens.xml
Normal 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>
|
||||
11
app/src/main/res/values/strings.xml
Normal 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>
|
||||
61
app/src/main/res/values/themes.xml
Normal 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>
|
||||
5
app/src/main/res/xml/device_filter.xml
Normal 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>
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal 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
@ -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
@ -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
@ -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
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
@ -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")
|
||||
31
store/reestr_po/components.md
Normal 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.
|
||||
103
store/reestr_po/functional_description.md
Normal 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
|
||||
65
store/reestr_po/install_guide.md
Normal 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
|
||||
52
store/reestr_po/lifecycle.md
Normal 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)
|
||||
|
||||
## Гарантии непрерывности
|
||||
- Исходный код хранится в системе контроля версий на территории РФ
|
||||
- Резервные копии создаются регулярно
|
||||
- Разработка и поддержка не зависят от иностранных сервисов и инфраструктуры
|
||||
170
store/reestr_po/user_manual.md
Normal 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
|
||||
- Разработчик: Капцов Александр Александрович
|
||||
111
tools/process_screenshots.py
Normal 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()
|
||||