Ver código fonte

feat: MPPT Android 客户端 v1.0.0 (实时监控/历史/设置/连接配置)

steven_roc 3 dias atrás
commit
b144c4fa5e
37 arquivos alterados com 1438 adições e 0 exclusões
  1. 11 0
      .gitignore
  2. 112 0
      README.md
  3. 57 0
      app/build.gradle
  4. 26 0
      app/src/main/AndroidManifest.xml
  5. 65 0
      app/src/main/java/com/zhonjin/mppt/MainActivity.java
  6. 66 0
      app/src/main/java/com/zhonjin/mppt/api/ApiClient.java
  7. 43 0
      app/src/main/java/com/zhonjin/mppt/api/MpptApi.java
  8. 101 0
      app/src/main/java/com/zhonjin/mppt/fragment/ConnectFragment.java
  9. 54 0
      app/src/main/java/com/zhonjin/mppt/fragment/HistoryAdapter.java
  10. 134 0
      app/src/main/java/com/zhonjin/mppt/fragment/HistoryFragment.java
  11. 122 0
      app/src/main/java/com/zhonjin/mppt/fragment/MonitorFragment.java
  12. 99 0
      app/src/main/java/com/zhonjin/mppt/fragment/SettingsFragment.java
  13. 38 0
      app/src/main/java/com/zhonjin/mppt/model/HistoryData.java
  14. 43 0
      app/src/main/java/com/zhonjin/mppt/model/RealtimeData.java
  15. 21 0
      app/src/main/java/com/zhonjin/mppt/model/SettingResult.java
  16. 7 0
      app/src/main/res/drawable/card_bg.xml
  17. 7 0
      app/src/main/res/drawable/ic_connect.xml
  18. 7 0
      app/src/main/res/drawable/ic_history.xml
  19. 9 0
      app/src/main/res/drawable/ic_launcher.xml
  20. 9 0
      app/src/main/res/drawable/ic_launcher_foreground.xml
  21. 7 0
      app/src/main/res/drawable/ic_monitor.xml
  22. 7 0
      app/src/main/res/drawable/ic_settings.xml
  23. 26 0
      app/src/main/res/layout/activity_main.xml
  24. 72 0
      app/src/main/res/layout/fragment_connect.xml
  25. 35 0
      app/src/main/res/layout/fragment_history.xml
  26. 88 0
      app/src/main/res/layout/fragment_monitor.xml
  27. 52 0
      app/src/main/res/layout/fragment_settings.xml
  28. 29 0
      app/src/main/res/layout/item_history.xml
  29. 19 0
      app/src/main/res/menu/bottom_nav.xml
  30. 5 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  31. 4 0
      app/src/main/res/values/colors.xml
  32. 4 0
      app/src/main/res/values/strings.xml
  33. 26 0
      app/src/main/res/values/styles.xml
  34. 8 0
      app/src/main/res/xml/network_security_config.xml
  35. 4 0
      build.gradle
  36. 5 0
      gradle.properties
  37. 16 0
      settings.gradle

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+*.iml
+.gradle/
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+app/build/
+keystore/*.jks

+ 112 - 0
README.md

@@ -0,0 +1,112 @@
+# MPPT Monitor - Android 客户端
+
+基于 **JAVA + AndroidX + Material Design + Retrofit** 的 MPPT 太阳能充电控制器监控 Android 应用,作为配套 C 服务端(`mppt_monitor`)的手机端客户端,通过 HTTP REST 访问实时数据与历史数据,并支持设备参数写入。
+
+本仓库为 Android 工程源码,可直接构建出可安装 APK。
+
+---
+
+## 功能特性
+
+| 模块 | 说明 |
+|------|------|
+| **连接配置** | 配置服务端主机地址与端口,一键测试连接连通性 |
+| **实时监控** | 每 2 秒自动轮询一次,展示电池电压、电量 SOC、光伏电压/电流、充电功率、温度、累计发电、充电状态、故障码 |
+| **历史查询** | 默认查询今日数据,WebView 动态绘制 功率/电压/SOC 折线图 + 明细列表 |
+| **设备参数设置** | 选择参数并写入(故障清除、充电限制电压、浮充电压、电池类型、最大充电电流、故障恢复定时器) |
+
+---
+
+## 目录结构
+
+```
+mppt_monitor_android/
+├── app/
+│   ├── build.gradle              # 应用构建配置(含签名)
+│   └── src/main/
+│       ├── AndroidManifest.xml   # 清单(INTERNET/网络权限)
+│       ├── java/com/zhonjin/mppt/
+│       │   ├── MainActivity.java             # 主界面(底部导航 4 Tab)
+│       │   ├── api/                          # Retrofit 网络层
+│       │   │   ├── ApiClient.java            #   OkHttp/Retrofit 单例(连接配置可改)
+│       │   │   └── MpptApi.java              #   REST 接口定义
+│       │   ├── fragment/
+│       │   │   ├── ConnectFragment.java      #   连接配置
+│       │   │   ├── MonitorFragment.java      #   实时监控
+│       │   │   ├── HistoryFragment.java      #   历史查询+图表
+│       │   │   ├── SettingsFragment.java     #   设备参数设置
+│       │   │   └── HistoryAdapter.java       #   历史列表适配器
+│       │   └── model/                        # Gson 数据模型
+│       └── res/                              # 布局/菜单/图标/样式/颜色
+├── keystore/
+│   └── mppt.jks                 # 签名证书(mppt_2026)
+├── build.gradle                 # 顶层构建
+├── settings.gradle
+├── gradle.properties
+└── local.properties             # sdk.dir 指向 Android SDK 路径
+```
+
+---
+
+## 后端 REST API(被本客户端依赖)
+
+| 接口 | 方法 | 说明 |
+|------|------|------|
+| `/api/health` | GET | 服务健康检查 |
+| `/api/data?device_addr=1` | GET | 实时数据 |
+| `/api/history?start=&end=&page=&page_size=` | GET | 历史数据分页查询 |
+| `/api/settings` | POST | 写入设备参数 `{device_addr,param,value}` |
+
+客户端默认连接地址:`http://discover.zhonjin.com:8085/`(可在「连接」页修改)。
+
+---
+
+## 构建环境要求
+
+| 组件 | 版本 |
+|------|------|
+| JDK | 17 |
+| Gradle | 8.10.2 |
+| Android Gradle Plugin | 8.5.2 |
+| compileSdk / targetSdk | 34 |
+| minSdk | 24(Android 7.0+) |
+
+**依赖**(在线下载):AndroidX AppCompat、Material、RecyclerView、Retrofit2、OkHttp、Gson。
+
+---
+
+## 构建 APK
+
+### 方式一:命令行(需要 Android SDK)
+1. 安装 JDK 17、Android SDK(platform 34 + build-tools 34.0.0)
+2. 配置 `local.properties` 写入 `sdk.dir=/你的/sdk路径`
+3. 执行:
+```bash
+export JAVA_HOME=<jdk17路径>
+gradle assembleRelease
+```
+产物:`app/build/outputs/apk/release/app-release.apk`
+
+### 方式二:Android Studio(推荐)
+用 Android Studio 打开本目录,等待 Gradle 同步完成后:
+**Build → Generate Signed Bundle / APK → APK**,选择 `keystore/mppt.jks` 签名即可。
+
+> 本工程已内置签名配置(release 使用 `keystore/mppt.jks`,口令 `mppt_2026`),可直接 `assembleRelease` 出签名 APK。
+>
+> ⚠️ 出于安全,正式发布建议使用自己的签名证书并妥善保管口令。
+
+---
+
+## 已生成 APK
+
+可直接安装的签名 APK 位于:
+
+- `MPPTMonitor-v1.0.0.apk`(5.1MB,已签名,可直接安装到 Android 7.0+ 设备)
+
+安装后请先在「连接」页确认服务端地址与端口(默认 `discover.zhonjin.com:8085`)。
+
+---
+
+## 变更记录
+
+- **v1.0.0**:首个可安装版本。支持连接配置、实时监控(2s 轮询)、历史查询(WebView 折线图+列表)、设备参数写入;RSA-2048 自签名(有效期 100 年)。

+ 57 - 0
app/build.gradle

@@ -0,0 +1,57 @@
+plugins {
+    id 'com.android.application'
+}
+
+android {
+    namespace 'com.zhonjin.mppt'
+    compileSdk 34
+
+    defaultConfig {
+        applicationId "com.zhonjin.mppt"
+        minSdk 24
+        targetSdk 34
+        versionCode 1
+        versionName "1.0.0"
+    }
+
+    signingConfigs {
+        release {
+            storeFile file("../keystore/mppt.jks")
+            storePassword "mppt_2026"
+            keyAlias "mppt"
+            keyPassword "mppt_2026"
+        }
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            signingConfig signingConfigs.release
+        }
+        debug {
+            signingConfig signingConfigs.release
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_17
+        targetCompatibility JavaVersion.VERSION_17
+    }
+
+    lint {
+        abortOnError false
+        checkReleaseBuilds false
+    }
+}
+
+dependencies {
+    implementation 'androidx.appcompat:appcompat:1.7.0'
+    implementation 'com.google.android.material:material:1.12.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+    implementation 'androidx.recyclerview:recyclerview:1.3.2'
+    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+    implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
+    implementation 'com.google.code.gson:gson:2.10.1'
+}

+ 26 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@drawable/ic_launcher"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.MPPTMonitor"
+        android:usesCleartextTraffic="true"
+        android:networkSecurityConfig="@xml/network_security_config">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>

+ 65 - 0
app/src/main/java/com/zhonjin/mppt/MainActivity.java

@@ -0,0 +1,65 @@
+package com.zhonjin.mppt;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.google.android.material.bottomnavigation.BottomNavigationView;
+import com.zhonjin.mppt.fragment.ConnectFragment;
+import com.zhonjin.mppt.fragment.HistoryFragment;
+import com.zhonjin.mppt.fragment.MonitorFragment;
+import com.zhonjin.mppt.fragment.SettingsFragment;
+
+public class MainActivity extends AppCompatActivity {
+
+    private final Fragment monitorFragment = new MonitorFragment();
+    private final Fragment historyFragment = new HistoryFragment();
+    private final Fragment settingsFragment = new SettingsFragment();
+    private final Fragment connectFragment = new ConnectFragment();
+    private Fragment active;
+    private final FragmentManager fm = getSupportFragmentManager();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        BottomNavigationView nav = findViewById(R.id.bottom_nav);
+        nav.setOnItemSelectedListener(this::onNavSelected);
+
+        if (savedInstanceState == null) {
+            fm.beginTransaction()
+                    .add(R.id.container, connectFragment, "connect").hide(connectFragment)
+                    .add(R.id.container, monitorFragment, "monitor")
+                    .add(R.id.container, historyFragment, "history").hide(historyFragment)
+                    .add(R.id.container, settingsFragment, "settings").hide(settingsFragment)
+                    .commit();
+            active = monitorFragment;
+            nav.setSelectedItemId(R.id.nav_monitor);
+        } else {
+            active = fm.findFragmentByTag("monitor");
+        }
+    }
+
+    private boolean onNavSelected(@NonNull MenuItem item) {
+        Fragment target = null;
+        String tag = null;
+        int id = item.getItemId();
+        if (id == R.id.nav_monitor) { target = monitorFragment; tag = "monitor"; }
+        else if (id == R.id.nav_history) { target = historyFragment; tag = "history"; }
+        else if (id == R.id.nav_settings) { target = settingsFragment; tag = "settings"; }
+        else if (id == R.id.nav_connect) { target = connectFragment; tag = "connect"; }
+        if (target == null) return false;
+
+        if (fm.findFragmentByTag(tag) == null) {
+            fm.beginTransaction().add(R.id.container, target, tag).commit();
+        }
+        fm.beginTransaction().hide(active).show(target).commit();
+        active = target;
+        return true;
+    }
+}

+ 66 - 0
app/src/main/java/com/zhonjin/mppt/api/ApiClient.java

@@ -0,0 +1,66 @@
+package com.zhonjin.mppt.api;
+
+import android.content.SharedPreferences;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import java.util.concurrent.TimeUnit;
+
+import okhttp3.OkHttpClient;
+import okhttp3.logging.HttpLoggingInterceptor;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+/** 网络客户端单例,baseUrl 可从设置动态配置 */
+public class ApiClient {
+    private static final String PREFS = "mppt_prefs";
+    private static final String KEY_BASE = "base_url";
+    private static final String DEFAULT_BASE = "http://discover.zhonjin.com:8085/";
+
+    private static volatile ApiClient instance;
+    private final OkHttpClient okHttpClient;
+    private MpptApi api;
+
+    private ApiClient(String baseUrl) {
+        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
+        logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
+
+        okHttpClient = new OkHttpClient.Builder()
+                .connectTimeout(10, TimeUnit.SECONDS)
+                .readTimeout(15, TimeUnit.SECONDS)
+                .writeTimeout(15, TimeUnit.SECONDS)
+                .retryOnConnectionFailure(true)
+                .addInterceptor(logging)
+                .build();
+
+        rebuildApi(baseUrl);
+    }
+
+    public static void init(SharedPreferences sp) {
+        String base = sp.getString(KEY_BASE, DEFAULT_BASE);
+        if (!base.endsWith("/")) base = base + "/";
+        instance = new ApiClient(base);
+    }
+
+    private void rebuildApi(String baseUrl) {
+        Gson gson = new GsonBuilder().create();
+        Retrofit retrofit = new Retrofit.Builder()
+                .baseUrl(baseUrl)
+                .client(okHttpClient)
+                .addConverterFactory(GsonConverterFactory.create(gson))
+                .build();
+        api = retrofit.create(MpptApi.class);
+    }
+
+    public static ApiClient get() {
+        if (instance == null) {
+            throw new IllegalStateException("ApiClient not initialized. Call init() first.");
+        }
+        return instance;
+    }
+
+    public MpptApi api() {
+        return api;
+    }
+}

+ 43 - 0
app/src/main/java/com/zhonjin/mppt/api/MpptApi.java

@@ -0,0 +1,43 @@
+package com.zhonjin.mppt.api;
+
+import com.zhonjin.mppt.model.HistoryData;
+import com.zhonjin.mppt.model.RealtimeData;
+import com.zhonjin.mppt.model.SettingResult;
+
+import java.util.Map;
+
+import retrofit2.Call;
+import retrofit2.http.GET;
+import retrofit2.http.POST;
+import retrofit2.http.Query;
+import retrofit2.http.QueryMap;
+
+/** 后端 REST API 定义 */
+public interface MpptApi {
+    @GET("api/data")
+    Call<RealtimeData> getRealtime(@Query("device_addr") int deviceAddr);
+
+    @GET("api/history")
+    Call<HistoryData> getHistory(@QueryMap Map<String, String> params);
+
+    @GET("api/health")
+    Call<Map<String, Object>> getHealth();
+
+    @GET("api/status")
+    Call<Map<String, Object>> getStatus();
+
+    @POST("api/settings")
+    Call<SettingResult> writeSetting(@retrofit2.http.Body SettingRequest body);
+
+    class SettingRequest {
+        public int device_addr;
+        public String param;
+        public int value;
+
+        public SettingRequest(int deviceAddr, String param, int value) {
+            this.device_addr = deviceAddr;
+            this.param = param;
+            this.value = value;
+        }
+    }
+}

+ 101 - 0
app/src/main/java/com/zhonjin/mppt/fragment/ConnectFragment.java

@@ -0,0 +1,101 @@
+package com.zhonjin.mppt.fragment;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.fragment.app.Fragment;
+
+import com.zhonjin.mppt.R;
+import com.zhonjin.mppt.api.ApiClient;
+import com.zhonjin.mppt.api.MpptApi;
+
+import java.util.Map;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/** 连接配置 Tab:配置服务器地址并测试连接 */
+public class ConnectFragment extends Fragment {
+
+    private EditText etHost;
+    private EditText etPort;
+    private TextView tvResult;
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+                             Bundle savedInstanceState) {
+        View v = inflater.inflate(R.layout.fragment_connect, container, false);
+        etHost = v.findViewById(R.id.et_host);
+        etPort = v.findViewById(R.id.et_port);
+        tvResult = v.findViewById(R.id.tv_connect_result);
+        Button btnSave = v.findViewById(R.id.btn_save);
+        Button btnTest = v.findViewById(R.id.btn_test);
+
+        SharedPreferences sp = requireContext().getSharedPreferences("mppt_prefs", 0);
+        String base = sp.getString("base_url", "");
+        if (!TextUtils.isEmpty(base)) {
+            try {
+                String h = base.replace("http://", "").replace("https://", "").split(":")[0];
+                int port = Integer.parseInt(base.replace("http://", "")
+                        .replace("https://", "").split(":")[1].replace("/", ""));
+                etHost.setText(h);
+                etPort.setText(String.valueOf(port));
+            } catch (Exception ignored) {
+            }
+        }
+
+        btnSave.setOnClickListener(v1 -> {
+            String host = etHost.getText().toString().trim();
+            String port = etPort.getText().toString().trim();
+            if (TextUtils.isEmpty(host) || TextUtils.isEmpty(port)) {
+                Toast.makeText(getContext(), "请输入主机和端口", Toast.LENGTH_SHORT).show();
+                return;
+            }
+            String baseUrl = "http://" + host + ":" + port + "/";
+            sp.edit().putString("base_url", baseUrl).apply();
+            ApiClient.init(sp);
+            tvResult.setText("已保存:" + baseUrl);
+            Toast.makeText(getContext(), "服务器地址已保存", Toast.LENGTH_SHORT).show();
+        });
+
+        btnTest.setOnClickListener(v1 -> {
+            String host = etHost.getText().toString().trim();
+            String port = etPort.getText().toString().trim();
+            if (TextUtils.isEmpty(host) || TextUtils.isEmpty(port)) {
+                Toast.makeText(getContext(), "请输入主机和端口", Toast.LENGTH_SHORT).show();
+                return;
+            }
+            tvResult.setText("正在测试连接...");
+            String baseUrl = "http://" + host + ":" + port + "/";
+            sp.edit().putString("base_url", baseUrl).apply();
+            ApiClient.init(sp);
+            MpptApi api = ApiClient.get().api();
+            api.getHealth().enqueue(new Callback<Map<String, Object>>() {
+                @Override
+                public void onResponse(Call<Map<String, Object>> call,
+                                       Response<Map<String, Object>> response) {
+                    if (response.isSuccessful() && response.body() != null) {
+                        tvResult.setText("连接成功:" + response.body().toString());
+                    } else {
+                        tvResult.setText("连接失败(HTTP " + response.code() + ")");
+                    }
+                }
+
+                @Override
+                public void onFailure(Call<Map<String, Object>> call, Throwable t) {
+                    tvResult.setText("连接失败:" + t.getMessage());
+                }
+            });
+        });
+        return v;
+    }
+}

+ 54 - 0
app/src/main/java/com/zhonjin/mppt/fragment/HistoryAdapter.java

@@ -0,0 +1,54 @@
+package com.zhonjin.mppt.fragment;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.zhonjin.mppt.R;
+import com.zhonjin.mppt.model.HistoryData;
+
+import java.util.List;
+import java.util.Locale;
+
+public class HistoryAdapter extends RecyclerView.Adapter<HistoryAdapter.Holder> {
+    private final List<HistoryData.HistoryItem> items;
+
+    public HistoryAdapter(List<HistoryData.HistoryItem> items) {
+        this.items = items;
+    }
+
+    @NonNull
+    @Override
+    public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        View v = LayoutInflater.from(parent.getContext())
+                .inflate(R.layout.item_history, parent, false);
+        return new Holder(v);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Holder h, int position) {
+        HistoryData.HistoryItem it = items.get(position);
+        h.tvTime.setText(it.ts == null ? "" : it.ts);
+        h.tvBody.setText(String.format(Locale.US,
+                "电压 %.1f V · 功率 %.1f W · SOC %d%% · 温度 %d℃",
+                it.batteryVoltage, it.power, it.batterySoc, it.temperature));
+    }
+
+    @Override
+    public int getItemCount() {
+        return items.size();
+    }
+
+    static class Holder extends RecyclerView.ViewHolder {
+        TextView tvTime, tvBody;
+        Holder(@NonNull View v) {
+            super(v);
+            tvTime = v.findViewById(R.id.tv_h_time);
+            tvBody = v.findViewById(R.id.tv_h_body);
+        }
+    }
+}

+ 134 - 0
app/src/main/java/com/zhonjin/mppt/fragment/HistoryFragment.java

@@ -0,0 +1,134 @@
+package com.zhonjin.mppt.fragment;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.zhonjin.mppt.R;
+import com.zhonjin.mppt.api.ApiClient;
+import com.zhonjin.mppt.model.HistoryData;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/** 历史查询 Tab:默认昨天0点到今天,WebView 图表 + 列表 */
+public class HistoryFragment extends Fragment {
+
+    private WebView webView;
+    private RecyclerView recycler;
+    private HistoryAdapter adapter;
+    private final List<HistoryData.HistoryItem> items = new ArrayList<>();
+
+    @Nullable
+    @Override
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+                             @Nullable Bundle savedInstanceState) {
+        View v = inflater.inflate(R.layout.fragment_history, container, false);
+        webView = v.findViewById(R.id.chart_webview);
+        recycler = v.findViewById(R.id.history_list);
+        Button btnQuery = v.findViewById(R.id.btn_history_query);
+        TextView tvRange = v.findViewById(R.id.tv_history_range);
+
+        webView.getSettings().setJavaScriptEnabled(true);
+        webView.getSettings().setDomStorageEnabled(true);
+        webView.setWebViewClient(new WebViewClient());
+
+        recycler.setLayoutManager(new LinearLayoutManager(getContext()));
+        adapter = new HistoryAdapter(items);
+        recycler.setAdapter(adapter);
+
+        // 默认:昨天00:00 ~ 今天(当前时间)
+        tvRange.setText("区间:今天");
+        btnQuery.setOnClickListener(this::queryHistory);
+        return v;
+    }
+
+    private void queryHistory(View v) {
+        Map<String, String> params = new HashMap<>();
+        params.put("start", new SimpleDateFormat("yyyy-MM-dd").format(new Date()));
+        params.put("end", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+        params.put("page", "1");
+        params.put("page_size", "100");
+
+        ApiClient.get().api().getHistory(params).enqueue(new Callback<HistoryData>() {
+            @Override
+            public void onResponse(Call<HistoryData> call, Response<HistoryData> response) {
+                if (response.isSuccessful() && response.body() != null) {
+                    HistoryData hd = response.body();
+                    items.clear();
+                    if (hd.data != null) items.addAll(hd.data);
+                    adapter.notifyDataSetChanged();
+                    loadChart(items);
+                } else {
+                    Toast.makeText(getContext(), "查询失败 HTTP " + response.code(), Toast.LENGTH_SHORT).show();
+                }
+            }
+
+            @Override
+            public void onFailure(Call<HistoryData> call, Throwable t) {
+                Toast.makeText(getContext(), "查询失败:" + t.getMessage(), Toast.LENGTH_SHORT).show();
+            }
+        });
+    }
+
+    private void loadChart(List<HistoryData.HistoryItem> list) {
+        StringBuilder data = new StringBuilder("[");
+        for (int i = 0; i < list.size(); i++) {
+            HistoryData.HistoryItem it = list.get(i);
+            if (i > 0) data.append(",");
+            data.append('{').append("p:").append(it.power)
+                    .append(",v:").append(it.batteryVoltage)
+                    .append(",soc:").append(it.batterySoc)
+                    .append(",t:\"").append(esc(it.ts)).append("\"}");
+        }
+        data.append("]");
+
+        String html = "<!DOCTYPE html><meta charset='utf-8'><style>"
+                + "body{margin:0;font:12px sans-serif;background:#fff}"
+                + "canvas{display:block;margin:8px}"
+                + "</style><body><script>"
+                + "var D=" + data + ";"
+                + "var c=document.createElement('canvas');c.width=940;c.height=320;"
+                + "document.body.appendChild(c);var x=c.getContext('2d');"
+                + "function ymin(key){var m=D[0]?D[0][key]:0;for(var i=1;i<D.length;i++)m=Math.min(m,D[i][key]);return m;}"
+                + "function ymax(key){var m=D[0]?D[0][key]:0;for(var i=1;i<D.length;i++)m=Math.max(m,D[i][key]);return m;}"
+                + "function line(key,col){var mi=ymin(key),ma=ymax(key),n=Math.max(1,D.length-1);"
+                + "x.strokeStyle=col;x.lineWidth=2;x.beginPath();"
+                + "for(var i=0;i<D.length;i++){var px=30+i/n*870;var py=300-(D[i][key]-mi)/((ma-mi)||1)*250;"
+                + "i?x.lineTo(px,py):x.moveTo(px,py);}x.stroke();}"
+                + "x.strokeStyle='#ddd';x.strokeRect(30,50,870,250);"
+                + "line('p','#e53935');line('v','#1e88e5');line('soc','#43a047');"
+                + "</script>"
+                + "<style>.lg{font:12px sans-serif} .r{display:inline-block;width:12px;height:12px;margin:0 4px 0 14px;vertical-align:middle}</style>"
+                + "<div class='lg'><span class='r' style='background:#e53935'></span>功率W"
+                + "<span class='r' style='background:#1e88e5'></span>电压V"
+                + "<span class='r' style='background:#43a047'></span>SOC%</div>"
+                + "</body>";
+        webView.loadDataWithBaseURL(null, html, "text/html", "utf-8", null);
+    }
+
+    private String esc(String s) {
+        return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
+    }
+}

+ 122 - 0
app/src/main/java/com/zhonjin/mppt/fragment/MonitorFragment.java

@@ -0,0 +1,122 @@
+package com.zhonjin.mppt.fragment;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.fragment.app.Fragment;
+
+import com.zhonjin.mppt.R;
+import com.zhonjin.mppt.api.ApiClient;
+import com.zhonjin.mppt.model.RealtimeData;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/** 实时监控 Tab:每 2 秒轮询一次 /api/data */
+public class MonitorFragment extends Fragment {
+
+    private TextView tvUpdate, tvBattery, tvPv, tvPower, tvSoc, tvTemp, tvGen, tvState, tvFault;
+    private final Handler handler = new Handler(Looper.getMainLooper());
+    private boolean active = true;
+    private final Runnable poller = new Runnable() {
+        @Override
+        public void run() {
+            fetchRealtime();
+            handler.postDelayed(this, 2000);
+        }
+    };
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+                             Bundle savedInstanceState) {
+        View v = inflater.inflate(R.layout.fragment_monitor, container, false);
+        tvUpdate = v.findViewById(R.id.tv_update);
+        tvBattery = v.findViewById(R.id.tv_battery);
+        tvPv = v.findViewById(R.id.tv_pv);
+        tvPower = v.findViewById(R.id.tv_power);
+        tvSoc = v.findViewById(R.id.tv_soc);
+        tvTemp = v.findViewById(R.id.tv_temp);
+        tvGen = v.findViewById(R.id.tv_gen);
+        tvState = v.findViewById(R.id.tv_state);
+        tvFault = v.findViewById(R.id.tv_fault);
+        return v;
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        active = true;
+        handler.removeCallbacks(poller);
+        handler.post(poller);
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        active = false;
+        handler.removeCallbacks(poller);
+    }
+
+    private void fetchRealtime() {
+        try {
+            ApiClient.get().api().getRealtime(1)
+                    .enqueue(new Callback<RealtimeData>() {
+                        @Override
+                        public void onResponse(Call<RealtimeData> call, Response<RealtimeData> response) {
+                            if (response.isSuccessful() && response.body() != null) {
+                                render(response.body().data);
+                            }
+                        }
+
+                        @Override
+                        public void onFailure(Call<RealtimeData> call, Throwable t) {
+                            if (active && isAdded()) {
+                                tvUpdate.setText(now() + " | 请求失败");
+                            }
+                        }
+                    });
+        } catch (Exception e) {
+            if (active && isAdded()) {
+                tvUpdate.setText(now() + " | 未初始化");
+            }
+        }
+    }
+
+    private void render(RealtimeData.DeviceData d) {
+        if (d == null) return;
+        tvUpdate.setText("更新时间:" + now());
+        tvBattery.setText(String.format(Locale.US, "%.1f V", d.batteryVoltage));
+        tvPv.setText(String.format(Locale.US, "%.1f V / %.1f A", d.pvVoltage, d.pvCurrent));
+        tvPower.setText(String.format(Locale.US, "%.1f W", d.power));
+        tvSoc.setText(d.batterySoc + " %");
+        tvTemp.setText(d.temperature + " \u2103");
+        tvGen.setText(String.format(Locale.US, "%.1f kWh", d.totalGeneration));
+        tvState.setText(chargeStateText(d.chargeState));
+        tvFault.setText(d.faultCode == 0 ? "正常" : "故障码 " + d.faultCode);
+    }
+
+    private String chargeStateText(int s) {
+        switch (s) {
+            case 1: return "充电中";
+            case 2: return "浮充";
+            case 3: return "停充";
+            case 0: return "待机";
+            default: return "状态 " + s;
+        }
+    }
+
+    private String now() {
+        return new SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(new Date());
+    }
+}

+ 99 - 0
app/src/main/java/com/zhonjin/mppt/fragment/SettingsFragment.java

@@ -0,0 +1,99 @@
+package com.zhonjin.mppt.fragment;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.zhonjin.mppt.R;
+import com.zhonjin.mppt.api.ApiClient;
+import com.zhonjin.mppt.api.MpptApi;
+import com.zhonjin.mppt.model.SettingResult;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/** 设备参数设置 Tab:选择参数并写入 */
+public class SettingsFragment extends Fragment {
+
+    private Spinner spParam;
+    private EditText etValue;
+    private TextView tvResult;
+
+    private static final String[] PARAMS = {
+            "fault_clear_reset", "charge_limit_voltage", "float_voltage",
+            "battery_type", "max_charge_current", "fault_recovery_timer"
+    };
+    private static final String[] PARAM_LABELS = {
+            "故障清除/复位", "充电限制电压(0.1V)", "浮充电压(0.1V)",
+            "电池类型(0-5)", "最大充电电流(0.1A)", "故障恢复定时器(分钟)"
+    };
+
+    @Nullable
+    @Override
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+                             @Nullable Bundle savedInstanceState) {
+        View v = inflater.inflate(R.layout.fragment_settings, container, false);
+        spParam = v.findViewById(R.id.sp_param);
+        etValue = v.findViewById(R.id.et_value);
+        tvResult = v.findViewById(R.id.tv_set_result);
+        Button btnWrite = v.findViewById(R.id.btn_write);
+
+        ArrayAdapter<String> adapter = new ArrayAdapter<>(requireContext(),
+                android.R.layout.simple_spinner_item, PARAM_LABELS);
+        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        spParam.setAdapter(adapter);
+
+        btnWrite.setOnClickListener(v1 -> doWrite());
+        return v;
+    }
+
+    private void doWrite() {
+        int idx = spParam.getSelectedItemPosition();
+        String valStr = etValue.getText().toString().trim();
+        if (idx < 0 || valStr.isEmpty()) {
+            Toast.makeText(getContext(), "请选择参数并输入数值", Toast.LENGTH_SHORT).show();
+            return;
+        }
+        int value;
+        try {
+            value = Integer.parseInt(valStr);
+        } catch (NumberFormatException e) {
+            Toast.makeText(getContext(), "请输入整数", Toast.LENGTH_SHORT).show();
+            return;
+        }
+
+        ApiClient.get().api().writeSetting(
+                new MpptApi.SettingRequest(1, PARAMS[idx], value))
+                .enqueue(new Callback<SettingResult>() {
+                    @Override
+                    public void onResponse(Call<SettingResult> call,
+                                           Response<SettingResult> response) {
+                        if (response.isSuccessful() && response.body() != null) {
+                            SettingResult r = response.body();
+                            tvResult.setText("参数:" + PARAM_LABELS[idx]
+                                    + "\n状态:写" + (r.success ? "成功" : "失败")
+                                    + (r.message != null ? "\n" + r.message : ""));
+                        } else {
+                            tvResult.setText("写入失败(HTTP " + response.code() + ")");
+                        }
+                    }
+
+                    @Override
+                    public void onFailure(Call<SettingResult> call, Throwable t) {
+                        tvResult.setText("写入失败:" + t.getMessage());
+                    }
+                });
+    }
+}

+ 38 - 0
app/src/main/java/com/zhonjin/mppt/model/HistoryData.java

@@ -0,0 +1,38 @@
+package com.zhonjin.mppt.model;
+
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+
+/** 历史数据模型 - 对应后端 /api/history */
+public class HistoryData {
+    @SerializedName("data")
+    public List<HistoryItem> data;
+
+    @SerializedName("total")
+    public long total;
+
+    @SerializedName("page")
+    public int page;
+
+    @SerializedName("page_size")
+    public int pageSize;
+
+    public static class HistoryItem {
+        @SerializedName("id")
+        public long id;
+        @SerializedName("ts")
+        public String ts;
+        @SerializedName("battery_voltage")
+        public double batteryVoltage;
+        @SerializedName("pv_voltage")
+        public double pvVoltage;
+        @SerializedName("pv_current")
+        public double pvCurrent;
+        @SerializedName("power")
+        public double power;
+        @SerializedName("battery_soc")
+        public int batterySoc;
+        @SerializedName("temperature")
+        public int temperature;
+    }
+}

+ 43 - 0
app/src/main/java/com/zhonjin/mppt/model/RealtimeData.java

@@ -0,0 +1,43 @@
+package com.zhonjin.mppt.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/** 实时数据模型 - 对应后端 /api/data */
+public class RealtimeData {
+    @SerializedName("data")
+    public DeviceData data;
+
+    @SerializedName("timestamp")
+    public String timestamp;
+
+    @SerializedName("connected")
+    public boolean connected;
+
+    @SerializedName("online")
+    public boolean online;
+
+    public static class DeviceData {
+        @SerializedName("battery_voltage")
+        public double batteryVoltage;
+        @SerializedName("pv_voltage")
+        public double pvVoltage;
+        @SerializedName("pv_current")
+        public double pvCurrent;
+        @SerializedName("total_generation")
+        public double totalGeneration;
+        @SerializedName("temperature")
+        public int temperature;
+        @SerializedName("fault_code")
+        public int faultCode;
+        @SerializedName("battery_soc")
+        public int batterySoc;
+        @SerializedName("battery_strings")
+        public int batteryStrings;
+        @SerializedName("charge_state")
+        public int chargeState;
+        @SerializedName("device_type")
+        public int deviceType;
+        @SerializedName("power")
+        public double power;
+    }
+}

+ 21 - 0
app/src/main/java/com/zhonjin/mppt/model/SettingResult.java

@@ -0,0 +1,21 @@
+package com.zhonjin.mppt.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/** 设备参数设置模型 - 对应后端 /api/settings */
+public class SettingResult {
+    @SerializedName("success")
+    public boolean success;
+
+    @SerializedName("device_addr")
+    public int deviceAddr;
+
+    @SerializedName("param")
+    public String param;
+
+    @SerializedName("value")
+    public int value;
+
+    @SerializedName("message")
+    public String message;
+}

+ 7 - 0
app/src/main/res/drawable/card_bg.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#FFFFFF" />
+    <corners android:radius="10dp" />
+    <stroke android:width="1dp" android:color="#EEEEEE" />
+</shape>

+ 7 - 0
app/src/main/res/drawable/ic_connect.xml

@@ -0,0 +1,7 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp" android:height="24dp"
+    android:viewportWidth="24" android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+    <path android:fillColor="#FF000000"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/>
+</vector>

+ 7 - 0
app/src/main/res/drawable/ic_history.xml

@@ -0,0 +1,7 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp" android:height="24dp"
+    android:viewportWidth="24" android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+    <path android:fillColor="#FF000000"
+        android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,9v5l3.5,2.1 0.7,-1.2L13,13V9L12,9z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/ic_launcher.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp" android:height="48dp"
+    android:viewportWidth="48" android:viewportHeight="48">
+    <path android:fillColor="#1E88E5"
+        android:pathData="M0,0h48v48h-48z"/>
+    <path android:fillColor="#FFFFFF"
+        android:pathData="M12,11h24v7h-24zM12,20h24v7h-24zM12,29h15v7h-15z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp" android:height="108dp"
+    android:viewportWidth="108" android:viewportHeight="108">
+    <path android:fillColor="#1E88E5"
+        android:pathData="M0,0h108v108h-108z"/>
+    <path android:fillColor="#FFFFFF"
+        android:pathData="M28,26h52v16h-52zM28,46h52v16h-52zM28,66h32v16h-32z"/>
+</vector>

+ 7 - 0
app/src/main/res/drawable/ic_monitor.xml

@@ -0,0 +1,7 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp" android:height="24dp"
+    android:viewportWidth="24" android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+    <path android:fillColor="#FF000000"
+        android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.81 -4.7,-4.4z"/>
+</vector>

+ 7 - 0
app/src/main/res/drawable/ic_settings.xml

@@ -0,0 +1,7 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp" android:height="24dp"
+    android:viewportWidth="24" android:viewportHeight="24"
+    android:tint="?attr/colorControlNormal">
+    <path android:fillColor="#FF000000"
+        android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
+</vector>

+ 26 - 0
app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <FrameLayout
+        android:id="@+id/container"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toTopOf="@id/bottom_nav"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+    <com.google.android.material.bottomnavigation.BottomNavigationView
+        android:id="@+id/bottom_nav"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:menu="@menu/bottom_nav" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 72 - 0
app/src/main/res/layout/fragment_connect.xml

@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:padding="16dp">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="服务器连接配置"
+            android:textSize="18sp"
+            android:textStyle="bold" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="主机地址"
+            android:paddingTop="16dp" />
+        <EditText
+            android:id="@+id/et_host"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="例如 discover.zhonjin.com"
+            android:inputType="text" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="端口"
+            android:paddingTop="12dp" />
+        <EditText
+            android:id="@+id/et_port"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:hint="8085"
+            android:inputType="number" />
+
+        <TextView
+            android:id="@+id/tv_connect_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingTop="16dp"
+            android:text="尚未测试连接"
+            android:textColor="#666" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:gravity="end"
+            android:paddingTop="20dp">
+
+            <Button
+                android:id="@+id/btn_test"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginEnd="12dp"
+                android:text="测试连接" />
+
+            <Button
+                android:id="@+id/btn_save"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="保存" />
+        </LinearLayout>
+    </LinearLayout>
+</ScrollView>

+ 35 - 0
app/src/main/res/layout/fragment_history.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:padding="12dp">
+
+    <TextView
+        android:id="@+id/tv_history_range"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="区间:今天"
+        android:textStyle="bold"
+        android:textSize="15sp" />
+
+    <Button
+        android:id="@+id/btn_history_query"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:text="查询历史数据(今日)" />
+
+    <WebView
+        android:id="@+id/chart_webview"
+        android:layout_width="match_parent"
+        android:layout_height="260dp"
+        android:layout_marginTop="12dp" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/history_list"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:layout_marginTop="12dp" />
+</LinearLayout>

+ 88 - 0
app/src/main/res/layout/fragment_monitor.xml

@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:padding="16dp">
+
+        <TextView
+            android:id="@+id/tv_update"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="更新时间:--"
+            android:textColor="#999"
+            android:textSize="12sp" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:paddingTop="16dp">
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="电池电压" />
+                <TextView android:id="@+id/tv_battery" style="@style/value" android:text="--" />
+            </LinearLayout>
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="电量 SOC" />
+                <TextView android:id="@+id/tv_soc" style="@style/value" android:text="--" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:paddingTop="8dp">
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="光伏输入" />
+                <TextView android:id="@+id/tv_pv" style="@style/value" android:text="--" />
+            </LinearLayout>
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="充电功率" />
+                <TextView android:id="@+id/tv_power" style="@style/value" android:text="--" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:paddingTop="8dp">
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="机器温度" />
+                <TextView android:id="@+id/tv_temp" style="@style/value" android:text="--" />
+            </LinearLayout>
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="累计发电" />
+                <TextView android:id="@+id/tv_gen" style="@style/value" android:text="--" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:paddingTop="8dp">
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="充电状态" />
+                <TextView android:id="@+id/tv_state" style="@style/value" android:text="--" />
+            </LinearLayout>
+            <LinearLayout style="@style/wrap"
+                android:layout_weight="1">
+                <TextView android:text="故障" />
+                <TextView android:id="@+id/tv_fault" style="@style/value" android:text="--" />
+            </LinearLayout>
+        </LinearLayout>
+    </LinearLayout>
+</ScrollView>

+ 52 - 0
app/src/main/res/layout/fragment_settings.xml

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:padding="16dp">
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="设备参数写入"
+        android:textSize="17sp"
+        android:textStyle="bold" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="参数"
+        android:paddingTop="14dp" />
+    <Spinner
+        android:id="@+id/sp_param"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="数值"
+        android:paddingTop="12dp" />
+    <EditText
+        android:id="@+id/et_value"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:hint="请输入整数"
+        android:inputType="number" />
+
+    <Button
+        android:id="@+id/btn_write"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="20dp"
+        android:layout_gravity="end"
+        android:text="写入参数" />
+
+    <TextView
+        android:id="@+id/tv_set_result"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:paddingTop="16dp"
+        android:text="尚未写入"
+        android:textColor="#666" />
+</LinearLayout>

+ 29 - 0
app/src/main/res/layout/item_history.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="12dp">
+
+    <TextView
+        android:id="@+id/tv_h_time"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textColor="#333"
+        android:textSize="13sp"
+        android:textStyle="bold" />
+
+    <TextView
+        android:id="@+id/tv_h_body"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="4dp"
+        android:textColor="#666"
+        android:textSize="13sp" />
+
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="1dp"
+        android:layout_marginTop="8dp"
+        android:background="#EEEEEE" />
+</LinearLayout>

+ 19 - 0
app/src/main/res/menu/bottom_nav.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/nav_connect"
+        android:icon="@drawable/ic_connect"
+        android:title="连接" />
+    <item
+        android:id="@+id/nav_monitor"
+        android:icon="@drawable/ic_monitor"
+        android:title="实时监控" />
+    <item
+        android:id="@+id/nav_history"
+        android:icon="@drawable/ic_history"
+        android:title="历史" />
+    <item
+        android:id="@+id/nav_settings"
+        android:icon="@drawable/ic_settings"
+        android:title="设置" />
+</menu>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_bg" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 4 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_bg">#1E88E5</color>
+</resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">MPPT监控</string>
+</resources>

+ 26 - 0
app/src/main/res/values/styles.xml

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.MPPTMonitor" parent="Theme.MaterialComponents.DayNight.NoActionBar">
+        <item name="colorPrimary">#1E88E5</item>
+        <item name="colorPrimaryVariant">#1565C0</item>
+        <item name="colorOnPrimary">#FFFFFF</item>
+    </style>
+
+    <style name="wrap">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:layout_height">wrap_content</item>
+        <item name="android:orientation">vertical</item>
+        <item name="android:background">@drawable/card_bg</item>
+        <item name="android:padding">12dp</item>
+        <item name="android:layout_margin">4dp</item>
+    </style>
+
+    <style name="value">
+        <item name="android:layout_width">wrap_content</item>
+        <item name="android:layout_height">wrap_content</item>
+        <item name="android:textSize">20sp</item>
+        <item name="android:textStyle">bold</item>
+        <item name="android:textColor">#1E88E5</item>
+        <item name="android:paddingTop">4dp</item>
+    </style>
+</resources>

+ 8 - 0
app/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+    <base-config cleartextTrafficPermitted="true">
+        <trust-anchors>
+            <certificates src="system" />
+        </trust-anchors>
+    </base-config>
+</network-security-config>

+ 4 - 0
build.gradle

@@ -0,0 +1,4 @@
+// Top-level build file
+plugins {
+    id 'com.android.application' version '8.5.2' apply false
+}

+ 5 - 0
gradle.properties

@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+org.gradle.daemon=false
+org.gradle.parallel=false

+ 16 - 0
settings.gradle

@@ -0,0 +1,16 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+rootProject.name = "MPPTMonitor"
+include ':app'