diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3731265..13364b1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,8 +3,10 @@
xmlns:tools="http://schemas.android.com/tools">
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/example/pdaplccontrol/DiagnosticJson.java b/app/src/main/java/com/example/pdaplccontrol/DiagnosticJson.java
new file mode 100644
index 0000000..3180eac
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/DiagnosticJson.java
@@ -0,0 +1,72 @@
+package com.example.pdaplccontrol;
+
+import java.util.Map;
+
+final class DiagnosticJson {
+
+ private DiagnosticJson() {
+ }
+
+ static String toJson(Map values) {
+ StringBuilder json = new StringBuilder("{");
+ boolean first = true;
+ for (Map.Entry 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('"');
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogFilePolicy.java b/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogFilePolicy.java
new file mode 100644
index 0000000..202f814
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogFilePolicy.java
@@ -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;
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogger.java b/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogger.java
new file mode 100644
index 0000000..f328fc3
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/DiagnosticLogger.java
@@ -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 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);
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/MainActivity.java b/app/src/main/java/com/example/pdaplccontrol/MainActivity.java
index c811b83..9dab0f9 100644
--- a/app/src/main/java/com/example/pdaplccontrol/MainActivity.java
+++ b/app/src/main/java/com/example/pdaplccontrol/MainActivity.java
@@ -4,6 +4,7 @@ import android.content.res.ColorStateList;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.os.SystemClock;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
@@ -15,6 +16,10 @@ import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
+/**
+ * PDA 监控与控制主页面。
+ * 提供线体选择与重量下发功能,并在前台周期性监控 PLC 上的实际线体和重量状态。
+ */
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
private final Button[] lineButtons = new Button[9];
@@ -28,8 +33,27 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
private EditText editWeightKg;
private TextView textMonitorStatus;
private LinearLayout rootLayout;
+
+ // 监控是否应该处于活跃轮询状态(生命周期相关)
private boolean monitorRunning;
+
+ // 标识是否已发起了读取请求但尚未返回,防止在低波速或卡顿网络下造成轮询任务堆积
private boolean monitorRequestInFlight;
+
+ // 核心避坑设计:标识当前是否正在向 PLC 写入指令(发线体号或重量)。
+ // 在写入期间,必须暂停后台监控轮询。这是因为底层 Moka7 S7 驱动是单线程串行交互,
+ // 读写操作串行排队。如果在下发指令时监控依然频繁发起读请求,会导致指令响应变慢甚至连接阻塞。
+ private boolean commandInProgress;
+
+ // 标识本次启动后是否至少成功读取过一次 PLC 数据,用于辅助断线过期时间判定
+ private boolean hasSuccessfulMonitorRead;
+
+ // 连续监控读取失败的次数,用于配合策略执行退避重试和异常渲染
+ private int consecutiveMonitorFailures;
+
+ // 记录上一次成功读取到 PLC 数据时的系统开机时间(毫秒)
+ private long lastMonitorSuccessElapsedMillis;
+
private ColorStateList defaultLineButtonBackgroundTint;
private ColorStateList defaultLineButtonTextColors;
@@ -37,6 +61,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
+ DiagnosticLogger.log("activity_created");
rootLayout = findViewById(R.id.rootLayout);
editWeightKg = findViewById(R.id.editWeightKg);
@@ -48,12 +73,15 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
@Override
protected void onStart() {
super.onStart();
+ // 页面可见时启动 PLC 数据周期监控
startMonitor();
}
@Override
protected void onStop() {
+ // 页面不可见时立即停止轮询,并释放 PLC 的长连接,防止退至后台后死连接无谓占用 PLC 的受限连接池
stopMonitor();
+ PlcManager.getInstance().disconnectAsync();
super.onStop();
}
@@ -66,7 +94,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
&& event.getAction() == KeyEvent.ACTION_UP
&& event.getKeyCode() == KeyEvent.KEYCODE_ENTER);
if (isDone) {
- rootLayout.requestFocus();
+ rootLayout.requestFocus(); // 输入完成后转移焦点,方便更新 PLC 传回的最新值
sendWeight();
return true;
}
@@ -99,6 +127,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
if (v instanceof Button) {
short lineNumber = (short) v.getTag();
+ // 发送指令前暂停监控以防线程池排队积压
+ pauseMonitorForCommand();
setControlsEnabled(false);
Toast.makeText(this, "正在向 PLC 发送线体号 " + lineNumber + "...", Toast.LENGTH_SHORT).show();
@@ -115,6 +145,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
return;
}
+ // 发送指令前暂停监控以防线程池排队积压
+ pauseMonitorForCommand();
setControlsEnabled(false);
Toast.makeText(this, "正在向 PLC 发送重量 "
+ WeightInputParser.kgToDisplayText(weightKg) + "kg...", Toast.LENGTH_SHORT).show();
@@ -122,18 +154,25 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
PlcManager.getInstance().sendWeightKgAsync(weightKg, createCallback());
}
+ /**
+ * 生成通用的写入操作回调处理器。
+ */
private PlcManager.PlcCallback createCallback() {
return new PlcManager.PlcCallback() {
@Override
public void onSuccess(String message) {
Toast.makeText(MainActivity.this, message, Toast.LENGTH_LONG).show();
setControlsEnabled(true);
+ // 写入指令执行完毕,延迟恢复周期监控
+ resumeMonitorAfterCommand();
}
@Override
public void onError(String error) {
Toast.makeText(MainActivity.this, error, Toast.LENGTH_LONG).show();
setControlsEnabled(true);
+ // 即使下发失败,也需恢复监控以更新当前真实数据
+ resumeMonitorAfterCommand();
}
};
}
@@ -149,17 +188,37 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
private void startMonitor() {
monitorRunning = true;
+ commandInProgress = false;
+ monitorRequestInFlight = false;
+ consecutiveMonitorFailures = 0;
+ hasSuccessfulMonitorRead = false;
+ lastMonitorSuccessElapsedMillis = 0L;
monitorHandler.removeCallbacks(monitorRunnable);
- renderMonitorPending();
+ renderMonitorNormal();
+ DiagnosticLogger.log(
+ "monitor_started",
+ "ui_status", currentMonitorStatus(),
+ "monitor_interval_ms", PlcConfig.MONITOR_INTERVAL_MILLIS
+ );
monitorHandler.post(monitorRunnable);
}
private void stopMonitor() {
monitorRunning = false;
+ commandInProgress = false;
monitorHandler.removeCallbacks(monitorRunnable);
+ DiagnosticLogger.log(
+ "monitor_stopped",
+ "consecutive_failures", consecutiveMonitorFailures,
+ "ui_status", currentMonitorStatus()
+ );
}
+ /**
+ * 执行周期性监控读取任务。
+ */
private void readCurrentValues() {
+ // 若已停止监控、或上一次读取网络请求仍在执行中,则跳过本次,防止堆积
if (!monitorRunning || monitorRequestInFlight) {
return;
}
@@ -172,50 +231,95 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
if (monitorRunning) {
renderCurrentValues(values);
}
- scheduleNextMonitorRead();
+ // 按照策略类中推荐的延迟执行下一次读取
+ int nextDelayMillis = PlcMonitorPolicy.nextDelayMillis(consecutiveMonitorFailures);
+ DiagnosticLogger.log(
+ "monitor_read_success",
+ "monitor_running", monitorRunning,
+ "consecutive_failures", consecutiveMonitorFailures,
+ "millis_since_last_success", 0L,
+ "ui_status", currentMonitorStatus(),
+ "next_delay_ms", nextDelayMillis
+ );
+ scheduleNextMonitorRead(nextDelayMillis);
}
@Override
public void onError(String error) {
monitorRequestInFlight = false;
if (monitorRunning) {
- renderMonitorStale();
+ renderMonitorFailure();
}
- scheduleNextMonitorRead();
+ // 读取失败时依据策略(如连续失败1/2次时加速重试),计算退避重试延迟
+ int nextDelayMillis = PlcMonitorPolicy.nextDelayMillis(consecutiveMonitorFailures);
+ DiagnosticLogger.log(
+ "monitor_read_failure",
+ "monitor_running", monitorRunning,
+ "consecutive_failures", consecutiveMonitorFailures,
+ "millis_since_last_success", millisSinceLastSuccess(),
+ "ui_status", currentMonitorStatus(),
+ "next_delay_ms", nextDelayMillis,
+ "error_text", error
+ );
+ scheduleNextMonitorRead(nextDelayMillis);
}
});
}
- private void scheduleNextMonitorRead() {
- if (monitorRunning) {
- monitorHandler.postDelayed(monitorRunnable, PlcConfig.MONITOR_INTERVAL_MILLIS);
+ private void scheduleNextMonitorRead(int delayMillis) {
+ // 如果用户正在执行下发动作,则不再安排下一次轮询,彻底让路给写入指令
+ if (monitorRunning && !commandInProgress) {
+ monitorHandler.removeCallbacks(monitorRunnable);
+ monitorHandler.postDelayed(monitorRunnable, delayMillis);
}
}
+ /**
+ * 渲染监控到的正常 PLC 数据。
+ */
private void renderCurrentValues(PlcCurrentValues values) {
+ consecutiveMonitorFailures = 0;
+ hasSuccessfulMonitorRead = true;
+ lastMonitorSuccessElapsedMillis = SystemClock.elapsedRealtime(); // 记录成功刷新的时间戳
renderMonitorNormal();
+
+ // 避坑考量:如果当前输入框正处于编辑聚焦状态,不应强制刷新数据,以防止覆盖用户的输入
if (!editWeightKg.hasFocus()) {
editWeightKg.setText(WeightInputParser.kgToDisplayText(values.getWeightKg()));
}
renderCurrentLineNumber(values.getLineNumber());
}
- private void renderMonitorPending() {
- textMonitorStatus.setText("正在读取PLC...");
- textMonitorStatus.setTextColor(getColor(R.color.monitor_status_pending));
- }
-
private void renderMonitorNormal() {
- textMonitorStatus.setText("监控正常");
+ textMonitorStatus.setText("监控中");
textMonitorStatus.setTextColor(getColor(R.color.monitor_status_normal));
}
- private void renderMonitorStale() {
- textMonitorStatus.setText("已失联/数据过期");
- textMonitorStatus.setTextColor(getColor(R.color.monitor_status_stale));
- clearCurrentLineNumberHighlight();
+ /**
+ * 监控失败时,进行状态渲染判定(退避容错防抖逻辑)。
+ */
+ private void renderMonitorFailure() {
+ consecutiveMonitorFailures++;
+ long millisSinceLastSuccess = hasSuccessfulMonitorRead
+ ? SystemClock.elapsedRealtime() - lastMonitorSuccessElapsedMillis
+ : 0L;
+ // 委托策略类判定此时是否属于严重“通讯异常”
+ if (PlcMonitorPolicy.isCommunicationAbnormal(
+ consecutiveMonitorFailures,
+ hasSuccessfulMonitorRead,
+ millisSinceLastSuccess
+ )) {
+ textMonitorStatus.setText("PLC通讯异常");
+ textMonitorStatus.setTextColor(getColor(R.color.monitor_status_stale));
+ } else {
+ // 如果连续失败次数较少且未满超时时间,前台依然静默维持“监控中”的正常状态,规避闪烁报错
+ renderMonitorNormal();
+ }
}
+ /**
+ * 高亮当前 PLC 返回的激活线体按钮。
+ */
private void renderCurrentLineNumber(short currentLineNumber) {
for (Button btn : lineButtons) {
if (btn == null) {
@@ -233,12 +337,41 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
}
}
- private void clearCurrentLineNumberHighlight() {
- for (Button btn : lineButtons) {
- if (btn != null) {
- btn.setBackgroundTintList(defaultLineButtonBackgroundTint);
- btn.setTextColor(defaultLineButtonTextColors);
- }
+ /**
+ * 发送下发指令前,挂起并清空已排队的监控任务。
+ */
+ private void pauseMonitorForCommand() {
+ commandInProgress = true;
+ monitorHandler.removeCallbacks(monitorRunnable);
+ DiagnosticLogger.log(
+ "monitor_paused_for_command",
+ "consecutive_failures", consecutiveMonitorFailures,
+ "ui_status", currentMonitorStatus()
+ );
+ }
+
+ /**
+ * 下发指令结束(无论成败),恢复常规周期的监控任务。
+ */
+ private void resumeMonitorAfterCommand() {
+ commandInProgress = false;
+ if (monitorRunning) {
+ DiagnosticLogger.log(
+ "monitor_resumed_after_command",
+ "next_delay_ms", PlcConfig.MONITOR_INTERVAL_MILLIS,
+ "ui_status", currentMonitorStatus()
+ );
+ scheduleNextMonitorRead(PlcConfig.MONITOR_INTERVAL_MILLIS);
}
}
+
+ private long millisSinceLastSuccess() {
+ return hasSuccessfulMonitorRead
+ ? SystemClock.elapsedRealtime() - lastMonitorSuccessElapsedMillis
+ : 0L;
+ }
+
+ private String currentMonitorStatus() {
+ return textMonitorStatus == null ? "UNKNOWN" : textMonitorStatus.getText().toString();
+ }
}
diff --git a/app/src/main/java/com/example/pdaplccontrol/MediaStoreDiagnosticLogWriter.java b/app/src/main/java/com/example/pdaplccontrol/MediaStoreDiagnosticLogWriter.java
new file mode 100644
index 0000000..4705206
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/MediaStoreDiagnosticLogWriter.java
@@ -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 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 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;
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/Moka7PlcValueSender.java b/app/src/main/java/com/example/pdaplccontrol/Moka7PlcValueSender.java
index e7a2ebf..78161bc 100644
--- a/app/src/main/java/com/example/pdaplccontrol/Moka7PlcValueSender.java
+++ b/app/src/main/java/com/example/pdaplccontrol/Moka7PlcValueSender.java
@@ -2,33 +2,63 @@ package com.example.pdaplccontrol;
import Moka7.S7;
import Moka7.S7Client;
+import android.os.SystemClock;
+/**
+ * 基于 Moka7 驱动的 S7 协议 PLC 通讯实现类。
+ * 采用了长连接策略以提升通讯效率,同时具备连接损坏自愈与主动断连机制。
+ */
class Moka7PlcValueSender implements PlcValueSender {
+ // S7 客户端连接实例。长连接策略下,此实例生命周期会持续存在,直到发生异常或主动断连。
+ private S7Client client;
+
@Override
public void sendLineNumber(short lineNumber) throws PlcCommunicationException {
- writeWord(PlcConfig.LINE_NUMBER_OFFSET, lineNumber);
+ writeWord(PlcConfig.LINE_NUMBER_OFFSET, lineNumber, "WRITE_LINE_NUMBER");
}
@Override
public void sendWeightKg(short weightKg) throws PlcCommunicationException {
- writeWord(PlcConfig.WEIGHT_KG_OFFSET, weightKg);
+ writeWord(PlcConfig.WEIGHT_KG_OFFSET, weightKg, "WRITE_WEIGHT");
}
@Override
public PlcCurrentValues readCurrentValues() throws PlcCommunicationException {
- byte[] data = readBytes(PlcConfig.MONITOR_START_OFFSET, PlcConfig.MONITOR_BYTE_LENGTH);
+ byte[] data = readBytes(
+ PlcConfig.MONITOR_START_OFFSET,
+ PlcConfig.MONITOR_BYTE_LENGTH,
+ "READ_CURRENT_VALUES"
+ );
return new PlcCurrentValues(
decodeWord(data, PlcConfig.LINE_NUMBER_OFFSET - PlcConfig.MONITOR_START_OFFSET),
decodeWord(data, PlcConfig.WEIGHT_KG_OFFSET - PlcConfig.MONITOR_START_OFFSET)
);
}
- private void writeWord(int offset, short value) throws PlcCommunicationException {
- S7Client client = new S7Client();
- try {
- connect(client);
+ /**
+ * 释放当前的 PLC 连接资源。
+ * 业务意图:彻底切断底层的 TCP 套接字并销毁客户端实例,配合 ensureConnected 实现重连自愈。
+ */
+ @Override
+ public synchronized void disconnect() {
+ disconnectInternal(DiagnosticLogger.nextOperationId(), "REQUESTED");
+ }
+ /**
+ * 写入一个 Word 长度的数据至 DB 块。
+ */
+ private void writeWord(int offset, short value, String operationType)
+ throws PlcCommunicationException {
+ long operationId = DiagnosticLogger.nextOperationId();
+ long operationStarted = SystemClock.elapsedRealtime();
+ boolean connectedBefore = isConnected();
+ boolean reusedConnection = false;
+ logOperationStart(operationId, operationType, connectedBefore);
+ try {
+ reusedConnection = ensureConnected(operationId, operationType);
+
+ long ioStarted = SystemClock.elapsedRealtime();
int writeRes = client.WriteArea(
S7.S7AreaDB,
PlcConfig.DB_NUMBER,
@@ -36,53 +66,295 @@ class Moka7PlcValueSender implements PlcValueSender {
PlcConfig.WORD_BYTE_LENGTH,
encodeWord(value)
);
+ logIoResult(operationId, operationType, "WRITE", ioStarted, writeRes);
if (writeRes != 0) {
- throw new PlcCommunicationException("写入数据失败,错误码:" + S7Client.ErrorText(writeRes));
+ // 写入失败说明当前长连接可能已失效,需立即执行断连清理,避免下一次调用继续读取损坏的连接
+ disconnectInternal(operationId, "WRITE_FAILED");
+ throw communicationException("写入数据失败", "WRITE", writeRes);
}
+ logOperationSuccess(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection
+ );
} catch (PlcCommunicationException e) {
+ logOperationFailure(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection,
+ e
+ );
throw e;
} catch (Exception e) {
- throw new PlcCommunicationException("通讯发生异常: " + e.getMessage(), e);
- } finally {
- client.Disconnect();
+ // 捕获可能产生的 Socket、网络断开等非受控异常,执行连接清理,确保重连自愈
+ disconnectInternal(operationId, "JAVA_EXCEPTION");
+ PlcCommunicationException communicationException = new PlcCommunicationException(
+ "通讯发生异常: " + e.getMessage(),
+ "WRITE",
+ null,
+ e
+ );
+ logOperationFailure(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection,
+ communicationException
+ );
+ throw communicationException;
}
}
- private byte[] readBytes(int offset, int amount) throws PlcCommunicationException {
- S7Client client = new S7Client();
+ /**
+ * 读取指定偏移量和长度的字节数据。
+ */
+ private byte[] readBytes(int offset, int amount, String operationType)
+ throws PlcCommunicationException {
+ long operationId = DiagnosticLogger.nextOperationId();
+ long operationStarted = SystemClock.elapsedRealtime();
+ boolean connectedBefore = isConnected();
+ boolean reusedConnection = false;
+ logOperationStart(operationId, operationType, connectedBefore);
try {
- connect(client);
+ reusedConnection = ensureConnected(operationId, operationType);
byte[] data = new byte[amount];
+ long ioStarted = SystemClock.elapsedRealtime();
int readRes = client.ReadArea(S7.S7AreaDB, PlcConfig.DB_NUMBER, offset, amount, data);
+ logIoResult(operationId, operationType, "READ", ioStarted, readRes);
if (readRes != 0) {
- throw new PlcCommunicationException("读取数据失败,错误码:" + S7Client.ErrorText(readRes));
+ // 读取失败立即断连,以便下一次读取能重连,避免残余脏连接阻塞后续轮询
+ disconnectInternal(operationId, "READ_FAILED");
+ throw communicationException("读取数据失败", "READ", readRes);
}
+ logOperationSuccess(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection
+ );
return data;
} catch (PlcCommunicationException e) {
+ logOperationFailure(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection,
+ e
+ );
throw e;
} catch (Exception e) {
- throw new PlcCommunicationException("通讯发生异常: " + e.getMessage(), e);
- } finally {
- client.Disconnect();
+ disconnectInternal(operationId, "JAVA_EXCEPTION");
+ PlcCommunicationException communicationException = new PlcCommunicationException(
+ "通讯发生异常: " + e.getMessage(),
+ "READ",
+ null,
+ e
+ );
+ logOperationFailure(
+ operationId,
+ operationType,
+ operationStarted,
+ connectedBefore,
+ reusedConnection,
+ communicationException
+ );
+ throw communicationException;
}
}
- private void connect(S7Client client) throws PlcCommunicationException {
+ /**
+ * 确保 PLC 长连接可用。
+ * 避坑考量:
+ * 1. 懒加载长连接:若连接已建立且有效,则直接复用,避免每次读写重新发起 TCP 三次握手和 S7 握手(这在工控场景中开销极大);
+ * 2. 自动重连:若连接未建立或已被 disconnect 释放,则重新创建 S7Client 实例并尝试连接。
+ */
+ private boolean ensureConnected(long operationId, String operationType)
+ throws PlcCommunicationException {
+ if (isConnected()) {
+ DiagnosticLogger.log(
+ "plc_connection_reused",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "connected", true
+ );
+ return true;
+ }
+
+ // 先清理可能残留的半连接或无效 client
+ disconnectInternal(operationId, "BEFORE_CONNECT_CLEANUP");
+
+ client = new S7Client();
client.SetConnectionType(S7.OP);
+ long connectStarted = SystemClock.elapsedRealtime();
+ DiagnosticLogger.log(
+ "plc_connect_start",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "receive_timeout_ms", client.RecvTimeout
+ );
int connectRes = client.ConnectTo(PlcConfig.PLC_IP, PlcConfig.RACK, PlcConfig.SLOT);
if (connectRes != 0) {
- throw new PlcCommunicationException("PLC连接失败,错误码:" + connectRes);
+ DiagnosticLogger.log(
+ "plc_connect_failure",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "duration_ms", SystemClock.elapsedRealtime() - connectStarted,
+ "error_code", connectRes,
+ "error_hex", PlcErrorClassifier.errorHex(connectRes),
+ "error_category", PlcErrorClassifier.category(connectRes),
+ "error_text", S7Client.ErrorText(connectRes)
+ );
+ disconnectInternal(operationId, "CONNECT_FAILED");
+ throw new PlcCommunicationException(
+ "PLC连接失败,错误码:" + connectRes,
+ "CONNECT",
+ connectRes
+ );
}
+ DiagnosticLogger.log(
+ "plc_connect_success",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "duration_ms", SystemClock.elapsedRealtime() - connectStarted,
+ "connected", true,
+ "negotiated_pdu_length", client.PDULength()
+ );
+ return false;
}
+ private synchronized void disconnectInternal(long operationId, String reason) {
+ if (client == null) {
+ return;
+ }
+ boolean connectedBefore = client.Connected;
+ long started = SystemClock.elapsedRealtime();
+ client.Disconnect();
+ client = null;
+ DiagnosticLogger.log(
+ "plc_disconnect",
+ "operation_id", operationId,
+ "reason", reason,
+ "duration_ms", SystemClock.elapsedRealtime() - started,
+ "connected_before", connectedBefore,
+ "connected_after", false
+ );
+ }
+
+ private boolean isConnected() {
+ return client != null && client.Connected;
+ }
+
+ private static PlcCommunicationException communicationException(
+ String message,
+ String phase,
+ int errorCode
+ ) {
+ return new PlcCommunicationException(
+ message + ",错误码:" + S7Client.ErrorText(errorCode),
+ phase,
+ errorCode
+ );
+ }
+
+ private void logOperationStart(long operationId, String operationType, boolean connectedBefore) {
+ DiagnosticLogger.log(
+ "plc_operation_start",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "connected_before", connectedBefore
+ );
+ }
+
+ private void logOperationSuccess(
+ long operationId,
+ String operationType,
+ long operationStarted,
+ boolean connectedBefore,
+ boolean reusedConnection
+ ) {
+ DiagnosticLogger.log(
+ "plc_operation_success",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "duration_ms", SystemClock.elapsedRealtime() - operationStarted,
+ "connected_before", connectedBefore,
+ "connected_after", isConnected(),
+ "reused_connection", reusedConnection
+ );
+ }
+
+ private void logOperationFailure(
+ long operationId,
+ String operationType,
+ long operationStarted,
+ boolean connectedBefore,
+ boolean reusedConnection,
+ PlcCommunicationException exception
+ ) {
+ Integer errorCode = exception.getErrorCode();
+ Throwable cause = exception.getCause();
+ DiagnosticLogger.log(
+ "plc_operation_failure",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "duration_ms", SystemClock.elapsedRealtime() - operationStarted,
+ "connected_before", connectedBefore,
+ "connected_after", isConnected(),
+ "reused_connection", reusedConnection,
+ "failure_phase", exception.getPhase(),
+ "error_code", errorCode,
+ "error_hex", errorCode == null ? null : PlcErrorClassifier.errorHex(errorCode),
+ "error_category", errorCode == null
+ ? "JAVA_EXCEPTION"
+ : PlcErrorClassifier.category(errorCode),
+ "error_text", exception.getMessage(),
+ "exception_type", cause == null ? null : cause.getClass().getName(),
+ "exception_message", cause == null ? null : cause.getMessage()
+ );
+ }
+
+ private void logIoResult(
+ long operationId,
+ String operationType,
+ String phase,
+ long ioStarted,
+ int errorCode
+ ) {
+ boolean success = errorCode == 0;
+ DiagnosticLogger.log(
+ "plc_io_result",
+ "operation_id", operationId,
+ "operation_type", operationType,
+ "phase", phase,
+ "duration_ms", SystemClock.elapsedRealtime() - ioStarted,
+ "success", success,
+ "error_code", success ? null : errorCode,
+ "error_hex", success ? null : PlcErrorClassifier.errorHex(errorCode),
+ "error_category", success ? null : PlcErrorClassifier.category(errorCode),
+ "error_text", success ? null : S7Client.ErrorText(errorCode)
+ );
+ }
+
+ /**
+ * 将 short 整数编码为大端的 2 字节(S7 Word)格式。
+ */
static byte[] encodeWord(short value) {
byte[] data = new byte[PlcConfig.WORD_BYTE_LENGTH];
- // S7 的 Word/Int 都是 2 字节大端格式;当前值范围都在正数区间内。
S7.SetShortAt(data, 0, value);
return data;
}
+ /**
+ * 将从 S7 读取的 2 字节大端数据解码为 short 整数。
+ */
static short decodeWord(byte[] data, int offset) {
return (short) S7.GetShortAt(data, offset);
}
diff --git a/app/src/main/java/com/example/pdaplccontrol/PdaPlcApplication.java b/app/src/main/java/com/example/pdaplccontrol/PdaPlcApplication.java
new file mode 100644
index 0000000..d9ac5d0
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/PdaPlcApplication.java
@@ -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";
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcCommunicationException.java b/app/src/main/java/com/example/pdaplccontrol/PlcCommunicationException.java
index 45e2219..6dfd8e7 100644
--- a/app/src/main/java/com/example/pdaplccontrol/PlcCommunicationException.java
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcCommunicationException.java
@@ -2,11 +2,37 @@ package com.example.pdaplccontrol;
public class PlcCommunicationException extends Exception {
+ private final String phase;
+ private final Integer errorCode;
+
public PlcCommunicationException(String message) {
- super(message);
+ this(message, "UNKNOWN", null, null);
}
public PlcCommunicationException(String message, Throwable cause) {
+ this(message, "UNKNOWN", null, cause);
+ }
+
+ public PlcCommunicationException(String message, String phase, Integer errorCode) {
+ this(message, phase, errorCode, null);
+ }
+
+ public PlcCommunicationException(
+ String message,
+ String phase,
+ Integer errorCode,
+ Throwable cause
+ ) {
super(message, cause);
+ this.phase = phase;
+ this.errorCode = errorCode;
+ }
+
+ public String getPhase() {
+ return phase;
+ }
+
+ public Integer getErrorCode() {
+ return errorCode;
}
}
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcConfig.java b/app/src/main/java/com/example/pdaplccontrol/PlcConfig.java
index 40521d5..d1252db 100644
--- a/app/src/main/java/com/example/pdaplccontrol/PlcConfig.java
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcConfig.java
@@ -12,7 +12,25 @@ final class PlcConfig {
static final int WORD_BYTE_LENGTH = 2;
static final int MONITOR_START_OFFSET = LINE_NUMBER_OFFSET;
static final int MONITOR_BYTE_LENGTH = WEIGHT_KG_OFFSET + WORD_BYTE_LENGTH - MONITOR_START_OFFSET;
- static final int MONITOR_INTERVAL_MILLIS = 2000;
+
+ // 监控常规轮询间隔(5000毫秒)。设为 5 秒是为了降低 PDA 与 PLC 的网络交互负荷。
+ static final int MONITOR_INTERVAL_MILLIS = 5000;
+
+ // 监控发生第一次读取失败时的退避重试延迟(2000毫秒)。初次失败快速重试,以规避偶发抖动。
+ static final int MONITOR_FIRST_FAILURE_RETRY_MILLIS = 2000;
+
+ // 监控发生第二次读取失败时的退避重试延迟(3000毫秒)。
+ static final int MONITOR_SECOND_FAILURE_RETRY_MILLIS = 3000;
+
+ // 监控发生多次连续失败时的最大退避延迟(5000毫秒)。防止网络彻底断开时高频请求占用设备连接资源。
+ static final int MONITOR_MAX_FAILURE_RETRY_MILLIS = 5000;
+
+ // 判定通讯异常的连续失败次数阈值。达到 3 次失败才报错,避免网络偶发丢包导致界面闪红。
+ static final int MONITOR_FAILURE_THRESHOLD = 3;
+
+ // 判定通讯数据失效的兜底时间阈值(15秒)。用于防止由于其他原因导致的监控界面数据僵死、长期未刷新的情况。
+ static final long MONITOR_STALE_THRESHOLD_MILLIS = 15000L;
+
static final int MIN_LINE_NUMBER = 10;
static final int MAX_LINE_NUMBER = 18;
static final int MIN_WEIGHT_KG = 0;
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcErrorClassifier.java b/app/src/main/java/com/example/pdaplccontrol/PlcErrorClassifier.java
new file mode 100644
index 0000000..774b13b
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcErrorClassifier.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcManager.java b/app/src/main/java/com/example/pdaplccontrol/PlcManager.java
index f56f2e9..8d55e14 100644
--- a/app/src/main/java/com/example/pdaplccontrol/PlcManager.java
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcManager.java
@@ -6,11 +6,23 @@ import android.os.Looper;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
+/**
+ * PLC 业务控制管理器(单例)。
+ * 业务意图:
+ * 1. 作为业务层与通讯驱动层之间的桥梁,管理异步任务分发与回调通知;
+ * 2. 核心避坑设计:使用单线程池(SingleThreadExecutor)作为所有的指令执行器,
+ * 确保“下发线体号”、“下发重量”、“读取当前值”以及“断开连接”在同一个线程中串行排队执行。
+ * 这天然消除了多线程并发访问底层 Moka7 S7 客户端连接所带来的竞态条件与线程安全隐患。
+ */
public class PlcManager {
private static PlcManager instance;
private final PlcValueSender plcValueSender;
+
+ // 串行指令执行器(单线程池),所有的 PLC 通讯网络 IO 操作都在此执行
private final Executor commandExecutor;
+
+ // 主线程回调执行器,用于将结果推送回 UI 线程
private final Executor callbackExecutor;
private PlcManager() {
@@ -40,6 +52,9 @@ public class PlcManager {
void onError(String error);
}
+ /**
+ * 异步向 PLC 写入线体号。
+ */
public void sendLineNumberAsync(final short lineNumber, final PlcCallback callback) {
if (!isValidLineNumber(lineNumber)) {
notifyError(callback, "线体号无效,只允许 10-18");
@@ -59,6 +74,9 @@ public class PlcManager {
});
}
+ /**
+ * 异步向 PLC 写入重量。
+ */
public void sendWeightKgAsync(final short weightKg, final PlcCallback callback) {
if (!isValidWeightKg(weightKg)) {
notifyError(callback, "重量范围为 0-30kg");
@@ -79,6 +97,9 @@ public class PlcManager {
});
}
+ /**
+ * 异步读取 PLC 当前状态值(监控数据)。
+ */
public void readCurrentValuesAsync(final PlcCurrentValuesCallback callback) {
commandExecutor.execute(new Runnable() {
@Override
@@ -92,6 +113,20 @@ public class PlcManager {
});
}
+ /**
+ * 异步断开并释放 PLC 连接。
+ * 业务意图:断连操作同样包含网络 IO,必须提交至单线程 Executor 中执行,避免阻塞主线程;
+ * 同时也保证了断连操作与之前的读写操作在时序上的严格串行,防止因并发导致连接在读写进行中被突发销毁。
+ */
+ public void disconnectAsync() {
+ commandExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ plcValueSender.disconnect();
+ }
+ });
+ }
+
static boolean isValidLineNumber(short lineNumber) {
return lineNumber >= PlcConfig.MIN_LINE_NUMBER && lineNumber <= PlcConfig.MAX_LINE_NUMBER;
}
@@ -148,6 +183,9 @@ public class PlcManager {
});
}
+ /**
+ * Android 主线程执行器。
+ */
private static class MainThreadExecutor implements Executor {
private final Handler mainHandler = new Handler(Looper.getMainLooper());
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcMonitorPolicy.java b/app/src/main/java/com/example/pdaplccontrol/PlcMonitorPolicy.java
new file mode 100644
index 0000000..9371c8d
--- /dev/null
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcMonitorPolicy.java
@@ -0,0 +1,49 @@
+package com.example.pdaplccontrol;
+
+/**
+ * PLC 监控轮询与异常判定策略策略类。
+ * 解耦了具体 UI 渲染逻辑与底层的网络异常判定算法,便于后续策略调整。
+ */
+final class PlcMonitorPolicy {
+
+ private PlcMonitorPolicy() {
+ }
+
+ /**
+ * 根据连续失败次数计算下一次轮询的延迟时间(毫秒)。
+ * 业务意图:
+ * 1. 正常状态下使用较长间隔(5秒)进行轮询,以减轻 PLC 通讯负荷;
+ * 2. 初次或第二次失败时(瞬抖可能),采用较短的重试间隔(2秒/3秒)快速尝试恢复连接;
+ * 3. 连续失败多次后,恢复到最大时间间隔(5秒),避免因设备彻底失联而引发的高频无效重试,保护 PDA 功耗和通道带宽。
+ */
+ static int nextDelayMillis(int consecutiveFailures) {
+ if (consecutiveFailures <= 0) {
+ return PlcConfig.MONITOR_INTERVAL_MILLIS;
+ }
+ if (consecutiveFailures == 1) {
+ return PlcConfig.MONITOR_FIRST_FAILURE_RETRY_MILLIS;
+ }
+ if (consecutiveFailures == 2) {
+ return PlcConfig.MONITOR_SECOND_FAILURE_RETRY_MILLIS;
+ }
+ return PlcConfig.MONITOR_MAX_FAILURE_RETRY_MILLIS;
+ }
+
+ /**
+ * 判定当前 PLC 通讯状态是否属于“通讯异常”。
+ * 业务意图:
+ * 1. 容错防抖:单次或双次闪断不渲染为“通讯异常”,降低用户由于网络瞬抖产生的不必要焦虑;
+ * 2. 连续失败达到阈值(3次),或虽然未满3次但自上一次成功读取已过期超 15 秒时,判定为异常状态,提示用户检查网络或物理链路。
+ */
+ static boolean isCommunicationAbnormal(
+ int consecutiveFailures,
+ boolean hasSuccessfulRead,
+ long millisSinceLastSuccess
+ ) {
+ if (consecutiveFailures >= PlcConfig.MONITOR_FAILURE_THRESHOLD) {
+ return true;
+ }
+ // 即使连续失败次数未达阈值,若距离上一次成功读取的时间过长,亦判定为数据过期/异常,以防僵死状态悬挂
+ return hasSuccessfulRead && millisSinceLastSuccess >= PlcConfig.MONITOR_STALE_THRESHOLD_MILLIS;
+ }
+}
diff --git a/app/src/main/java/com/example/pdaplccontrol/PlcValueSender.java b/app/src/main/java/com/example/pdaplccontrol/PlcValueSender.java
index 21f301f..1d61437 100644
--- a/app/src/main/java/com/example/pdaplccontrol/PlcValueSender.java
+++ b/app/src/main/java/com/example/pdaplccontrol/PlcValueSender.java
@@ -16,4 +16,11 @@ public interface PlcValueSender {
* 周期读取 PLC 当前值,用于界面监控显示。
*/
PlcCurrentValues readCurrentValues() throws PlcCommunicationException;
+
+ /**
+ * 主动断开并释放 PLC 物理连接。
+ * 业务意图:由于 PDA 移动端网络不稳定且存在锁屏、退后台等复杂生命周期,在监控停止时必须主动释放连接,
+ * 防止因 TCP 假死或连接泄露导致 PLC 本地有限的连接通道(通常 PLC 连接数上限极低,如 8 个)被占满。
+ */
+ void disconnect();
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c3ed90a..caa47ac 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -44,8 +44,8 @@
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:gravity="center"
- android:text="正在读取PLC..."
- android:textColor="@color/monitor_status_pending"
+ android:text="监控中"
+ android:textColor="@color/monitor_status_normal"
android:textSize="16sp" />
#FFFFFFFF
#2E7D32
#C62828
- #616161
diff --git a/app/src/test/java/com/example/pdaplccontrol/DiagnosticSupportTest.java b/app/src/test/java/com/example/pdaplccontrol/DiagnosticSupportTest.java
new file mode 100644
index 0000000..cbc295a
--- /dev/null
+++ b/app/src/test/java/com/example/pdaplccontrol/DiagnosticSupportTest.java
@@ -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 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));
+ }
+}
diff --git a/app/src/test/java/com/example/pdaplccontrol/ExampleUnitTest.java b/app/src/test/java/com/example/pdaplccontrol/ExampleUnitTest.java
index 4a64865..b5f6dac 100644
--- a/app/src/test/java/com/example/pdaplccontrol/ExampleUnitTest.java
+++ b/app/src/test/java/com/example/pdaplccontrol/ExampleUnitTest.java
@@ -43,6 +43,39 @@ public class ExampleUnitTest {
assertEquals("12", WeightInputParser.kgToDisplayText((short) 12));
}
+ @Test
+ public void monitorPolicy_successUsesNormalInterval() {
+ assertEquals(PlcConfig.MONITOR_INTERVAL_MILLIS, PlcMonitorPolicy.nextDelayMillis(0));
+ }
+
+ @Test
+ public void monitorPolicy_failuresUseLightweightBackoff() {
+ assertEquals(PlcConfig.MONITOR_FIRST_FAILURE_RETRY_MILLIS, PlcMonitorPolicy.nextDelayMillis(1));
+ assertEquals(PlcConfig.MONITOR_SECOND_FAILURE_RETRY_MILLIS, PlcMonitorPolicy.nextDelayMillis(2));
+ assertEquals(PlcConfig.MONITOR_MAX_FAILURE_RETRY_MILLIS, PlcMonitorPolicy.nextDelayMillis(3));
+ assertEquals(PlcConfig.MONITOR_MAX_FAILURE_RETRY_MILLIS, PlcMonitorPolicy.nextDelayMillis(4));
+ }
+
+ @Test
+ public void monitorPolicy_firstTwoFailuresRemainNormal() {
+ assertFalse(PlcMonitorPolicy.isCommunicationAbnormal(1, true, 1000L));
+ assertFalse(PlcMonitorPolicy.isCommunicationAbnormal(2, true, 1000L));
+ }
+
+ @Test
+ public void monitorPolicy_thirdFailureIsAbnormal() {
+ assertTrue(PlcMonitorPolicy.isCommunicationAbnormal(3, true, 1000L));
+ }
+
+ @Test
+ public void monitorPolicy_staleThresholdIsAbnormalAfterSuccess() {
+ assertTrue(PlcMonitorPolicy.isCommunicationAbnormal(
+ 1,
+ true,
+ PlcConfig.MONITOR_STALE_THRESHOLD_MILLIS
+ ));
+ }
+
private void assertInvalidWeight(String input) {
try {
WeightInputParser.parseKg(input);
diff --git a/app/src/test/java/com/example/pdaplccontrol/PlcManagerTest.java b/app/src/test/java/com/example/pdaplccontrol/PlcManagerTest.java
index 1a06fec..ad9cd63 100644
--- a/app/src/test/java/com/example/pdaplccontrol/PlcManagerTest.java
+++ b/app/src/test/java/com/example/pdaplccontrol/PlcManagerTest.java
@@ -82,6 +82,16 @@ public class PlcManagerTest {
assertNull(callback.errorMessage);
}
+ @Test
+ public void disconnectAsync_delegatesDisconnect() {
+ RecordingPlcValueSender sender = new RecordingPlcValueSender();
+ PlcManager manager = new PlcManager(sender, new DirectExecutor(), new DirectExecutor());
+
+ manager.disconnectAsync();
+
+ assertTrue(sender.disconnected);
+ }
+
private static class DirectExecutor implements Executor {
@Override
public void execute(Runnable command) {
@@ -93,6 +103,7 @@ public class PlcManagerTest {
private Short lineNumber;
private Short weightKg;
private PlcCurrentValues currentValues = new PlcCurrentValues((short) 10, (short) 0);
+ private boolean disconnected;
@Override
public void sendLineNumber(short lineNumber) {
@@ -108,6 +119,11 @@ public class PlcManagerTest {
public PlcCurrentValues readCurrentValues() {
return currentValues;
}
+
+ @Override
+ public void disconnect() {
+ disconnected = true;
+ }
}
private static class RecordingCallback implements PlcManager.PlcCallback {