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 {