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