The file /Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/java/com/qsjnic/demo/radio/MainActivity.java has been updated successfully. (file state is current in your context — no need to Read it back)

package com.qsjnic.demo.radio;

import android.app.Activity;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;

import com.qsjnic.mcu.contract.IMcuFrameListener;
import com.qsjnic.mcu.contract.RadioState;
import com.qsjnic.mcu.sdk.KeyListener;
import com.qsjnic.mcu.sdk.McuClient;
import com.qsjnic.mcu.sdk.McuRadioClient;
import com.qsjnic.mcu.sdk.RadioListener;
import com.qsjnic.mcu.sdk.VehicleListener;

/**
 * Radio Demo with FM Tuner audio passthrough and comprehensive MCU frame monitor.
 * Covers: Radio (FREQ/PS_PTY/RT), SWC Key events, Vehicle Speed, raw frame hex.
 */
public class MainActivity extends Activity {

    private static final String TAG = "RadioDemo";
    private static final int PERMISSION_REQ = 100;

    private final Handler ui = new Handler(Looper.getMainLooper());

    // Data panel
    private TextView tvBand, tvFreq, tvFreqMhz, tvSignal, tvStereo, tvSeek, tvAudioStatus;

    // Frame Monitor — radio parsed
    private TextView tvValid, tvPty, tvTa, tvTp, tvPs, tvRt, tvWorkStatus, tvTaPtyTips;
    private TextView tvFreqHex, tvPsPtyHex, tvRtHex, tvKeyHex, tvSpeedHex;
    private String lastPs = "";
    private String lastRt = "";

    // SWC & Speed
    private TextView tvLastKey, tvSpeed;

    // Control buttons
    private TextView tvStatus;
    private Button btnFm, btnAm, btn875, btn900, btnSeekFw, btnSeekStop, btnPlay875;
    private Button btnAudioStart, btnAudioStop;

    // ----- Pajero SWC key name mapping (MCU → Android) -----
    private static String swcName(int keyValue) {
        switch (keyValue) {
            case 0x00: return "IDLE";
            case 0x03: return "SPEECH";
            case 0x04: return "MUSIC";
            case 0x08: return "VOL-";
            case 0x09: return "VOL+";
            case 0x0E: return "PREV";
            case 0x0F: return "NEXT";
            case 0x18: return "ANSWER";
            case 0x19: return "REJECT";
            case 0x25: return "CAMERA";
            default:   return String.format("0x%02X", keyValue);
        }
    }

    private static String swcStatus(int status) {
        switch (status) {
            case 0x00: return "IDLE";
            case 0x01: return "TRIGGER";
            case 0x02: return "STUCK";
            case 0x03: return "ENC_L";
            case 0x04: return "ENC_R";
            default:   return String.format("0x%02X", status);
        }
    }

    // ----- Radio Listener -----
    private final RadioListener radioListener = new RadioListener() {
        @Override public void onFreqChanged(RadioState s) {
            ui.post(() -> updateRadio(s));
        }
        @Override public void onPsPty(int pi, int pty, String ps) {
            Log.i(TAG, "PS/PTY pi=" + pi + " pty=" + pty + " ps=" + ps);
            ui.post(() -> {
                lastPs = (ps != null) ? ps : "";
                tvPs.setText("PS: " + (lastPs.isEmpty() ? "(empty)" : lastPs));
                tvPs.setTextColor(lastPs.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
                tvPty.setText("PTY: " + pty);
            });
        }
        @Override public void onRadioText(String rt) {
            Log.i(TAG, "RT=" + rt);
            ui.post(() -> {
                lastRt = (rt != null) ? rt : "";
                String display = lastRt.isEmpty() ? "(empty)" : (lastRt.length() > 80 ? lastRt.substring(0, 80) + "..." : lastRt);
                tvRt.setText("RT: " + display);
                tvRt.setTextColor(lastRt.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
            });
        }
        @Override public void onWorkStatus(int status) {
            Log.i(TAG, "WorkStatus=" + status);
            ui.post(() -> {
                tvWorkStatus.setText("Work: " + status + (status == 0 ? " (OK)" : " (ERR)"));
                tvWorkStatus.setTextColor(status == 0 ? Color.parseColor("#4CAF50") : Color.parseColor("#F44336"));
            });
        }
        @Override public void onTaPtyTips(int mode, int value) {
            Log.i(TAG, "TA/PTY Tips mode=" + mode + " value=" + value);
            ui.post(() -> {
                String modeName = mode == 0 ? "TA" : (mode == 1 ? "PTY" : "?");
                String valName = value == 0 ? "Clear" : (value == 1 ? "NoResult" : (value == 2 ? "Searching" : "?"));
                tvTaPtyTips.setText("Tips: " + modeName + " / " + valName);
            });
        }
    };

    // ----- Key Listener (M2A — MCU → Android SWC events) -----
    private final KeyListener keyListener = new KeyListener() {
        @Override public void onKeyEvent(int keyValue, int keyStatus) {
            Log.i(TAG, "SWC keyValue=0x" + Integer.toHexString(keyValue) + " status=" + keyStatus);
            ui.post(() -> {
                String name = swcName(keyValue);
                String state = swcStatus(keyStatus);
                tvLastKey.setText("Key: " + name + " " + state);
                tvLastKey.setTextColor(keyStatus == 0x01 ? Color.parseColor("#FF5722") : Color.parseColor("#4CAF50"));
            });
        }
    };

    // ----- Vehicle Listener (speed) -----
    private final VehicleListener vehicleListener = new VehicleListener() {
        @Override public void onCarSpeed(int speedKmh) {
            Log.i(TAG, "Speed: " + speedKmh + " km/h");
            ui.post(() -> {
                tvSpeed.setText("Speed: " + speedKmh + " km/h");
                tvSpeed.setTextColor(speedKmh > 0 ? Color.parseColor("#1976D2") : Color.parseColor("#999999"));
            });
        }
    };

    // ----- Raw Frame Listener for hex dump (RADIO + KEY + SPEED) -----
    private final IMcuFrameListener.Stub rawFrameListener = new IMcuFrameListener.Stub() {
        @Override
        public void onMcuFrame(int frameType, int msgType, int cmd, byte[] body) {
            String hex = bytesToHex(body);
            ui.post(() -> {
                switch (msgType) {
                    case 0x01: // PUBLIC — speed, etc.
                        if (cmd == 0x0A) { // CAR_SPEED
                            tvSpeedHex.setText("SPEED (" + body.length + "B): " + hex);
                        }
                        break;
                    case 0x02: // KEY
                        if (cmd == 0x01) { // M2A_KEY
                            tvKeyHex.setText("KEY (" + body.length + "B): " + hex);
                        }
                        break;
                    case 0x05: // RADIO
                        switch (cmd) {
                            case 0x01:
                                tvFreqHex.setText("FREQ (" + body.length + "B): " + hex);
                                break;
                            case 0x02:
                                tvPsPtyHex.setText("PS/PTY (" + body.length + "B): " + hex);
                                break;
                            case 0x03:
                                String rtHex = body.length > 32
                                        ? bytesToHex(body, 32) + " ..."
                                        : hex;
                                tvRtHex.setText("RT (" + body.length + "B): " + rtHex);
                                break;
                        }
                        break;
                }
            });
        }
        @Override public void onVehicleStateChanged(com.qsjnic.mcu.contract.VehicleSnapshot s) {}
        @Override public void onRadioStateChanged(RadioState s) {}
        @Override public void onUpgradeProgress(com.qsjnic.mcu.contract.UpgradeProgress p) {}
    };

    // ----- Audio passthrough -----
    private AudioRecord audioRecord;
    private AudioTrack audioTrack;
    private Thread audioThread;
    private volatile boolean audioRunning = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScrollView scroll = new ScrollView(this);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(32, 32, 32, 32);
        root.setBackgroundColor(Color.parseColor("#F5F5F5"));

        // ===== Title =====
        TextView title = new TextView(this);
        title.setText("MCU Monitor");
        title.setTextSize(24f);
        title.setTypeface(null, Typeface.BOLD);
        title.setTextColor(Color.parseColor("#333333"));
        title.setPadding(0, 0, 0, 16);
        root.addView(title);

        tvStatus = new TextView(this);
        tvStatus.setText("Service: connecting");
        tvStatus.setTextSize(14f);
        tvStatus.setTextColor(Color.parseColor("#555555"));
        tvStatus.setPadding(0, 0, 0, 16);
        root.addView(tvStatus);

        // ===== Frequency Display Card =====
        LinearLayout freqCard = card(root);
        tvBand = new TextView(this);
        tvBand.setText("--");
        tvBand.setTextSize(16f);
        tvBand.setTextColor(Color.parseColor("#888888"));
        tvBand.setGravity(Gravity.CENTER);
        freqCard.addView(tvBand);

        tvFreq = new TextView(this);
        tvFreq.setText("----");
        tvFreq.setTextSize(56f);
        tvFreq.setTypeface(null, Typeface.BOLD);
        tvFreq.setTextColor(Color.parseColor("#1976D2"));
        tvFreq.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreq);

        tvFreqMhz = new TextView(this);
        tvFreqMhz.setText("MHz");
        tvFreqMhz.setTextSize(14f);
        tvFreqMhz.setTextColor(Color.parseColor("#888888"));
        tvFreqMhz.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreqMhz);

        LinearLayout detailRow = new LinearLayout(this);
        detailRow.setOrientation(LinearLayout.HORIZONTAL);
        detailRow.setGravity(Gravity.CENTER);
        detailRow.setPadding(0, 16, 0, 0);
        freqCard.addView(detailRow);

        tvSignal = chip(detailRow, "Signal: --");
        tvStereo = chip(detailRow, "Stereo: --");
        tvSeek   = chip(detailRow, "Seek: --");

        // ===== SWC & Speed Card =====
        root.addView(sectionTitle("SWC Keys & Speed"));
        LinearLayout swcCard = card(root);

        tvLastKey = monText(swcCard, "Key: --");
        tvLastKey.setTextSize(16f);
        tvLastKey.setTypeface(null, Typeface.BOLD);

        tvSpeed = monText(swcCard, "Speed: -- km/h");
        tvSpeed.setTextSize(16f);
        tvSpeed.setTypeface(null, Typeface.BOLD);

        // Key hex
        tvKeyHex = hexText(swcCard);
        tvSpeedHex = hexText(swcCard);

        // ===== Frame Monitor — Radio =====
        root.addView(sectionTitle("Radio Monitor"));
        LinearLayout monCard = card(root);

        // Row 1: valid + pty + TA + TP
        LinearLayout monRow1 = new LinearLayout(this);
        monRow1.setOrientation(LinearLayout.HORIZONTAL);
        monRow1.setGravity(Gravity.CENTER_VERTICAL);
        monRow1.setPadding(0, 0, 0, 8);
        monCard.addView(monRow1);

        tvValid = chip(monRow1, "valid: --");
        tvPty   = chip(monRow1, "PTY: --");
        tvTa    = chip(monRow1, "TA: --");
        tvTp    = chip(monRow1, "TP: --");

        // Row 2: work status + TA/PTY tips
        LinearLayout monRow2 = new LinearLayout(this);
        monRow2.setOrientation(LinearLayout.HORIZONTAL);
        monRow2.setGravity(Gravity.CENTER_VERTICAL);
        monRow2.setPadding(0, 0, 0, 4);
        monCard.addView(monRow2);

        tvWorkStatus = monText(monRow2, "Work: --");
        tvTaPtyTips  = monText(monRow2, "Tips: --");

        // Row 3: PS station name
        tvPs = monText(monCard, "PS: --");
        tvPs.setPadding(0, 4, 0, 2);

        // Row 4: RT radio text
        tvRt = monText(monCard, "RT: --");
        tvRt.setPadding(0, 2, 0, 8);

        // Divider
        View divider = new View(this);
        divider.setBackgroundColor(Color.parseColor("#E0E0E0"));
        LinearLayout.LayoutParams dlp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, 1);
        dlp.setMargins(0, 0, 0, 8);
        divider.setLayoutParams(dlp);
        monCard.addView(divider);

        // Hex section
        TextView hexLabel = new TextView(this);
        hexLabel.setText("Raw Frame Hex");
        hexLabel.setTextSize(12f);
        hexLabel.setTypeface(null, Typeface.BOLD);
        hexLabel.setTextColor(Color.parseColor("#888888"));
        hexLabel.setPadding(0, 0, 0, 4);
        monCard.addView(hexLabel);

        tvFreqHex = hexText(monCard);
        tvPsPtyHex = hexText(monCard);
        tvRtHex = hexText(monCard);

        // ===== Tuner Audio =====
        root.addView(sectionTitle("Tuner Audio"));
        LinearLayout audioCard = card(root);
        tvAudioStatus = new TextView(this);
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextSize(14f);
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        tvAudioStatus.setPadding(0, 8, 0, 8);
        audioCard.addView(tvAudioStatus);

        LinearLayout audioRow = buttonRow(audioCard);
        btnAudioStart = btn(audioRow, "Start Audio", v -> startTunerAudio());
        btnAudioStop  = btn(audioRow, "Stop Audio",  v -> stopTunerAudio());

        // ===== Band Selection =====
        root.addView(sectionTitle("Band"));
        LinearLayout bandCard = card(root);
        LinearLayout bandRow = buttonRow(bandCard);
        btnFm = btn(bandRow, "FM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_FM));
        btnAm = btn(bandRow, "AM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_AM));

        // ===== Frequency Presets =====
        root.addView(sectionTitle("Frequency"));
        LinearLayout freqCtrlCard = card(root);
        LinearLayout freqRow1 = buttonRow(freqCtrlCard);
        btn875 = btn(freqRow1, "87.5 MHz", v -> McuClient.getInstance().radio().setFrequency(8750));
        btn900 = btn(freqRow1, "90.0 MHz", v -> McuClient.getInstance().radio().setFrequency(9000));

        // ===== Seek Control =====
        root.addView(sectionTitle("Seek"));
        LinearLayout seekCard = card(root);
        LinearLayout seekRow = buttonRow(seekCard);
        btnSeekFw   = btn(seekRow, "Forward",  v -> McuClient.getInstance().radio().seekForward());
        btnSeekStop = btn(seekRow, "Stop",     v -> McuClient.getInstance().radio().seekStop());

        // ===== Other =====
        root.addView(sectionTitle("Other"));
        LinearLayout otherCard = card(root);
        LinearLayout otherRow = buttonRow(otherCard);
        btnPlay875 = btn(otherRow, "Play 90.0", v -> playFm900());
        btn(otherRow, "Ant ON",  v -> McuClient.getInstance().radio().setAntPower(true));
        btn(otherRow, "Init Range", v -> McuClient.getInstance().radio().setFreqRange(0, 522, 1670, 8750, 10800, 9, 1));

        scroll.addView(root);
        setContentView(scroll);

        McuClient.getInstance().init(getApplicationContext());
        McuClient.getInstance().addReadyListener(() -> ui.post(() -> {
            tvStatus.setText("Service: connected, linkAlive=" + McuClient.getInstance().isLinkAlive());
            playFm900();
            ui.postDelayed(this::startTunerAudio, 1000);
            McuClient.getInstance().addFrameListener(rawFrameListener);
        }));
        McuClient.getInstance().radio().addListener(radioListener);
        McuClient.getInstance().key().addListener(keyListener);
        McuClient.getInstance().vehicle().addListener(vehicleListener);

        // Request audio permission
        if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[]{android.Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ);
        }
    }

    // ---------- Audio Passthrough ----------

    private void startTunerAudio() {
        if (audioRunning) return;

        AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
        AudioDeviceInfo tunerDevice = null;
        for (AudioDeviceInfo dev : am.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
            Log.i(TAG, "Input device: type=" + dev.getType() + " name=" + dev.getProductName());
            if (dev.getType() == AudioDeviceInfo.TYPE_FM_TUNER) {
                tunerDevice = dev;
                break;
            }
        }

        if (tunerDevice == null) {
            tvAudioStatus.setText("Audio: FM Tuner device NOT FOUND");
            Log.e(TAG, "TYPE_FM_TUNER not found in input devices");
            return;
        }

        tvAudioStatus.setText("Audio: starting...");
        Log.i(TAG, "Found FM Tuner: " + tunerDevice.getProductName());

        final int sampleRate = 48000;
        final int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
        final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
        final int minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
        final int bufferSize = Math.max(minBufSize, 16384);
        final int AUDIO_SOURCE_FM_TUNER = 1998;

        try {
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
                    sampleRate, channelConfig, audioFormat, bufferSize);

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && audioRecord.setPreferredDevice(tunerDevice)) {
                Log.i(TAG, "setPreferredDevice(tuner) succeeded");
            }

            audioTrack = new AudioTrack.Builder()
                    .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_MEDIA)
                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                            .build())
                    .setAudioFormat(new AudioFormat.Builder()
                            .setSampleRate(sampleRate)
                            .setEncoding(audioFormat)
                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                            .build())
                    .setBufferSizeInBytes(bufferSize)
                    .setTransferMode(AudioTrack.MODE_STREAM)
                    .build();

            audioRecord.startRecording();
            audioTrack.play();
            audioRunning = true;

            final int playbackBufSize = bufferSize;
            audioThread = new Thread(() -> {
                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                byte[] buf = new byte[playbackBufSize];
                int totalRead = 0;
                long lastLog = System.currentTimeMillis();
                while (audioRunning) {
                    int read = audioRecord.read(buf, 0, buf.length);
                    if (read > 0) {
                        totalRead += read;
                        audioTrack.write(buf, 0, read);
                        long now = System.currentTimeMillis();
                        if (now - lastLog > 3000) {
                            short sample = (short) ((buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8));
                            Log.i(TAG, "Audio passthrough: read=" + read
                                    + " total=" + totalRead
                                    + " sample=" + sample
                                    + " trackState=" + audioTrack.getPlayState());
                            lastLog = now;
                        }
                    } else if (read < 0) {
                        Log.e(TAG, "AudioRecord read error: " + read);
                        break;
                    }
                }
            }, "TunerAudio");
            audioThread.setPriority(Thread.MAX_PRIORITY);
            audioThread.start();

            tvAudioStatus.setText("Audio: PLAYING (" + tunerDevice.getProductName() + ")");
            tvAudioStatus.setTextColor(Color.parseColor("#4CAF50"));

        } catch (Exception e) {
            Log.e(TAG, "startTunerAudio failed", e);
            tvAudioStatus.setText("Audio: FAILED - " + e.getMessage());
            tvAudioStatus.setTextColor(Color.parseColor("#F44336"));
            stopTunerAudio();
        }
    }

    private void stopTunerAudio() {
        audioRunning = false;
        if (audioThread != null) {
            try { audioThread.join(500); } catch (InterruptedException ignored) {}
            audioThread = null;
        }
        if (audioRecord != null) {
            try { audioRecord.stop(); } catch (Exception ignored) {}
            try { audioRecord.release(); } catch (Exception ignored) {}
            audioRecord = null;
        }
        if (audioTrack != null) {
            try { audioTrack.stop(); } catch (Exception ignored) {}
            try { audioTrack.release(); } catch (Exception ignored) {}
            audioTrack = null;
        }
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        Log.i(TAG, "Tuner audio stopped");
    }

    // ---------- UI helpers ----------

    private TextView sectionTitle(String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(14f);
        t.setTypeface(null, Typeface.BOLD);
        t.setTextColor(Color.parseColor("#666666"));
        t.setPadding(8, 24, 0, 8);
        return t;
    }

    private LinearLayout card(LinearLayout parent) {
        LinearLayout card = new LinearLayout(this);
        card.setOrientation(LinearLayout.VERTICAL);
        card.setPadding(24, 24, 24, 24);
        card.setBackgroundColor(Color.WHITE);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.WHITE);
        bg.setCornerRadius(16f);
        card.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(0, 0, 0, 16);
        card.setLayoutParams(lp);
        parent.addView(card);
        return card;
    }

    private TextView chip(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(12f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(16, 8, 16, 8);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.parseColor("#E8E8E8"));
        bg.setCornerRadius(20f);
        t.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(8, 0, 8, 0);
        t.setLayoutParams(lp);
        parent.addView(t);
        return t;
    }

    private TextView monText(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(13f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(8, 2, 8, 2);
        parent.addView(t);
        return t;
    }

    private TextView hexText(LinearLayout parent) {
        TextView t = new TextView(this);
        t.setText("--");
        t.setTextSize(10f);
        t.setTypeface(Typeface.MONOSPACE);
        t.setTextColor(Color.parseColor("#888888"));
        t.setPadding(0, 2, 0, 2);
        parent.addView(t);
        return t;
    }

    private LinearLayout buttonRow(LinearLayout parent) {
        LinearLayout row = new LinearLayout(this);
        row.setOrientation(LinearLayout.HORIZONTAL);
        row.setPadding(0, 8, 0, 8);
        parent.addView(row);
        return row;
    }

    private Button btn(LinearLayout parent, String label, View.OnClickListener l) {
        Button b = new Button(this);
        b.setText(label);
        b.setOnClickListener(l);
        b.setAllCaps(false);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
        lp.setMargins(8, 0, 8, 0);
        b.setLayoutParams(lp);
        parent.addView(b);
        return b;
    }

    private static String bytesToHex(byte[] bytes) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        StringBuilder sb = new StringBuilder(bytes.length * 3);
        for (byte b : bytes) {
            sb.append(String.format("%02X ", b & 0xFF));
        }
        sb.setLength(sb.length() - 1);
        return sb.toString();
    }

    private static String bytesToHex(byte[] bytes, int limit) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        int n = Math.min(bytes.length, limit);
        StringBuilder sb = new StringBuilder(n * 3);
        for (int i = 0; i < n; i++) {
            sb.append(String.format("%02X ", bytes[i] & 0xFF));
        }
        sb.setLength(sb.length() - 1);
        if (bytes.length > limit) sb.append(" ...");
        return sb.toString();
    }

    private void playFm900() {
        McuRadioClient radio = McuClient.getInstance().radio();
        int rcBand = radio.setBand(McuRadioClient.BAND_FM);
        int rcRange = radio.setFreqRange(0, 522, 1670, 8750, 10800, 9, 1);
        int rcAnt = radio.setAntPower(true);
        int rcStereo = radio.setSwitch(McuRadioClient.SWITCH_STEREO, 1);
        int rcRds = radio.setSwitch(McuRadioClient.SWITCH_RDS, 1);
        int rcMute = radio.setRegMute(false);
        int rcFreq = radio.setFrequency(9344);
        int rcStop = radio.seekStop();
        String msg = "Play 93.4 rc band=" + rcBand
                + " range=" + rcRange
                + " ant=" + rcAnt
                + " stereo=" + rcStereo
                + " rds=" + rcRds
                + " freq=" + rcFreq
                + " stop=" + rcStop
                + " muteOff=" + rcMute;
        Log.i(TAG, msg);
        tvStatus.setText(msg);
    }

    private int lastSignal = 0;
    private int strongFreq = 0;

    private void updateRadio(RadioState s) {
        String bandName = s.band == 0 ? "FM" : (s.band == 1 ? "AM" : "?");
        tvBand.setText(bandName + " Radio");

        double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0;
        tvFreq.setText(String.format("%.1f", mhz));
        tvFreqMhz.setText(s.band == 0 ? "MHz" : "KHz");

        tvSignal.setText("Signal: " + (s.signal >= 0 ? s.signal : "--"));
        tvStereo.setText(s.flagStereo == 1 ? "Stereo" : "Mono");
        tvStereo.setTextColor(s.flagStereo == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        String[] seekNames = {"Stop", "AMS", "Fwd", "Bwd", "PTY", "TA"};
        tvSeek.setText("Seek: " + (s.seekMode >= 0 && s.seekMode < seekNames.length ? seekNames[s.seekMode] : "--"));

        // Frame Monitor updates
        if (s.valid == 1) {
            tvValid.setText("valid: LOCK");
            tvValid.setTextColor(Color.parseColor("#4CAF50"));
        } else if (s.valid == 0) {
            tvValid.setText("valid: 0");
            tvValid.setTextColor(Color.parseColor("#F44336"));
        } else {
            tvValid.setText("valid: --");
            tvValid.setTextColor(Color.parseColor("#999999"));
        }

        tvPty.setText("PTY: " + (s.pty >= 0 ? s.pty : "--"));

        tvTa.setText("TA: " + (s.flagTa == 1 ? "ON" : (s.flagTa == 0 ? "OFF" : "--")));
        tvTa.setTextColor(s.flagTa == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));
        tvTp.setText("TP: " + (s.flagTp == 1 ? "ON" : (s.flagTp == 0 ? "OFF" : "--")));
        tvTp.setTextColor(s.flagTp == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        if (s.signal > lastSignal) {
            lastSignal = s.signal;
            strongFreq = s.freq;
            Log.i(TAG, "*** STRONG SIGNAL: " + s.freq + " signal=" + s.signal + " ***");
        }
    }

    @Override
    protected void onDestroy() {
        stopTunerAudio();
        McuClient.getInstance().radio().removeListener(radioListener);
        McuClient.getInstance().key().removeListener(keyListener);
        McuClient.getInstance().vehicle().removeListener(vehicleListener);
        McuClient.getInstance().removeFrameListener(rawFrameListener);
        super.onDestroy();
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (!McuClient.getInstance().isReady()) {
            android.widget.Toast.makeText(this, "Service 未连接，5s后重试", android.widget.Toast.LENGTH_SHORT).show();
            new Handler(Looper.getMainLooper()).postDelayed(this::onResume, 5000);
        }
    }
}

import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
-import android.os.RemoteException;
import android.util.Log;
import android.view.Gravity;
import android.view.View;

- * Radio Demo with FM Tuner audio passthrough and MCU frame monitor.

+ * Radio Demo with FM Tuner audio passthrough and comprehensive MCU frame monitor.

+ * Covers: Radio (FREQ/PS_PTY/RT), SWC Key events, Vehicle Speed, raw frame hex.

public class MainActivity extends Activity {

-    private TextView tvFreqHex, tvPsPtyHex, tvRtHex;

+    private TextView tvFreqHex, tvPsPtyHex, tvRtHex, tvKeyHex, tvSpeedHex;

+    private TextView tvLastKey, tvSpeed;

private Button btnAudioStart, btnAudioStop;

-    private final RadioListener listener = new RadioListener() {

+    // ----- Pajero SWC key name mapping (MCU → Android) -----

+    private static String swcName(int keyValue) {

+            case 0x03: return "SPEECH";

+            case 0x18: return "ANSWER";

+            case 0x19: return "REJECT";

+            case 0x25: return "CAMERA";

+            default:   return String.format("0x%02X", keyValue);

+    private static String swcStatus(int status) {

+            case 0x01: return "TRIGGER";

+            default:   return String.format("0x%02X", status);

+    private final RadioListener radioListener = new RadioListener() {

@Override public void onFreqChanged(RadioState s) {

-                // Truncate display if too long

-    // ----- raw frame listener for hex dump -----

+    // ----- Key Listener (M2A — MCU → Android SWC events) -----

+    private final KeyListener keyListener = new KeyListener() {

+        @Override public void onKeyEvent(int keyValue, int keyStatus) {

+            Log.i(TAG, "SWC keyValue=0x" + Integer.toHexString(keyValue) + " status=" + keyStatus);

+                String name = swcName(keyValue);

+                String state = swcStatus(keyStatus);

+                tvLastKey.setText("Key: " + name + " " + state);

+                tvLastKey.setTextColor(keyStatus == 0x01 ? Color.parseColor("#FF5722") : Color.parseColor("#4CAF50"));

+    // ----- Vehicle Listener (speed) -----

+    private final VehicleListener vehicleListener = new VehicleListener() {

+        @Override public void onCarSpeed(int speedKmh) {

+            Log.i(TAG, "Speed: " + speedKmh + " km/h");

+                tvSpeed.setText("Speed: " + speedKmh + " km/h");

+                tvSpeed.setTextColor(speedKmh > 0 ? Color.parseColor("#1976D2") : Color.parseColor("#999999"));

+    // ----- Raw Frame Listener for hex dump (RADIO + KEY + SPEED) -----

-            // Only monitor RADIO module (0x05)

-            if (msgType != 0x05) return;

-                        tvFreqHex.setText("FREQ (" + body.length + "B): " + hex);

+                    case 0x01: // PUBLIC — speed, etc.

+                        if (cmd == 0x0A) { // CAR_SPEED

+                            tvSpeedHex.setText("SPEED (" + body.length + "B): " + hex);

-                    case 0x02: // PS_PTY

-                        tvPsPtyHex.setText("PS/PTY (" + body.length + "B): " + hex);

+                            tvKeyHex.setText("KEY (" + body.length + "B): " + hex);

-                        String rtHex = body.length > 32

-                                ? bytesToHex(body, 32) + " ..." // truncate display

-                        tvRtHex.setText("RT (" + body.length + "B): " + rtHex);

+                    case 0x05: // RADIO

+                                tvFreqHex.setText("FREQ (" + body.length + "B): " + hex);

+                                tvPsPtyHex.setText("PS/PTY (" + body.length + "B): " + hex);

+                                String rtHex = body.length > 32

+                                        ? bytesToHex(body, 32) + " ..."

+                                        : hex;

+                                tvRtHex.setText("RT (" + body.length + "B): " + rtHex);

-                    case 0x04: // TA_PTY_TIPS

-                    case 0x05: // WORK_STATUS

-        title.setText("Radio Tuner + Audio");

tvStereo = chip(detailRow, "Stereo: --");

-        // ===== Frame Monitor Card =====

-        root.addView(sectionTitle("Frame Monitor"));

+        root.addView(sectionTitle("SWC Keys & Speed"));

+        LinearLayout swcCard = card(root);

+        tvLastKey = monText(swcCard, "Key: --");

+        tvLastKey.setTypeface(null, Typeface.BOLD);

+        tvSpeed = monText(swcCard, "Speed: -- km/h");

+        tvSpeed.setTypeface(null, Typeface.BOLD);

+        // ===== Frame Monitor — Radio =====

+        root.addView(sectionTitle("Radio Monitor"));

-        // Row 1: valid + pty + TA + TP flags

+        // Row 1: valid + pty + TA + TP

LinearLayout monRow1 = new LinearLayout(this);

-            // Register raw frame listener for hex dump

-        McuClient.getInstance().radio().addListener(listener);

+        McuClient.getInstance().radio().addListener(radioListener);

+        McuClient.getInstance().key().addListener(keyListener);

+        McuClient.getInstance().vehicle().addListener(vehicleListener);

final int bufferSize = Math.max(minBufSize, 16384);

-        // AUDIO_SOURCE_FM_TUNER = 1998 (from system/audio.h)

-            // Use DEFAULT source + setPreferredDevice(tuner) approach

sampleRate, channelConfig, audioFormat, bufferSize);

-            // Try to set device on API 24+

-                        // Log first non-zero sample every 3 seconds

-    /** Simple text label for monitor rows. */

private TextView hexText(LinearLayout parent) {

-    /** Convert full byte array to space-separated hex. */

private static String bytesToHex(byte[] bytes) {

sb.append(String.format("%02X ", b & 0xFF));

-        sb.setLength(sb.length() - 1); // trim trailing space

-    /** Convert first N bytes to hex. */

-        int rcMute = radio.setRegMute(false);  // 先解除静音

+        int rcMute = radio.setRegMute(false);

String msg = "Play 93.4 rc band=" + rcBand

-        // ---- Frame Monitor updates ----

-        // valid bit (p[4] bit7) — the key firmware issue indicator

tvPty.setText("PTY: " + (s.pty >= 0 ? s.pty : "--"));

-        McuClient.getInstance().radio().removeListener(listener);

+        McuClient.getInstance().radio().removeListener(radioListener);

+        McuClient.getInstance().key().removeListener(keyListener);

+        McuClient.getInstance().vehicle().removeListener(vehicleListener);


Prompt: 串口要先建立连接，不知道你有没有考虑这个问题


'/Users/admin/workspace/jyq/qsjnic/docs/MCU_ARM_Reference/1.方控和车速自测.txt'，结合这个文档中说的模拟测试，我本机已经安装了picocom和minicom，你能否优化测试demo，然后我们终端中输入测试命令，然后安卓车机的demo里面看看能否收到数据，或者你先用串口工具结合文档的测试，先看看返回值是什么


The user is pointing out that the serial port needs to establish a connection first. This makes me think about what's really happening:

1. The MCU has a debug UART that needs to be properly connected
2. The USB serial device `/dev/cu.usbmodem21302` might be the Android device itself (some Android boards expose a USB gadget serial interface)
3. Or, the user might be referring to the fact that the Android-to-MCU link needs to be established before the MCU will respond to any commands

Wait, actually the user might be saying that the serial port on the Mac (`/dev/cu.usbmodem21302`) actually needs the Android MCU service to establish a connection first. Or maybe the USB serial is connected to the Android board's debug UART, not directly to the MCU.

Let me think about this differently. The setup might be:
- Mac → USB cable → Android board (debug/ADB)
- Android board → UART (ttyS7) → MCU

And `/dev/cu.usbmodem21302` might be a USB-serial gadget on the Android board, or it could be a direct connection to the MCU's debug port.

Actually, I think the user is telling me that the serial connection needs to be established first BEFORE we can communicate. With picocom, you need to open the connection, and the MCU might need to see the DTR/RTS signals to know a terminal is connected.

Let me try using picocom properly. The key issues might be:
1. DTR/RTS need to be asserted (--dtr, --rts flags or similar)
2. The port needs to stay open (keep picocom running)
3. Maybe we need to send a specific character first

But actually, we CAN'T run interactive picocom through the Bash tool. The tool executes commands non-interactively.

So the approach should be:
1. Build the new APK
2. Tell the user to open a terminal and run: `picocom -b 921600 /dev/cu.usbmodem21302`
3. Then in picocom, type: `KeyTest 1 0x19 0x01`
4. Meanwhile, the Android demo should show the received key event

Let me also think about what "建立连接" means for serial. For a UART connection:
- No "connection" in the TCP sense - it's just electrical
- But some MCUs use DTR to detect when a terminal is attached
- picocom by default asserts DTR and RTS

Let me check picocom's help to understand the flags.

Actually, let me now:
1. Build the new APK
2. Install it
3. Explain to the user how to use picocom to connect to the MCU

For the serial test, the user should:
1. Run `picocom -b 921600 /dev/cu.usbmodem21302` in their terminal
2. Wait for the MCU prompt (or press Enter)
3. Type `KeyTest 1 0x19 0x01` and press Enter
4. Observe the MCU debug output
5. Check the Android demo app to see if the key event was received

Let me build and install first.


你说得对，串口需要先用 picocom 建立连接。让我先构建新版 APK 并安装，同时准备串口测试。


Bash

Build updated radio-demo APK


Starting a Gradle Daemon (subsequent builds will be faster)
Warning: SDK processing. This version only understands SDK XML versions up to 3 but an SDK XML file of version 4 was encountered. This can happen if you use versions of Android Studio and the command-line tools that were released at different times.
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugJavaWithJavac
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes NO-SOURCE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs NO-SOURCE
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug

BUILD SUCCESSFUL in 5s
31 actionable tasks: 4 executed, 27 up-to-date


Build successful. Now let me install it and copy the files.


adb -s 192.168.0.66:5555 install -r /Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/build/outputs/apk/debug/app-debug.apk 2>&1

Install updated APK on car device


cp /Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/build/outputs/apk/debug/app-debug.apk "/Users/admin/workspace/jyq/imgs/testapp/qsmw-radio-demo.apk" && cp /Users/admin/workspace/jyq/qsjnic/demos/radio-demo/app/src/main/java/com/qsjnic/demo/radio/MainActivity.java "/Users/admin/workspace/jyq/imgs/testapp/MainActivity.java" && echo "copied"

Copy APK and source to testapp folder


Performing Streamed Install
Success


copied


有vihicle demo ,你为什么要把方控的功能加到radio demo上


APK installed and files copied. Now let me try to use picocom properly for the serial connection. The user said we need to establish the connection first.

Let me try running picocom in the background with the right parameters to establish a connection. The key issue might be:
1. Using the right baud rate (921600)
2. Sending the right init sequence
3. The MCU might need DTR assertion

Let me try:
```
picocom -b 921600 /dev/cu.usbmodem21302
```

But this is interactive. I can't run it interactively via the Bash tool. Let me try a different approach.

Actually, I can use `run_in_background` and `Monitor` to handle the interactive session. Or I can use a script to send commands.

Wait, let me think about this. The Bash tool runs commands non-interactively. picocom is an interactive terminal program. I can't use it in a non-interactive way.

Alternative approaches:
1. Use a Python script with pyserial (install it if not available)
2. Use `screen` in detached mode
3. Use `expect` script
4. Use `stty` + `cat` + `echo` (what I tried before but maybe with different parameters)

Let me try with a Python script. First, let me check if pyserial is available, and if not, whether I can install it.

Actually, the simplest approach for non-interactive serial communication is to use a raw shell script with proper setup. Let me try:

```bash
# Set proper serial params with DTR
stty -f /dev/cu.usbmodem21302 921600 cs8 -cstopb -parenb -icanon -echo -echoe -echok min 1 time 1
```

The key difference from my previous attempt: `min 1` instead of `min 0`. With `min 0`, `cat` returns immediately. With `min 1`, it waits for at least 1 byte.

But more importantly, maybe the issue is that:
1. The MCU debug console isn't connected to the USB-serial adapter at all
2. Or the MCU debug console uses a different baud rate
3. Or the MCU is in a low-power state and needs to be woken up

Actually, let me try a completely different approach. Instead of trying to connect to the MCU debug console, let me:
1. Check if we can access the MCU console via the Android device
2. Some Android boards have a virtual COM port that connects to the MCU debug UART

Actually, you know what, let me just check: is `/dev/cu.usbmodem21302` the same as the Android device via USB? Some Android boards present a USB-serial gadget interface. The ADB is over TCP (192.168.0.66:5555), but there might be a USB connection too.

Let me check if there's a USB connection to the Android device.

Actually, let me just try a practical approach:

1. Run picocom in the background (as a persistent process)
2. Send commands to it via a pipe
3. Read its output

Or even simpler: use the `run_in_background` feature of Bash tool to start a picocom session, then use the Monitor to watch its output, and send commands via another mechanism.

Actually, picocom has a `--send-cmd` option or similar? Let me check.

Actually, I think the cleanest approach is to write a quick Python script using built-in `termios` module (no pyserial needed). Let me do that.

But wait, the user's message was just reminding me that serial needs connection establishment. Let me acknowledge that and provide the right approach. Let me start a picocom process in the background that the user can later interact with.

Actually, I just realized something. Let me check if `picocom` supports running in a scripted way. Looking at picocom's help:

```
picocom --help
```

Let me check.


APK 已更新安装。现在让我正确建立串口连接——用 Python 脚本控制串口比 shell 重定向更可靠。


Install pyserial for reliable serial communication


Error: Exit code 1
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try brew install
    xyz, where xyz is the package you are trying to
    install.

    If you wish to install a Python library that isn't in Homebrew,
    use a virtual environment:

    python3 -m venv path/to/venv
    source path/to/venv/bin/activate
    python3 -m pip install xyz

    If you wish to install a Python application that isn't in Homebrew,
    it may be easiest to use 'pipx install xyz', which will manage a
    virtual environment for you. You can install pipx with

    brew install pipx

    You may restore the old behavior of pip by passing
    the '--break-system-packages' flag to pip, or by adding
    'break-system-packages = true' to your pip.conf file. The latter
    will permanently disable this error.

    If you disable this error, we STRONGLY recommend that you additionally
    pass the '--user' flag to pip, or set 'user = true' in your pip.conf
    file. Failure to do this can result in a broken Homebrew installation.

    Read more about this behavior here: <https://peps.python.org/pep-0668/>

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.


Prompt: 有vihicle demo ,你为什么要把方控的功能加到radio demo上


The user is questioning why I'm adding SWC (steering wheel control) functionality to the radio demo when there's already a vehicle demo that handles these features. They have a valid point.

Let me reconsider the approach:
1. There's already a `vehicle-demo` that handles keys and vehicle data
2. There's already a `radio-demo` that handles radio data
3. I should NOT merge them - that creates duplication and confusion

Instead, I should:
1. Keep radio-demo focused on radio (with the Frame Monitor we already added for valid/PS/RT/hex)
2. Either use the existing vehicle-demo as-is, or enhance it slightly to add the missing features (speed display, raw hex for KEY/PUBLIC frames)

The vehicle-demo already has:
- KeyListener → displays key events
- VehicleListener → shows line detect, voltage, backlight, version
- Key test buttons

What vehicle-demo is MISSING:
- Speed display (onCarSpeed handler)
- Raw frame hex for KEY and PUBLIC msgTypes
- MCU version display (has this already)

So the right approach is:
1. **Revert radio-demo** back to the radio-only version (with Frame Monitor for radio debug)
2. **Enhance vehicle-demo** to add speed display and raw frame hex for KEY/PUBLIC

Actually, wait. Let me re-read what the user originally asked. They said they wanted to see the "相关数值" (relevant values) from the feedback document. The feedback was about RADIO issues. So the radio demo changes were correct for the original task.

But now the user is asking about the 方控和车速自测 document. For this, the vehicle-demo is the right place.

Let me:
1. Revert radio-demo to the radio-only version (keep Frame Monitor with radio hex)
2. Enhance vehicle-demo to add: speed display, raw hex for KEY/PUBLIC frames
3. Build both

Actually wait, I already have a clean radio-only version. Let me revert to that (the version before I added Key/Vehicle listeners). The radio demo should only have:
- Radio data display
- Frame Monitor (valid, PTY, PS, RT, TA, TP, work status, tips)
- Raw FREQ/PS_PTY/RT hex

Let me revert the radio-demo changes, then modify the vehicle-demo instead.

But first, let me think about what the user needs from the test document:

1. 方控自测 (SWC self-test):
   - MCU sends key events when buttons are pressed
   - KeyTest commands from MCU debug console simulate key presses
   - vehicle-demo already has KeyListener and displays key events

2. 车速上报模拟 (Speed report simulation):
   - MCU sends speed data
   - PublicDebug 23 command from MCU debug console simulates speed
   - vehicle-demo needs to add speed display

So for vehicle-demo, I need to:
1. Add onCarSpeed listener
2. Add raw frame hex display for KEY (msgType 0x02) and PUBLIC (msgType 0x01) frames
3. Maybe add speed hex display

For radio-demo, revert to radio-only version.

For the serial test, I need to try connecting with picocom.

Let me revert radio-demo first, then enhance vehicle-demo.


你说得对，方控和车速应该在 vehicle-demo 里。让我把 radio-demo 恢复为纯 radio 监控，然后增强 vehicle-demo。


Write

package com.qsjnic.demo.radio;

import android.app.Activity;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.media.AudioAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;

import com.qsjnic.mcu.contract.IMcuFrameListener;
import com.qsjnic.mcu.contract.RadioState;
import com.qsjnic.mcu.sdk.McuClient;
import com.qsjnic.mcu.sdk.McuRadioClient;
import com.qsjnic.mcu.sdk.RadioListener;

/**
 * Radio Demo with FM Tuner audio passthrough and MCU frame monitor.
 */
public class MainActivity extends Activity {

    private static final String TAG = "RadioDemo";
    private static final int PERMISSION_REQ = 100;

    private final Handler ui = new Handler(Looper.getMainLooper());

    // Data panel
    private TextView tvBand, tvFreq, tvFreqMhz, tvSignal, tvStereo, tvSeek, tvAudioStatus;

    // Frame Monitor fields
    private TextView tvValid, tvPty, tvTa, tvTp, tvPs, tvRt, tvWorkStatus, tvTaPtyTips;
    private TextView tvFreqHex, tvPsPtyHex, tvRtHex;
    private String lastPs = "";
    private String lastRt = "";

    // Control buttons
    private TextView tvStatus;
    private Button btnFm, btnAm, btn875, btn900, btnSeekFw, btnSeekStop, btnPlay875;
    private Button btnAudioStart, btnAudioStop;

    private final RadioListener listener = new RadioListener() {
        @Override public void onFreqChanged(RadioState s) {
            ui.post(() -> updateRadio(s));
        }
        @Override public void onPsPty(int pi, int pty, String ps) {
            Log.i(TAG, "PS/PTY pi=" + pi + " pty=" + pty + " ps=" + ps);
            ui.post(() -> {
                lastPs = (ps != null) ? ps : "";
                tvPs.setText("PS: " + (lastPs.isEmpty() ? "(empty)" : lastPs));
                tvPs.setTextColor(lastPs.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
                tvPty.setText("PTY: " + pty);
            });
        }
        @Override public void onRadioText(String rt) {
            Log.i(TAG, "RT=" + rt);
            ui.post(() -> {
                lastRt = (rt != null) ? rt : "";
                String display = lastRt.isEmpty() ? "(empty)" : (lastRt.length() > 80 ? lastRt.substring(0, 80) + "..." : lastRt);
                tvRt.setText("RT: " + display);
                tvRt.setTextColor(lastRt.isEmpty() ? Color.parseColor("#F44336") : Color.parseColor("#4CAF50"));
            });
        }
        @Override public void onWorkStatus(int status) {
            Log.i(TAG, "WorkStatus=" + status);
            ui.post(() -> {
                tvWorkStatus.setText("Work: " + status + (status == 0 ? " (OK)" : " (ERR)"));
                tvWorkStatus.setTextColor(status == 0 ? Color.parseColor("#4CAF50") : Color.parseColor("#F44336"));
            });
        }
        @Override public void onTaPtyTips(int mode, int value) {
            Log.i(TAG, "TA/PTY Tips mode=" + mode + " value=" + value);
            ui.post(() -> {
                String modeName = mode == 0 ? "TA" : (mode == 1 ? "PTY" : "?");
                String valName = value == 0 ? "Clear" : (value == 1 ? "NoResult" : (value == 2 ? "Searching" : "?"));
                tvTaPtyTips.setText("Tips: " + modeName + " / " + valName);
            });
        }
    };

    // ----- raw frame listener for hex dump -----
    private final IMcuFrameListener.Stub rawFrameListener = new IMcuFrameListener.Stub() {
        @Override
        public void onMcuFrame(int frameType, int msgType, int cmd, byte[] body) {
            if (msgType != 0x05) return; // RADIO only
            String hex = bytesToHex(body);
            ui.post(() -> {
                switch (cmd) {
                    case 0x01: // FREQ
                        tvFreqHex.setText("FREQ (" + body.length + "B): " + hex);
                        break;
                    case 0x02: // PS_PTY
                        tvPsPtyHex.setText("PS/PTY (" + body.length + "B): " + hex);
                        break;
                    case 0x03: // RT
                        String rtHex = body.length > 32
                                ? bytesToHex(body, 32) + " ..."
                                : hex;
                        tvRtHex.setText("RT (" + body.length + "B): " + rtHex);
                        break;
                }
            });
        }
        @Override public void onVehicleStateChanged(com.qsjnic.mcu.contract.VehicleSnapshot s) {}
        @Override public void onRadioStateChanged(RadioState s) {}
        @Override public void onUpgradeProgress(com.qsjnic.mcu.contract.UpgradeProgress p) {}
    };

    // ----- Audio passthrough -----
    private AudioRecord audioRecord;
    private AudioTrack audioTrack;
    private Thread audioThread;
    private volatile boolean audioRunning = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScrollView scroll = new ScrollView(this);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setPadding(32, 32, 32, 32);
        root.setBackgroundColor(Color.parseColor("#F5F5F5"));

        // ===== Title =====
        TextView title = new TextView(this);
        title.setText("Radio Tuner + Audio");
        title.setTextSize(24f);
        title.setTypeface(null, Typeface.BOLD);
        title.setTextColor(Color.parseColor("#333333"));
        title.setPadding(0, 0, 0, 16);
        root.addView(title);

        tvStatus = new TextView(this);
        tvStatus.setText("Service: connecting");
        tvStatus.setTextSize(14f);
        tvStatus.setTextColor(Color.parseColor("#555555"));
        tvStatus.setPadding(0, 0, 0, 16);
        root.addView(tvStatus);

        // ===== Frequency Display Card =====
        LinearLayout freqCard = card(root);
        tvBand = new TextView(this);
        tvBand.setText("--");
        tvBand.setTextSize(16f);
        tvBand.setTextColor(Color.parseColor("#888888"));
        tvBand.setGravity(Gravity.CENTER);
        freqCard.addView(tvBand);

        tvFreq = new TextView(this);
        tvFreq.setText("----");
        tvFreq.setTextSize(56f);
        tvFreq.setTypeface(null, Typeface.BOLD);
        tvFreq.setTextColor(Color.parseColor("#1976D2"));
        tvFreq.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreq);

        tvFreqMhz = new TextView(this);
        tvFreqMhz.setText("MHz");
        tvFreqMhz.setTextSize(14f);
        tvFreqMhz.setTextColor(Color.parseColor("#888888"));
        tvFreqMhz.setGravity(Gravity.CENTER);
        freqCard.addView(tvFreqMhz);

        LinearLayout detailRow = new LinearLayout(this);
        detailRow.setOrientation(LinearLayout.HORIZONTAL);
        detailRow.setGravity(Gravity.CENTER);
        detailRow.setPadding(0, 16, 0, 0);
        freqCard.addView(detailRow);

        tvSignal = chip(detailRow, "Signal: --");
        tvStereo = chip(detailRow, "Stereo: --");
        tvSeek   = chip(detailRow, "Seek: --");

        // ===== Frame Monitor Card =====
        root.addView(sectionTitle("Frame Monitor"));
        LinearLayout monCard = card(root);

        // Row 1: valid + pty + TA + TP flags
        LinearLayout monRow1 = new LinearLayout(this);
        monRow1.setOrientation(LinearLayout.HORIZONTAL);
        monRow1.setGravity(Gravity.CENTER_VERTICAL);
        monRow1.setPadding(0, 0, 0, 8);
        monCard.addView(monRow1);

        tvValid = chip(monRow1, "valid: --");
        tvPty   = chip(monRow1, "PTY: --");
        tvTa    = chip(monRow1, "TA: --");
        tvTp    = chip(monRow1, "TP: --");

        // Row 2: work status + TA/PTY tips
        LinearLayout monRow2 = new LinearLayout(this);
        monRow2.setOrientation(LinearLayout.HORIZONTAL);
        monRow2.setGravity(Gravity.CENTER_VERTICAL);
        monRow2.setPadding(0, 0, 0, 4);
        monCard.addView(monRow2);

        tvWorkStatus = monText(monRow2, "Work: --");
        tvTaPtyTips  = monText(monRow2, "Tips: --");

        // Row 3: PS station name
        tvPs = monText(monCard, "PS: --");
        tvPs.setPadding(0, 4, 0, 2);

        // Row 4: RT radio text
        tvRt = monText(monCard, "RT: --");
        tvRt.setPadding(0, 2, 0, 8);

        // Divider
        View divider = new View(this);
        divider.setBackgroundColor(Color.parseColor("#E0E0E0"));
        LinearLayout.LayoutParams dlp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, 1);
        dlp.setMargins(0, 0, 0, 8);
        divider.setLayoutParams(dlp);
        monCard.addView(divider);

        // Hex dump section title
        TextView hexLabel = new TextView(this);
        hexLabel.setText("Raw Frame Hex");
        hexLabel.setTextSize(12f);
        hexLabel.setTypeface(null, Typeface.BOLD);
        hexLabel.setTextColor(Color.parseColor("#888888"));
        hexLabel.setPadding(0, 0, 0, 4);
        monCard.addView(hexLabel);

        // Hex rows (monospace, small)
        tvFreqHex = hexText(monCard);
        tvPsPtyHex = hexText(monCard);
        tvRtHex = hexText(monCard);

        // ===== Audio Status =====
        root.addView(sectionTitle("Tuner Audio"));
        LinearLayout audioCard = card(root);
        tvAudioStatus = new TextView(this);
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextSize(14f);
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        tvAudioStatus.setPadding(0, 8, 0, 8);
        audioCard.addView(tvAudioStatus);

        LinearLayout audioRow = buttonRow(audioCard);
        btnAudioStart = btn(audioRow, "Start Audio", v -> startTunerAudio());
        btnAudioStop  = btn(audioRow, "Stop Audio",  v -> stopTunerAudio());

        // ===== Band Selection =====
        root.addView(sectionTitle("Band"));
        LinearLayout bandCard = card(root);
        LinearLayout bandRow = buttonRow(bandCard);
        btnFm = btn(bandRow, "FM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_FM));
        btnAm = btn(bandRow, "AM", v -> McuClient.getInstance().radio().setBand(McuRadioClient.BAND_AM));

        // ===== Frequency Presets =====
        root.addView(sectionTitle("Frequency"));
        LinearLayout freqCtrlCard = card(root);
        LinearLayout freqRow1 = buttonRow(freqCtrlCard);
        btn875 = btn(freqRow1, "87.5 MHz", v -> McuClient.getInstance().radio().setFrequency(8750));
        btn900 = btn(freqRow1, "90.0 MHz", v -> McuClient.getInstance().radio().setFrequency(9000));

        // ===== Seek Control =====
        root.addView(sectionTitle("Seek"));
        LinearLayout seekCard = card(root);
        LinearLayout seekRow = buttonRow(seekCard);
        btnSeekFw   = btn(seekRow, "Forward",  v -> McuClient.getInstance().radio().seekForward());
        btnSeekStop = btn(seekRow, "Stop",     v -> McuClient.getInstance().radio().seekStop());

        // ===== Other =====
        root.addView(sectionTitle("Other"));
        LinearLayout otherCard = card(root);
        LinearLayout otherRow = buttonRow(otherCard);
        btnPlay875 = btn(otherRow, "Play 90.0", v -> playFm900());
        btn(otherRow, "Ant ON",  v -> McuClient.getInstance().radio().setAntPower(true));
        btn(otherRow, "Init Range", v -> McuClient.getInstance().radio().setFreqRange(0, 522, 1670, 8750, 10800, 9, 1));

        scroll.addView(root);
        setContentView(scroll);

        McuClient.getInstance().init(getApplicationContext());
        McuClient.getInstance().addReadyListener(() -> ui.post(() -> {
            tvStatus.setText("Service: connected, linkAlive=" + McuClient.getInstance().isLinkAlive());
            playFm900();
            ui.postDelayed(this::startTunerAudio, 1000);
            McuClient.getInstance().addFrameListener(rawFrameListener);
        }));
        McuClient.getInstance().radio().addListener(listener);

        // Request audio permission
        if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[]{android.Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ);
        }
    }

    // ---------- Audio Passthrough ----------

    private void startTunerAudio() {
        if (audioRunning) return;

        AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
        AudioDeviceInfo tunerDevice = null;
        for (AudioDeviceInfo dev : am.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
            Log.i(TAG, "Input device: type=" + dev.getType() + " name=" + dev.getProductName());
            if (dev.getType() == AudioDeviceInfo.TYPE_FM_TUNER) {
                tunerDevice = dev;
                break;
            }
        }

        if (tunerDevice == null) {
            tvAudioStatus.setText("Audio: FM Tuner device NOT FOUND");
            Log.e(TAG, "TYPE_FM_TUNER not found in input devices");
            return;
        }

        tvAudioStatus.setText("Audio: starting...");
        Log.i(TAG, "Found FM Tuner: " + tunerDevice.getProductName());

        final int sampleRate = 48000;
        final int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
        final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
        final int minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
        final int bufferSize = Math.max(minBufSize, 16384);
        final int AUDIO_SOURCE_FM_TUNER = 1998;

        try {
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
                    sampleRate, channelConfig, audioFormat, bufferSize);

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && audioRecord.setPreferredDevice(tunerDevice)) {
                Log.i(TAG, "setPreferredDevice(tuner) succeeded");
            }

            audioTrack = new AudioTrack.Builder()
                    .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_MEDIA)
                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                            .build())
                    .setAudioFormat(new AudioFormat.Builder()
                            .setSampleRate(sampleRate)
                            .setEncoding(audioFormat)
                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                            .build())
                    .setBufferSizeInBytes(bufferSize)
                    .setTransferMode(AudioTrack.MODE_STREAM)
                    .build();

            audioRecord.startRecording();
            audioTrack.play();
            audioRunning = true;

            final int playbackBufSize = bufferSize;
            audioThread = new Thread(() -> {
                android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
                byte[] buf = new byte[playbackBufSize];
                int totalRead = 0;
                long lastLog = System.currentTimeMillis();
                while (audioRunning) {
                    int read = audioRecord.read(buf, 0, buf.length);
                    if (read > 0) {
                        totalRead += read;
                        audioTrack.write(buf, 0, read);
                        long now = System.currentTimeMillis();
                        if (now - lastLog > 3000) {
                            short sample = (short) ((buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8));
                            Log.i(TAG, "Audio passthrough: read=" + read
                                    + " total=" + totalRead
                                    + " sample=" + sample
                                    + " trackState=" + audioTrack.getPlayState());
                            lastLog = now;
                        }
                    } else if (read < 0) {
                        Log.e(TAG, "AudioRecord read error: " + read);
                        break;
                    }
                }
            }, "TunerAudio");
            audioThread.setPriority(Thread.MAX_PRIORITY);
            audioThread.start();

            tvAudioStatus.setText("Audio: PLAYING (" + tunerDevice.getProductName() + ")");
            tvAudioStatus.setTextColor(Color.parseColor("#4CAF50"));

        } catch (Exception e) {
            Log.e(TAG, "startTunerAudio failed", e);
            tvAudioStatus.setText("Audio: FAILED - " + e.getMessage());
            tvAudioStatus.setTextColor(Color.parseColor("#F44336"));
            stopTunerAudio();
        }
    }

    private void stopTunerAudio() {
        audioRunning = false;
        if (audioThread != null) {
            try { audioThread.join(500); } catch (InterruptedException ignored) {}
            audioThread = null;
        }
        if (audioRecord != null) {
            try { audioRecord.stop(); } catch (Exception ignored) {}
            try { audioRecord.release(); } catch (Exception ignored) {}
            audioRecord = null;
        }
        if (audioTrack != null) {
            try { audioTrack.stop(); } catch (Exception ignored) {}
            try { audioTrack.release(); } catch (Exception ignored) {}
            audioTrack = null;
        }
        tvAudioStatus.setText("Audio: stopped");
        tvAudioStatus.setTextColor(Color.parseColor("#555555"));
        Log.i(TAG, "Tuner audio stopped");
    }

    // ---------- UI helpers ----------

    private TextView sectionTitle(String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(14f);
        t.setTypeface(null, Typeface.BOLD);
        t.setTextColor(Color.parseColor("#666666"));
        t.setPadding(8, 24, 0, 8);
        return t;
    }

    private LinearLayout card(LinearLayout parent) {
        LinearLayout card = new LinearLayout(this);
        card.setOrientation(LinearLayout.VERTICAL);
        card.setPadding(24, 24, 24, 24);
        card.setBackgroundColor(Color.WHITE);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.WHITE);
        bg.setCornerRadius(16f);
        card.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(0, 0, 0, 16);
        card.setLayoutParams(lp);
        parent.addView(card);
        return card;
    }

    private TextView chip(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(12f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(16, 8, 16, 8);
        GradientDrawable bg = new GradientDrawable();
        bg.setColor(Color.parseColor("#E8E8E8"));
        bg.setCornerRadius(20f);
        t.setBackground(bg);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.setMargins(8, 0, 8, 0);
        t.setLayoutParams(lp);
        parent.addView(t);
        return t;
    }

    private TextView monText(LinearLayout parent, String text) {
        TextView t = new TextView(this);
        t.setText(text);
        t.setTextSize(13f);
        t.setTextColor(Color.parseColor("#555555"));
        t.setPadding(8, 2, 8, 2);
        parent.addView(t);
        return t;
    }

    private TextView hexText(LinearLayout parent) {
        TextView t = new TextView(this);
        t.setText("--");
        t.setTextSize(10f);
        t.setTypeface(Typeface.MONOSPACE);
        t.setTextColor(Color.parseColor("#888888"));
        t.setPadding(0, 2, 0, 2);
        parent.addView(t);
        return t;
    }

    private LinearLayout buttonRow(LinearLayout parent) {
        LinearLayout row = new LinearLayout(this);
        row.setOrientation(LinearLayout.HORIZONTAL);
        row.setPadding(0, 8, 0, 8);
        parent.addView(row);
        return row;
    }

    private Button btn(LinearLayout parent, String label, View.OnClickListener l) {
        Button b = new Button(this);
        b.setText(label);
        b.setOnClickListener(l);
        b.setAllCaps(false);
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
        lp.setMargins(8, 0, 8, 0);
        b.setLayoutParams(lp);
        parent.addView(b);
        return b;
    }

    private static String bytesToHex(byte[] bytes) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        StringBuilder sb = new StringBuilder(bytes.length * 3);
        for (byte b : bytes) {
            sb.append(String.format("%02X ", b & 0xFF));
        }
        sb.setLength(sb.length() - 1);
        return sb.toString();
    }

    private static String bytesToHex(byte[] bytes, int limit) {
        if (bytes == null || bytes.length == 0) return "(empty)";
        int n = Math.min(bytes.length, limit);
        StringBuilder sb = new StringBuilder(n * 3);
        for (int i = 0; i < n; i++) {
            sb.append(String.format("%02X ", bytes[i] & 0xFF));
        }
        sb.setLength(sb.length() - 1);
        if (bytes.length > limit) sb.append(" ...");
        return sb.toString();
    }

    private void playFm900() {
        McuRadioClient radio = McuClient.getInstance().radio();
        int rcBand = radio.setBand(McuRadioClient.BAND_FM);
        int rcRange = radio.setFreqRange(0, 522, 1670, 8750, 10800, 9, 1);
        int rcAnt = radio.setAntPower(true);
        int rcStereo = radio.setSwitch(McuRadioClient.SWITCH_STEREO, 1);
        int rcRds = radio.setSwitch(McuRadioClient.SWITCH_RDS, 1);
        int rcMute = radio.setRegMute(false);
        int rcFreq = radio.setFrequency(9344);
        int rcStop = radio.seekStop();
        String msg = "Play 93.4 rc band=" + rcBand
                + " range=" + rcRange
                + " ant=" + rcAnt
                + " stereo=" + rcStereo
                + " rds=" + rcRds
                + " freq=" + rcFreq
                + " stop=" + rcStop
                + " muteOff=" + rcMute;
        Log.i(TAG, msg);
        tvStatus.setText(msg);
    }

    private int lastSignal = 0;
    private int strongFreq = 0;

    private void updateRadio(RadioState s) {
        String bandName = s.band == 0 ? "FM" : (s.band == 1 ? "AM" : "?");
        tvBand.setText(bandName + " Radio");

        double mhz = s.band == 0 ? s.freq / 100.0 : s.freq / 1000.0;
        tvFreq.setText(String.format("%.1f", mhz));
        tvFreqMhz.setText(s.band == 0 ? "MHz" : "KHz");

        tvSignal.setText("Signal: " + (s.signal >= 0 ? s.signal : "--"));
        tvStereo.setText(s.flagStereo == 1 ? "Stereo" : "Mono");
        tvStereo.setTextColor(s.flagStereo == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        String[] seekNames = {"Stop", "AMS", "Fwd", "Bwd", "PTY", "TA"};
        tvSeek.setText("Seek: " + (s.seekMode >= 0 && s.seekMode < seekNames.length ? seekNames[s.seekMode] : "--"));

        // ---- Frame Monitor updates ----
        if (s.valid == 1) {
            tvValid.setText("valid: LOCK");
            tvValid.setTextColor(Color.parseColor("#4CAF50"));
        } else if (s.valid == 0) {
            tvValid.setText("valid: 0");
            tvValid.setTextColor(Color.parseColor("#F44336"));
        } else {
            tvValid.setText("valid: --");
            tvValid.setTextColor(Color.parseColor("#999999"));
        }

        tvPty.setText("PTY: " + (s.pty >= 0 ? s.pty : "--"));

        tvTa.setText("TA: " + (s.flagTa == 1 ? "ON" : (s.flagTa == 0 ? "OFF" : "--")));
        tvTa.setTextColor(s.flagTa == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));
        tvTp.setText("TP: " + (s.flagTp == 1 ? "ON" : (s.flagTp == 0 ? "OFF" : "--")));
        tvTp.setTextColor(s.flagTp == 1 ? Color.parseColor("#4CAF50") : Color.parseColor("#999999"));

        if (s.signal > lastSignal) {
            lastSignal = s.signal;
            strongFreq = s.freq;
            Log.i(TAG, "*** STRONG SIGNAL: " + s.freq + " signal=" + s.signal + " ***");
        }
    }

    @Override
    protected void onDestroy() {
        stopTunerAudio();
        McuClient.getInstance().radio().removeListener(listener);
        McuClient.getInstance().removeFrameListener(rawFrameListener);
        super.onDestroy();
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (!McuClient.getInstance().isReady()) {
            android.widget.Toast.makeText(this, "Service 未连接，5s后重试", android.widget.Toast.LENGTH_SHORT).show();
            new Handler(Looper.getMainLooper()).postDelayed(this::onResume, 5000);
        }
    }
}