refactor(plc): 重构PLC通信管理与监控轮询机制
- 实现PLC长连接策略,支持连接复用和自动重连 - 添加监控轮询的退避重试机制,支持快速重试和最大延迟配置 - 实现写入指令期间暂停监控轮询,防止线程池排队积压 - 添加详细的诊断日志自动记录和错误分类功能 - 优化UI状态渲染,区分正常监控和通讯异常状态 - 添加后台断连释放连接资源,防止连接泄露 - 实现监控策略类解耦,支持灵活的异常判定规则 - 添加单元测试覆盖监控策略和断连功能main
parent
56fdf39d8e
commit
6fbc8971c7
@ -0,0 +1,72 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
final class DiagnosticJson {
|
||||
|
||||
private DiagnosticJson() {
|
||||
}
|
||||
|
||||
static String toJson(Map<String, Object> values) {
|
||||
StringBuilder json = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
if (!first) {
|
||||
json.append(',');
|
||||
}
|
||||
first = false;
|
||||
appendQuoted(json, entry.getKey());
|
||||
json.append(':');
|
||||
appendValue(json, entry.getValue());
|
||||
}
|
||||
return json.append('}').toString();
|
||||
}
|
||||
|
||||
private static void appendValue(StringBuilder json, Object value) {
|
||||
if (value == null) {
|
||||
json.append("null");
|
||||
} else if (value instanceof Number || value instanceof Boolean) {
|
||||
json.append(value);
|
||||
} else {
|
||||
appendQuoted(json, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendQuoted(StringBuilder json, String value) {
|
||||
json.append('"');
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char current = value.charAt(i);
|
||||
switch (current) {
|
||||
case '"':
|
||||
json.append("\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
json.append("\\\\");
|
||||
break;
|
||||
case '\b':
|
||||
json.append("\\b");
|
||||
break;
|
||||
case '\f':
|
||||
json.append("\\f");
|
||||
break;
|
||||
case '\n':
|
||||
json.append("\\n");
|
||||
break;
|
||||
case '\r':
|
||||
json.append("\\r");
|
||||
break;
|
||||
case '\t':
|
||||
json.append("\\t");
|
||||
break;
|
||||
default:
|
||||
if (current < 0x20) {
|
||||
json.append(String.format("\\u%04x", (int) current));
|
||||
} else {
|
||||
json.append(current);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
json.append('"');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
final class DiagnosticLogFilePolicy {
|
||||
|
||||
static final long MAX_FILE_BYTES = 20L * 1024L * 1024L;
|
||||
static final int MAX_FILE_COUNT = 10;
|
||||
static final String FILE_PREFIX = "PdaPlc-diagnostics-";
|
||||
|
||||
private DiagnosticLogFilePolicy() {
|
||||
}
|
||||
|
||||
static String fileName(String sessionTimestamp, int partNumber) {
|
||||
String partSuffix = partNumber <= 1 ? "" : "-part-" + partNumber;
|
||||
return FILE_PREFIX + sessionTimestamp + partSuffix + ".jsonl";
|
||||
}
|
||||
|
||||
static boolean shouldRotate(long currentBytes, int nextLineBytes) {
|
||||
return currentBytes > 0 && currentBytes + nextLineBytes > MAX_FILE_BYTES;
|
||||
}
|
||||
|
||||
static boolean shouldDeleteBeforeCreating(int newestFirstIndex) {
|
||||
return newestFirstIndex >= MAX_FILE_COUNT - 1;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
final class DiagnosticLogger {
|
||||
|
||||
private static final String TAG = "PdaPlcDiagnostics";
|
||||
private static final ExecutorService LOG_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final AtomicLong OPERATION_SEQUENCE = new AtomicLong();
|
||||
private static volatile boolean initialized;
|
||||
private static Context applicationContext;
|
||||
private static String sessionTimestamp;
|
||||
private static MediaStoreDiagnosticLogWriter writer;
|
||||
|
||||
private DiagnosticLogger() {
|
||||
}
|
||||
|
||||
static synchronized void initialize(Context context) {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
applicationContext = context.getApplicationContext();
|
||||
sessionTimestamp = formatLocalTime("yyyyMMdd-HHmmss", new Date());
|
||||
LOG_EXECUTOR.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ensureWriter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static long nextOperationId() {
|
||||
return OPERATION_SEQUENCE.incrementAndGet();
|
||||
}
|
||||
|
||||
static void log(String eventName, Object... keyValues) {
|
||||
try {
|
||||
final Map<String, Object> event = new LinkedHashMap<>();
|
||||
event.put("timestamp", formatLocalTime("yyyy-MM-dd'T'HH:mm:ss.SSSZ", new Date()));
|
||||
event.put("elapsed_ms", SystemClock.elapsedRealtime());
|
||||
event.put("event", eventName);
|
||||
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||
event.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
||||
}
|
||||
|
||||
LOG_EXECUTOR.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
MediaStoreDiagnosticLogWriter currentWriter = ensureWriter();
|
||||
if (currentWriter == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentWriter.write(event);
|
||||
} catch (Exception e) {
|
||||
currentWriter.recoverAfterFailure();
|
||||
Log.e(TAG, "写入自动诊断日志失败", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "提交自动诊断日志失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static MediaStoreDiagnosticLogWriter ensureWriter() {
|
||||
if (writer != null || applicationContext == null || sessionTimestamp == null) {
|
||||
return writer;
|
||||
}
|
||||
try {
|
||||
writer = new MediaStoreDiagnosticLogWriter(applicationContext, sessionTimestamp);
|
||||
writer.openInitialFile();
|
||||
} catch (Exception e) {
|
||||
writer = null;
|
||||
Log.e(TAG, "创建自动诊断日志失败", e);
|
||||
}
|
||||
return writer;
|
||||
}
|
||||
|
||||
private static String formatLocalTime(String pattern, Date date) {
|
||||
SimpleDateFormat formatter = new SimpleDateFormat(pattern, Locale.US);
|
||||
formatter.setTimeZone(TimeZone.getDefault());
|
||||
return formatter.format(date);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
final class MediaStoreDiagnosticLogWriter {
|
||||
|
||||
private static final String RELATIVE_PATH = Environment.DIRECTORY_DOWNLOADS + "/PdaPlc/";
|
||||
|
||||
private final Context context;
|
||||
private final ContentResolver contentResolver;
|
||||
private final String sessionTimestamp;
|
||||
private OutputStream outputStream;
|
||||
private long currentBytes;
|
||||
private int partNumber;
|
||||
|
||||
MediaStoreDiagnosticLogWriter(Context context, String sessionTimestamp) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.contentResolver = context.getContentResolver();
|
||||
this.sessionTimestamp = sessionTimestamp;
|
||||
}
|
||||
|
||||
void openInitialFile() throws IOException {
|
||||
openNextFile();
|
||||
}
|
||||
|
||||
void write(Map<String, Object> event) throws IOException {
|
||||
appendNetworkState(event);
|
||||
byte[] line = (DiagnosticJson.toJson(event) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
if (DiagnosticLogFilePolicy.shouldRotate(currentBytes, line.length)) {
|
||||
openNextFile();
|
||||
}
|
||||
ensureOpen();
|
||||
outputStream.write(line);
|
||||
outputStream.flush();
|
||||
currentBytes += line.length;
|
||||
}
|
||||
|
||||
void recoverAfterFailure() {
|
||||
closeCurrentFile();
|
||||
}
|
||||
|
||||
private void ensureOpen() throws IOException {
|
||||
if (outputStream == null) {
|
||||
openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
private void openNextFile() throws IOException {
|
||||
closeCurrentFile();
|
||||
cleanupOldFilesBeforeCreating();
|
||||
partNumber++;
|
||||
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
DiagnosticLogFilePolicy.fileName(sessionTimestamp, partNumber));
|
||||
values.put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson");
|
||||
values.put(MediaStore.MediaColumns.RELATIVE_PATH, RELATIVE_PATH);
|
||||
|
||||
Uri uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
|
||||
if (uri == null) {
|
||||
throw new IOException("无法在 Download/PdaPlc 创建诊断日志");
|
||||
}
|
||||
outputStream = contentResolver.openOutputStream(uri, "w");
|
||||
if (outputStream == null) {
|
||||
contentResolver.delete(uri, null, null);
|
||||
throw new IOException("无法打开诊断日志输出流");
|
||||
}
|
||||
currentBytes = 0L;
|
||||
}
|
||||
|
||||
private void cleanupOldFilesBeforeCreating() {
|
||||
String[] projection = {MediaStore.MediaColumns._ID};
|
||||
String selection = MediaStore.MediaColumns.RELATIVE_PATH + "=? AND "
|
||||
+ MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ?";
|
||||
String[] selectionArgs = {RELATIVE_PATH, DiagnosticLogFilePolicy.FILE_PREFIX + "%"};
|
||||
String sortOrder = MediaStore.MediaColumns.DATE_ADDED + " DESC, "
|
||||
+ MediaStore.MediaColumns.DISPLAY_NAME + " DESC";
|
||||
|
||||
try (Cursor cursor = contentResolver.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder
|
||||
)) {
|
||||
if (cursor == null) {
|
||||
return;
|
||||
}
|
||||
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
|
||||
int index = 0;
|
||||
while (cursor.moveToNext()) {
|
||||
if (DiagnosticLogFilePolicy.shouldDeleteBeforeCreating(index)) {
|
||||
long id = cursor.getLong(idColumn);
|
||||
Uri uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id);
|
||||
contentResolver.delete(uri, null, null);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 清理失败不能影响本次诊断日志创建,更不能影响 PLC 通讯。
|
||||
}
|
||||
}
|
||||
|
||||
private void appendNetworkState(Map<String, Object> event) {
|
||||
try {
|
||||
ConnectivityManager manager =
|
||||
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
Network activeNetwork = manager == null ? null : manager.getActiveNetwork();
|
||||
NetworkCapabilities capabilities =
|
||||
activeNetwork == null ? null : manager.getNetworkCapabilities(activeNetwork);
|
||||
event.put("active_network", activeNetwork != null && capabilities != null);
|
||||
event.put("network_type", networkType(capabilities));
|
||||
} catch (Exception e) {
|
||||
event.put("active_network", null);
|
||||
event.put("network_type", "UNKNOWN");
|
||||
event.put("network_state_error", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private static String networkType(NetworkCapabilities capabilities) {
|
||||
if (capabilities == null) {
|
||||
return "NO_ACTIVE_NETWORK";
|
||||
}
|
||||
StringBuilder type = new StringBuilder();
|
||||
appendTransport(type, capabilities, NetworkCapabilities.TRANSPORT_WIFI, "WIFI");
|
||||
appendTransport(type, capabilities, NetworkCapabilities.TRANSPORT_CELLULAR, "CELLULAR");
|
||||
appendTransport(type, capabilities, NetworkCapabilities.TRANSPORT_ETHERNET, "ETHERNET");
|
||||
appendTransport(type, capabilities, NetworkCapabilities.TRANSPORT_VPN, "VPN");
|
||||
return type.length() == 0 ? "OTHER" : type.toString();
|
||||
}
|
||||
|
||||
private static void appendTransport(
|
||||
StringBuilder type,
|
||||
NetworkCapabilities capabilities,
|
||||
int transport,
|
||||
String name
|
||||
) {
|
||||
if (!capabilities.hasTransport(transport)) {
|
||||
return;
|
||||
}
|
||||
if (type.length() > 0) {
|
||||
type.append('+');
|
||||
}
|
||||
type.append(name);
|
||||
}
|
||||
|
||||
private void closeCurrentFile() {
|
||||
if (outputStream == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (IOException ignored) {
|
||||
// 关闭失败仅影响诊断日志,不能向业务线程传播。
|
||||
} finally {
|
||||
outputStream = null;
|
||||
currentBytes = 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
|
||||
import Moka7.S7;
|
||||
|
||||
public class PdaPlcApplication extends Application {
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
DiagnosticLogger.initialize(this);
|
||||
DiagnosticLogger.log(
|
||||
"app_start",
|
||||
"app_version", applicationVersion(),
|
||||
"android_version", Build.VERSION.RELEASE,
|
||||
"device_model", Build.MANUFACTURER + " " + Build.MODEL,
|
||||
"plc_ip", PlcConfig.PLC_IP,
|
||||
"rack", PlcConfig.RACK,
|
||||
"slot", PlcConfig.SLOT,
|
||||
"db_number", PlcConfig.DB_NUMBER,
|
||||
"connection_type", "S7.OP",
|
||||
"connection_type_code", S7.OP,
|
||||
"moka7_receive_timeout_ms", 2000,
|
||||
"monitor_interval_ms", PlcConfig.MONITOR_INTERVAL_MILLIS,
|
||||
"monitor_failure_threshold", PlcConfig.MONITOR_FAILURE_THRESHOLD,
|
||||
"monitor_stale_threshold_ms", PlcConfig.MONITOR_STALE_THRESHOLD_MILLIS
|
||||
);
|
||||
}
|
||||
|
||||
private String applicationVersion() {
|
||||
try {
|
||||
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||
return packageInfo.versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import Moka7.S7Client;
|
||||
|
||||
final class PlcErrorClassifier {
|
||||
|
||||
private PlcErrorClassifier() {
|
||||
}
|
||||
|
||||
static String category(int errorCode) {
|
||||
switch (errorCode) {
|
||||
case S7Client.errTCPConnectionFailed:
|
||||
return "TCP_CONNECTION_FAILED";
|
||||
case S7Client.errTCPDataSend:
|
||||
return "TCP_SEND_FAILED";
|
||||
case S7Client.errTCPDataRecv:
|
||||
return "TCP_RECEIVE_FAILED";
|
||||
case S7Client.errTCPDataRecvTout:
|
||||
return "TCP_RECEIVE_TIMEOUT";
|
||||
case S7Client.errTCPConnectionReset:
|
||||
return "TCP_CONNECTION_RESET";
|
||||
case S7Client.errISOInvalidPDU:
|
||||
case S7Client.errISOConnectionFailed:
|
||||
case S7Client.errISONegotiatingPDU:
|
||||
return "ISO_HANDSHAKE_FAILED";
|
||||
case S7Client.errS7InvalidPDU:
|
||||
return "S7_INVALID_PDU";
|
||||
case S7Client.errS7DataRead:
|
||||
return "S7_READ_FAILED";
|
||||
case S7Client.errS7DataWrite:
|
||||
return "S7_WRITE_FAILED";
|
||||
default:
|
||||
return "UNKNOWN_MOKA7_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
static String errorHex(int errorCode) {
|
||||
return String.format(Locale.US, "0x%04X", errorCode);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.example.pdaplccontrol;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import Moka7.S7Client;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DiagnosticSupportTest {
|
||||
|
||||
@Test
|
||||
public void errorClassifier_mapsMoka7Errors() {
|
||||
assertEquals("TCP_CONNECTION_FAILED",
|
||||
PlcErrorClassifier.category(S7Client.errTCPConnectionFailed));
|
||||
assertEquals("TCP_RECEIVE_TIMEOUT",
|
||||
PlcErrorClassifier.category(S7Client.errTCPDataRecvTout));
|
||||
assertEquals("ISO_HANDSHAKE_FAILED",
|
||||
PlcErrorClassifier.category(S7Client.errISOConnectionFailed));
|
||||
assertEquals("S7_READ_FAILED",
|
||||
PlcErrorClassifier.category(S7Client.errS7DataRead));
|
||||
assertEquals("S7_WRITE_FAILED",
|
||||
PlcErrorClassifier.category(S7Client.errS7DataWrite));
|
||||
assertEquals("UNKNOWN_MOKA7_ERROR", PlcErrorClassifier.category(0x1234));
|
||||
assertEquals("0x0004", PlcErrorClassifier.errorHex(S7Client.errTCPDataRecvTout));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void diagnosticJson_serializesAndEscapesValues() {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("event", "read\nfailure");
|
||||
values.put("error_code", 4);
|
||||
values.put("connected", false);
|
||||
values.put("optional", null);
|
||||
|
||||
assertEquals(
|
||||
"{\"event\":\"read\\nfailure\",\"error_code\":4,"
|
||||
+ "\"connected\":false,\"optional\":null}",
|
||||
DiagnosticJson.toJson(values)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void filePolicy_usesOneFilePerSessionAndNumberedParts() {
|
||||
assertEquals(
|
||||
"PdaPlc-diagnostics-20260608-143025.jsonl",
|
||||
DiagnosticLogFilePolicy.fileName("20260608-143025", 1)
|
||||
);
|
||||
assertEquals(
|
||||
"PdaPlc-diagnostics-20260608-143025-part-2.jsonl",
|
||||
DiagnosticLogFilePolicy.fileName("20260608-143025", 2)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void filePolicy_rotatesOnlyWhenCurrentFileHasContentAndWouldExceedLimit() {
|
||||
assertFalse(DiagnosticLogFilePolicy.shouldRotate(0, Integer.MAX_VALUE));
|
||||
assertFalse(DiagnosticLogFilePolicy.shouldRotate(
|
||||
DiagnosticLogFilePolicy.MAX_FILE_BYTES - 100,
|
||||
100
|
||||
));
|
||||
assertTrue(DiagnosticLogFilePolicy.shouldRotate(
|
||||
DiagnosticLogFilePolicy.MAX_FILE_BYTES - 100,
|
||||
101
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void filePolicy_keepsNineExistingFilesBeforeCreatingNextFile() {
|
||||
assertFalse(DiagnosticLogFilePolicy.shouldDeleteBeforeCreating(8));
|
||||
assertTrue(DiagnosticLogFilePolicy.shouldDeleteBeforeCreating(9));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue