23 Commits
1.0.0 ... 1.1.0

Author SHA1 Message Date
Zhanghu
6c0204325f 修复闹钟没有循环的bug 2026-03-13 09:07:26 +08:00
Zhanghu
08144f5106 小改动 2026-03-12 15:33:49 +08:00
Zhanghu
40bfbd8ceb 定时开关机优化,支持三种模式 2026-03-12 15:28:44 +08:00
Zhanghu
98dba95232 去掉仪表盘风格
url 设置修改
串口命令发送3次,避免发送失败
2026-03-12 13:35:47 +08:00
Zhanghu
2a5a6e95b4 去掉仪表盘风格 2026-03-12 08:55:46 +08:00
Zhanghu
691d6c15ad * 设置界面增加电源开关测试
* App 启动时默认不发送开机指令
2026-03-10 10:45:46 +08:00
Zhanghu
cac9dd41aa 如果串口路径不为空时则开关一次设备 2026-03-05 14:47:07 +08:00
Zhanghu
d29aa0dfd1 * 仅串口路径为空时才自动检测设备
* 设置界面可清除串口路径
2026-03-05 14:04:38 +08:00
Zhanghu
5b0eb711a6 changelog 2026-01-05 16:57:27 +08:00
Zhanghu
dd03bbfcea 自动检测串口,检测时保证发送一次开机指令; 增加 acra 2026-01-05 15:39:16 +08:00
Zhanghu
80341f540f 当选择自动跳转时,直接访问 URL 前缀,这样网址自由设置 2025-11-01 10:03:36 +08:00
Zhanghu
cb151b989e fix retry count 2025-10-31 17:32:58 +08:00
Zhanghu
d450cf4933 changelog 2025-10-11 17:12:39 +08:00
Zhanghu
d4f3340383 小改动 2025-10-11 09:47:04 +08:00
Zhanghu
bdb20c2130 changelog 2025-10-11 08:45:08 +08:00
Zhanghu
605e482199 翻译 2025-10-10 16:11:34 +08:00
Zhanghu
3aa55dd875 小改动 2025-10-10 16:09:06 +08:00
Zhanghu
31a82e5461 优化图标和翻译 2025-10-10 16:05:25 +08:00
Zhanghu
4551fe1f5a * 设置界面支持切换到不同类型的仪表盘地址,默认是自动跳转
* 监听遥控器 F8 按键,打开设置界面
* webView 禁止缩放,避免受到安卓字体和dpi缩放的影响
2025-10-10 15:42:24 +08:00
Zhanghu
80a8226c80 readme 2025-09-16 16:58:40 +08:00
Zhanghu
598e1ed3cc changelog 2025-09-16 11:56:57 +08:00
Zhanghu
ec2ab0fa64 app 启动时发送一次电视开机命令 2025-09-16 11:54:00 +08:00
Zhanghu
616c15c408 changelog 2025-09-12 18:17:17 +08:00
38 changed files with 1515 additions and 470 deletions

View File

@@ -19,7 +19,6 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// sign the application
signingConfigs {
release {
@@ -51,16 +50,15 @@ android {
viewBinding true
}
android.applicationVariants.all { variant ->
def docDir = rootProject.getRootDir().getAbsolutePath() + "/../apk/dashboard"
def releaseDir = rootProject.getRootDir().getAbsolutePath() + "/../apk/dashboard"
def docDir = rootProject.getRootDir().getAbsolutePath() + "/../apk/dashboardclient"
def releaseDir = rootProject.getRootDir().getAbsolutePath() + "/../apk/dashboardclient"
if (versionName.contains("beta"))
releaseDir += "/beta"
variant.outputs.all {
// 常规版本不加后缀
outputFileName = "dashboard_${defaultConfig.versionName}.apk"
outputFileName = "dashboardclient_${defaultConfig.versionName}.apk"
}
// assemble 结束后将apk复制到指定目录
@@ -72,7 +70,7 @@ android {
into releaseDir
}
def downloadUrl = "http://thinkdisk.thinkbo.cn/src/web/dashboard/${outputFileName}"
def downloadUrl = "http://thinkdisk.thinkbo.cn/src/web/dashboardclient/${outputFileName}"
genUpdateJson(downloadUrl, releaseDir, outputFileName, 'changelog.md', defaultConfig, variant)
File file = new File("${project.getProjectDir().path}/changelog.md")
@@ -98,6 +96,8 @@ repositories {
}
dependencies {
implementation libs.serialport
implementation libs.acra.http
implementation libs.appcompat
implementation libs.material
implementation libs.activity
@@ -113,6 +113,4 @@ dependencies {
implementation libs.banner
implementation libs.glide
annotationProcessor libs.glidecompiler
implementation 'io.github.xmaihh:serialport:2.1.1'
}

View File

@@ -1,5 +1,5 @@
---
title: "看板APP更新日志和文件下载"
title: "仪表盘 APP 更新日志和文件下载"
author:
- 宁波升维信息技术有限公司
---
@@ -9,11 +9,73 @@ author:
2. $ {VERSION_CODE} (去掉空格),会自动替换实际修订号,比如 1.1.4.$ {VERSION_CODE}
-->
### [1.0.0.${VERSION_CODE}] - 2025.9.12
### [1.1.0.${VERSION_CODE}] - 2026.3.13
#### 文件下载
* [dashboard_1.0.0.apk](dashboard_1.0.0.apk)
* [dashboardclient_1.1.0.apk](dashboardclient_1.1.0.apk)
#### 更新记录
* WebView 启用 LocalStorage支持新版前端
* 优化定时开关机逻辑,增加不控制和本地控制
* 命令发送3次避免命令丢失
* 支持老款网关检测
### [1.0.5.26] - 2026.3.5
#### 文件下载
* [dashboardclient_1.0.5.apk](dashboardclient_1.0.5.apk)
#### 更新记录
* App 启动时如果串口路径为空时自动检测设备, 否则开关一次设备
* 设置界面可清除串口路径
### [1.0.4.23] - 2026.1.5
#### 文件下载
* [dashboardclient_1.0.4.apk](dashboardclient_1.0.4.apk)
#### 更新记录
* 自动检测串口,检测时保证发送一次开机指令
* 增加 acra
### [1.0.3.22] - 2025.11.1
#### 文件下载
* [dashboardclient_1.0.3.apk](dashboardclient_1.0.3.apk)
#### 更新记录
* 当选择自动跳转时,直接访问 URL 前缀,这样网址自由设置
### [1.0.2.19] - 2025.10.11
#### 文件下载
* [dashboardclient_1.0.2.apk](dashboardclient_1.0.2.apk)
#### 更新记录
* 设置界面支持切换到不同类型的仪表盘地址,默认是自动跳转
* 监听遥控器 F8 按键,打开设置界面
* webView 禁止缩放避免受到安卓字体和dpi缩放的影响
* 优化图标和翻译
### [1.0.1.12] - 2025.9.16
#### 文件下载
* [dashboardclient_1.0.1.apk](dashboardclient_1.0.1.apk)
#### 更新记录
* APP 启动时,发送一次电视开机指令
### [1.0.0.9] - 2025.9.12
#### 文件下载
* [dashboardclient_1.0.0.apk](dashboard_1.0.0.apk)
#### 更新记录
* 初版

View File

@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -28,7 +29,6 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
@@ -54,5 +54,4 @@
</intent-filter>
</receiver>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,46 @@
/*************************************************************************
* 版权所有 (C) 2026宁波升维信息技术有限公司. 保留所有权利.
*
* 本软件是由 宁波升维信息技术有限公司 开发的。
* 未经版权所有者明确授权,任何人不得使用、复制、修改、分发本软件及其相关文档。
* 本软件包含机密和专有信息,未经授权不得向任何第三方披露。
*************************************************************************/
package cn.ykbox.dashboard;
import android.app.Application;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.acra.config.HttpSenderConfigurationBuilder;
import org.acra.data.StringFormat;
public class MyApplication extends Application {
public static final String TAG = "MyApplication";
@Override
public void onCreate() {
super.onCreate();
initLog();
initAcra();
}
private void initLog() {
}
private void initAcra() {
ACRA.init(this, new CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig.class)
.withReportFormat(StringFormat.JSON)
.withPluginConfigurations(
new HttpSenderConfigurationBuilder()
.withBasicAuthLogin("u9UEf5W0giZOBrtR")
.withBasicAuthPassword("B39uMIhh15cCoOK7")
.withUri("http://acra.slackz.cn/report")
.build()
)
);
}
}

View File

@@ -1,16 +1,16 @@
package cn.ykbox.dashboard.activity;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.webkit.WebResourceError;
@@ -25,40 +25,33 @@ import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.preference.PreferenceManager;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Calendar;
import cn.ykbox.dashboard.ConfigReader;
import cn.ykbox.dashboard.alarm.PowerAlarmManager;
import cn.ykbox.dashboard.databinding.ActivityBuildingDashboardBinding;
import cn.ykbox.dashboard.receiver.CommandBroadcastReceiver;
import cn.ykbox.dashboard.perferences.PreferenceConfiguration;
import cn.ykbox.dashboard.serial.SerialPortDetector;
public class BuildingDashboardActivity extends FullscreenActivity {
private final static String TAG = "DashboardActivity";
private static final boolean AUTO_HIDE = true;
private static final int AUTO_HIDE_DELAY_MILLIS = 3000;
private static final long CONFIG_LOAD_INTERVAL = 10 * 60 * 1000; // 10 minutes
private static final int MAX_COMMAND_ALARMS = 20; // 支持的最大闹钟指令数量
private Context mContext;
private String mainUrl;
private int retryCount = 0;
private static final int MAX_RETRY = 99; // 最大重试次数
private static final int RETRY_DELAY = 60000; // 1分钟 = 60000毫秒
private String configUrl;
private ConfigReader configReader;
private String lastAppliedSerialConfig = null;
private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener;
// --- Periodic Config Loading ---
private final Handler configLoadHandler = new Handler(Looper.getMainLooper());
private Runnable configLoadRunnable;
private PowerAlarmManager powerAlarmManager;
private ActivityBuildingDashboardBinding binding;
private ActivityResultLauncher<Intent> settingsLauncher;
private SerialPortDetector detector;
private ProgressDialog progressDialog;
private final View.OnTouchListener mDelayHideTouchListener = (view, motionEvent) -> {
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
@@ -83,10 +76,21 @@ public class BuildingDashboardActivity extends FullscreenActivity {
super.setViews(binding.webview, binding.fullscreenContentControls);
super.onCreate(savedInstanceState);
initSerialPort();
initSettingsLauncher();
initListener();
initWebView();
initConfigLoader();
// 获取配置 URL
SharedPreferences pre = PreferenceManager.getDefaultSharedPreferences(this);
String urlPrefix = pre.getString("k_url_prefix", "http://172.18.22.211:8002/Dashboard");
String configUrl = urlPrefix + "/data/config.json";
// 初始化 PowerAlarmManager
powerAlarmManager = new PowerAlarmManager(this, configUrl);
powerAlarmManager.start();
initPreferenceChangeListener();
}
@Override
@@ -94,15 +98,125 @@ public class BuildingDashboardActivity extends FullscreenActivity {
super.onResume();
SharedPreferences pre = PreferenceManager.getDefaultSharedPreferences(this);
String url = pre.getString("k_url", "http://172.18.22.211:8002/Dashboard");
mainUrl = url + "/index.html";
configUrl = url + "/data/config.json";
String urlPrefix = pre.getString("k_url_prefix", "http://172.18.22.211:8002/Dashboard");
String urlPath = pre.getString("k_url_path", "/index.html");
mainUrl = urlPrefix + urlPath;
configLoadHandler.post(configLoadRunnable);
Log.i(TAG, "Main: " + mainUrl);
loadUrlWithRetry();
}
private void initSerialPort() {
String portPath = PreferenceConfiguration.getSerialPortPath(this);
int baudRate = PreferenceConfiguration.getSerialPortBaudRate(this);
boolean sendPowerOnCmd = PreferenceConfiguration.getSendPowerOnCmd(this);
// 初始化串口检测器
detector = new SerialPortDetector(portPath, baudRate);
// 只有 portPath 为空时才进行串口检测
if (TextUtils.isEmpty(portPath)) {
Log.i(TAG, "Starting serial port detection/verification...");
// 参数 true 表示确保设备开机(发送两个命令)
// 参数 false 表示仅检测串口(任一命令有响应即可)
startSerialPortDetection(true);
} else {
Log.i(TAG, "Using saved serial port path: " + portPath);
// 如果勾选了"发送打开电源指令",则先发送关闭再发送打开
if (sendPowerOnCmd) {
Log.i(TAG, "Sending power on sequence...");
new Thread(() -> {
detector.sendPowerOffCommand();
try {
Thread.sleep(5000); // 等待 5 秒
} catch (InterruptedException e) {
Log.e(TAG, "Sleep interrupted", e);
}
int loop = PreferenceConfiguration.getSerialCmdLoop(this);
detector.sendPowerOnCommand(loop);
}).start();
}
}
}
/**
* 启动串口检测
* @param ensurePowerOn 是否确保设备开机
*/
private void startSerialPortDetection(boolean ensurePowerOn) {
// 显示进度对话框
progressDialog = new ProgressDialog(this);
progressDialog.setTitle("串口检测");
progressDialog.setMessage("正在检测串口...");
progressDialog.setCancelable(false);
progressDialog.show();
// 设置检测回调
detector.setCallback(new SerialPortDetector.DetectionCallback() {
@Override
public void onDetectionStart() {
Log.d(TAG, "Detection started");
runOnUiThread(() -> {
if (progressDialog != null) {
progressDialog.setMessage("开始检测串口...");
}
});
}
@Override
public void onDetectionProgress(String portPath, int current, int total) {
Log.d(TAG, "Testing port " + current + "/" + total + ": " + portPath);
runOnUiThread(() -> {
if (progressDialog != null) {
progressDialog.setMessage("正在测试串口路径: " + portPath + "\n" + current + "/" + total);
}
});
}
@Override
public void onDetectionSuccess(String portPath) {
Log.i(TAG, "Detection success: " + portPath);
runOnUiThread(() -> {
if (progressDialog != null) {
progressDialog.dismiss();
}
Toast.makeText(BuildingDashboardActivity.this,
"串口检测成功!\n路径: " + portPath,
Toast.LENGTH_LONG).show();
PreferenceConfiguration.setSerialPortPath(BuildingDashboardActivity.this, portPath);
});
}
@Override
public void onDetectionFailed() {
Log.e(TAG, "Detection failed");
runOnUiThread(() -> {
if (progressDialog != null) {
progressDialog.dismiss();
}
// 可以显示错误界面或重试选项
showDetectionFailedDialog();
});
}
});
// 开始检测
detector.startDetection(ensurePowerOn);
}
/**
* 显示检测失败的对话框
*/
private void showDetectionFailedDialog() {
Toast.makeText(BuildingDashboardActivity.this,
"未找到有效的串口设备,请检查设备连接后重试。",
Toast.LENGTH_LONG).show();
}
private void loadUrlWithRetry() {
Log.i("WebView", "Loading " + mainUrl);
binding.webview.loadUrl(mainUrl);
}
@@ -112,7 +226,7 @@ public class BuildingDashboardActivity extends FullscreenActivity {
Log.d("WebView", "加载失败将在1分钟后重试 (第" + retryCount + "次重试)");
// 使用Handler延迟执行重试
configLoadHandler.postDelayed(new Runnable() {
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
Log.d("WebView", "开始重试加载...");
@@ -130,15 +244,37 @@ public class BuildingDashboardActivity extends FullscreenActivity {
@Override
protected void onPause() {
super.onPause();
configLoadHandler.removeCallbacks(configLoadRunnable);
// PowerAlarmManager 内部已处理配置加载的暂停
}
@Override
protected void onDestroy() {
super.onDestroy();
if (configReader != null) {
configReader.release();
if (preferenceChangeListener != null) {
PreferenceManager.getDefaultSharedPreferences(this)
.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
}
if (powerAlarmManager != null) {
powerAlarmManager.stop();
}
}
/**
* 监听按键,因为 WebView 可能会拦截功能键,这里不使用 onKeyDown
* @param event
* @return
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
Log.d(TAG, "dispatchKeyEvent:" + event.getKeyCode());
if ( event.getKeyCode() == KeyEvent.KEYCODE_F8
&& event.getAction() == KeyEvent.ACTION_DOWN) {
openSettingsActivity();
return true; // 不再向下分发
}
return super.dispatchKeyEvent(event);
}
private void initListener() {
@@ -156,15 +292,32 @@ public class BuildingDashboardActivity extends FullscreenActivity {
WebSettings webSettings = binding.webview.getSettings();
webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE);
webSettings.setJavaScriptEnabled(true);
webSettings.setDomStorageEnabled(true);
// 允许媒体自动播放 (Android 8+ 必须)
webSettings.setMediaPlaybackRequiresUserGesture(false);
// 禁用缩放相关设置
webSettings.setTextZoom(100);
webSettings.setSupportZoom(false); // 禁止缩放
webSettings.setBuiltInZoomControls(false);
binding.webview.setInitialScale(100);
binding.webview.setWebViewClient(new WebViewClient() {
private boolean hasError = false;
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
hasError = false;
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request,
WebResourceError error) {
super.onReceivedError(view, request, error);
// 只处理主页面的错误,不处理资源文件错误
if (request.getUrl().toString().equals(mainUrl)) {
if (request.isForMainFrame()) {
hasError = true;
handleLoadError();
}
}
@@ -173,9 +326,8 @@ public class BuildingDashboardActivity extends FullscreenActivity {
public void onReceivedHttpError(WebView view, WebResourceRequest request,
WebResourceResponse errorResponse) {
super.onReceivedHttpError(view, request, errorResponse);
// 处理HTTP错误如404, 500等
if (request.getUrl().toString().equals(mainUrl)) {
if (request.isForMainFrame() && errorResponse.getStatusCode() >= 400) {
hasError = true;
handleLoadError();
}
}
@@ -183,127 +335,33 @@ public class BuildingDashboardActivity extends FullscreenActivity {
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// 页面加载成功,重置重试计数
if (url.equals(mainUrl)) {
if (!hasError) {
retryCount = 0;
Log.d("WebView", "页面加载成功: " + url);
Log.d("WebView", "加载OK");
}
}
});
}
private void initConfigLoader() {
configLoadRunnable = new Runnable() {
@Override
public void run() {
if (!TextUtils.isEmpty(configUrl)) {
Log.d(TAG, "Periodically loading config...");
if (configReader != null) {
configReader.loadConfig(configUrl);
}
private void initPreferenceChangeListener() {
preferenceChangeListener = (sharedPreferences, key) -> {
if (key.equals("k_power_control_mode") ||
key.equals("k_power_on_time") ||
key.equals("k_power_off_time")) {
Log.d(TAG, "Power control preference changed: " + key);
runOnUiThread(() -> {
if (key.equals("k_power_control_mode")) {
int mode = PreferenceConfiguration.getPowerControlMode(BuildingDashboardActivity.this);
powerAlarmManager.onModeChanged(mode);
} else {
Log.e(TAG, "configUrl is empty, skipping config load.");
// 本地时间变更
powerAlarmManager.onLocalTimeChanged();
}
configLoadHandler.postDelayed(this, CONFIG_LOAD_INTERVAL);
});
}
};
if (configReader == null) {
configReader = new ConfigReader(this);
configReader.setOnConfigLoadListener(new ConfigReader.OnConfigLoadListener() {
@Override
public void onConfigLoaded() {
Log.i(TAG, "Config loaded successfully. Refreshing alarms.");
String serialConfig = configReader.getSavedSerialConfig();
refreshAlarms(serialConfig);
}
@Override
public void onConfigLoadFailed(String error) {
Log.e(TAG, "Config load failed: " + error + ". Refreshing alarms with local config.");
String serialConfig = configReader.getSavedSerialConfig();
refreshAlarms(serialConfig);
}
});
}
}
private void refreshAlarms(String serialConfigJson) {
if (serialConfigJson != null && serialConfigJson.equals(lastAppliedSerialConfig)) {
Log.d(TAG, "Serial config has not changed. Skipping alarm refresh.");
return;
}
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
for (int i = 0; i < MAX_COMMAND_ALARMS; i++) {
Intent intent = new Intent(this, CommandBroadcastReceiver.class);
intent.setAction(CommandBroadcastReceiver.ACTION_SEND_COMMAND);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, i, intent, PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE);
if (pendingIntent != null) {
alarmManager.cancel(pendingIntent);
pendingIntent.cancel();
}
}
try {
JSONObject serialConfig = new JSONObject(serialConfigJson);
String portPath = serialConfig.optString("DevicePath", "/dev/ttyS2");
int baudRate = serialConfig.optInt("Baud", 9600);
if (serialConfig.has("Commands")) {
JSONArray commands = serialConfig.getJSONArray("Commands");
for (int i = 0; i < commands.length() && i < MAX_COMMAND_ALARMS; i++) {
JSONObject command = commands.getJSONObject(i);
String time = command.getString("Time");
String hex = command.getString("Hex");
String[] timeParts = time.split(":");
int hour = Integer.parseInt(timeParts[0]);
int minute = Integer.parseInt(timeParts[1]);
Intent intent = new Intent(this, CommandBroadcastReceiver.class);
intent.setAction(CommandBroadcastReceiver.ACTION_SEND_COMMAND);
intent.putExtra(CommandBroadcastReceiver.EXTRA_COMMAND_HEX, hex);
intent.putExtra(CommandBroadcastReceiver.EXTRA_PORT_PATH, portPath);
intent.putExtra(CommandBroadcastReceiver.EXTRA_BAUD_RATE, baudRate);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, i, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(System.currentTimeMillis());
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(Calendar.DAY_OF_YEAR, 1);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), pendingIntent);
Log.d(TAG, "setExactAndAllowWhileIdle, Set repeating alarm for " + time + " with command " + hex + " on port " + portPath + " at " + baudRate + " baud");
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), pendingIntent);
Log.d(TAG, "setExact, Set repeating alarm for " + time + " with command " + hex + " on port " + portPath + " at " + baudRate + " baud");
} else {
alarmManager.set(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), pendingIntent);
Log.d(TAG, "Set repeating alarm for " + time + " with command " + hex + " on port " + portPath + " at " + baudRate + " baud");
}
// alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, pendingIntent);
}
}
this.lastAppliedSerialConfig = serialConfigJson;
Log.d(TAG, "Alarms refreshed and config backed up.");
} catch (Exception e) {
Log.e(TAG, "Failed to parse or schedule commands", e);
}
PreferenceManager.getDefaultSharedPreferences(this)
.registerOnSharedPreferenceChangeListener(preferenceChangeListener);
}
private void openSettingsActivity() {

View File

@@ -110,7 +110,7 @@ public class FullscreenActivity extends AppCompatActivity {
hide();
} else {
show();
delayedHide(30000);
delayedHide(20000);
}
}

View File

@@ -1,12 +1,22 @@
package cn.ykbox.dashboard.activity;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.blankj.utilcode.util.ToastUtils;
import cn.ykbox.dashboard.R;
import cn.ykbox.dashboard.perferences.PreferenceConfiguration;
import cn.ykbox.dashboard.serial.SerialPortDetector;
public class SettingsActivity extends AppCompatActivity {
@@ -26,10 +36,117 @@ public class SettingsActivity extends AppCompatActivity {
}
}
public static class SettingsFragment extends PreferenceFragmentCompat {
public static class SettingsFragment extends PreferenceFragmentCompat
implements Preference.OnPreferenceClickListener {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.root_preferences, rootKey);
ListPreference powerControlModePref = findPreference("k_power_control_mode");
EditTextPreference powerOnTimePref = findPreference("k_power_on_time");
EditTextPreference powerOffTimePref = findPreference("k_power_off_time");
Preference clearDevicePref = findPreference("k_clear_device");
if (clearDevicePref != null) {
clearDevicePref.setOnPreferenceClickListener(this);
}
Preference testPowerOnPref = findPreference("k_test_power_on");
if (testPowerOnPref != null) {
testPowerOnPref.setOnPreferenceClickListener(this);
}
Preference testPowerOffPref = findPreference("k_test_power_off");
if (testPowerOffPref != null) {
testPowerOffPref.setOnPreferenceClickListener(this);
}
if (powerControlModePref != null) {
powerControlModePref.setOnPreferenceChangeListener((preference, newValue) -> {
int mode = Integer.parseInt((String) newValue);
boolean isLocalMode = (mode == 2);
if (powerOnTimePref != null) {
powerOnTimePref.setEnabled(isLocalMode);
}
if (powerOffTimePref != null) {
powerOffTimePref.setEnabled(isLocalMode);
}
return true;
});
}
int mode = PreferenceConfiguration.getPowerControlMode(getContext());
boolean isLocalMode = (mode == 2);
if (powerOnTimePref != null) {
powerOnTimePref.setEnabled(isLocalMode);
}
if (powerOffTimePref != null) {
powerOffTimePref.setEnabled(isLocalMode);
}
}
@Override
public boolean onPreferenceClick(@NonNull Preference preference) {
String key = preference.getKey();
if(key.equals("k_clear_device")) {
clearSerialPath();
return true;
} else if(key.equals("k_test_power_on")) {
testPowerOn();
return true;
} else if(key.equals("k_test_power_off")) {
testPowerOff();
return true;
}
return false;
}
private void clearSerialPath() {
new AlertDialog.Builder(requireContext())
.setTitle("确认清除")
.setMessage("确定要清除当前串口设备路径吗?")
.setPositiveButton("确定", (dialog, which) -> {
// 清空
PreferenceConfiguration.setSerialPortPath(requireContext(), "");
// 刷新
Preference serialPortPathPref = findPreference("k_serial_port_path");
if (serialPortPathPref != null) {
String newValue = PreferenceConfiguration.getSerialPortPath(requireContext());
((EditTextPreference) serialPortPathPref).setText(newValue);
}
})
.setNegativeButton("取消", null)
.show();
}
private void testPowerOn() {
String portPath = PreferenceConfiguration.getSerialPortPath(getContext());
int baudRate = PreferenceConfiguration.getSerialPortBaudRate(getContext());
if (TextUtils.isEmpty(portPath)) {
ToastUtils.showShort("串口路径未设置");
return;
}
SerialPortDetector detector = new SerialPortDetector(portPath, baudRate);
detector.sendPowerOnCommand(1);
}
private void testPowerOff() {
String portPath = PreferenceConfiguration.getSerialPortPath(getContext());
int baudRate = PreferenceConfiguration.getSerialPortBaudRate(getContext());
if (TextUtils.isEmpty(portPath)) {
ToastUtils.showShort("串口路径未设置");
return;
}
SerialPortDetector detector = new SerialPortDetector(portPath, baudRate);
detector.sendPowerOffCommand();
}
}
}

View File

@@ -19,6 +19,7 @@ public class StartActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = this;
EdgeToEdge.enable(this);
setContentView(R.layout.activity_start);
@@ -39,4 +40,5 @@ public class StartActivity extends AppCompatActivity {
}
}, 3000);
}
}

View File

@@ -0,0 +1,361 @@
package cn.ykbox.dashboard.alarm;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Calendar;
import cn.ykbox.dashboard.ConfigReader;
import cn.ykbox.dashboard.perferences.PreferenceConfiguration;
import cn.ykbox.dashboard.receiver.CommandBroadcastReceiver;
import cn.ykbox.dashboard.serial.SerialPortDetector;
public class PowerAlarmManager {
private static final String TAG = "PowerAlarmManager";
private static final int MAX_COMMAND_ALARMS = 20;
private static final int ALARM_ID_POWER_ON = 0;
private static final int ALARM_ID_POWER_OFF = 1;
private static final long CONFIG_LOAD_INTERVAL = 10 * 60 * 1000; // 10 minutes
private static final int POWER_MODE_NONE = 0;
private static final int POWER_MODE_REMOTE = 1;
private static final int POWER_MODE_LOCAL = 2;
private final Context context;
private String lastAppliedSerialConfig = null;
private ConfigReader configReader;
private String configUrl;
private Handler configLoadHandler;
private Runnable configLoadRunnable;
public PowerAlarmManager(Context context, String configUrl) {
this.context = context.getApplicationContext();
this.configUrl = configUrl;
this.configLoadHandler = new Handler(Looper.getMainLooper());
}
/**
* 启动管理器,根据当前模式初始化
*/
public void start() {
int mode = PreferenceConfiguration.getPowerControlMode(context);
Log.d(TAG, "PowerAlarmManager starting, mode: " + mode);
refreshAlarms();
if (mode == POWER_MODE_REMOTE) {
// 服务器配置模式,初始化 ConfigLoader
initConfigLoader();
}
}
/**
* 停止管理器,释放资源
*/
public void stop() {
if (configLoadRunnable != null) {
configLoadHandler.removeCallbacks(configLoadRunnable);
}
if (configReader != null) {
configReader.release();
configReader = null;
}
}
/**
* 模式变更时调用
*/
public void onModeChanged(int newMode) {
Log.d(TAG, "Mode changed to: " + newMode);
if (newMode == POWER_MODE_REMOTE) {
// 切换到服务器配置模式
if (configReader == null) {
initConfigLoader();
}
clearServerConfigCache();
if (configLoadRunnable != null) {
configLoadHandler.post(configLoadRunnable);
}
} else {
// 切换到其他模式,停止配置加载
if (configLoadRunnable != null) {
configLoadHandler.removeCallbacks(configLoadRunnable);
}
}
refreshAlarms();
}
/**
* 本地时间变更时调用
*/
public void onLocalTimeChanged() {
int mode = PreferenceConfiguration.getPowerControlMode(context);
if (mode == POWER_MODE_LOCAL) {
// 本地控制模式,刷新闹钟
refreshAlarms();
}
}
/**
* 根据当前模式刷新闹钟
*/
public void refreshAlarms() {
int mode = PreferenceConfiguration.getPowerControlMode(context);
Log.d(TAG, "Refreshing alarms, mode: " + mode);
cancelAllAlarms();
switch (mode) {
case POWER_MODE_NONE: // 不控制
Log.d(TAG, "Power control: No control mode. No alarms set.");
break;
case POWER_MODE_REMOTE: // 根据服务器配置
Log.d(TAG, "Power control: Server config mode.");
// 使用已加载的配置或请求立即加载
if (configReader != null) {
String serialConfig = configReader.getSavedSerialConfig();
refreshWithServerConfig(serialConfig, false);
}
break;
case POWER_MODE_LOCAL: // 本地控制
Log.d(TAG, "Power control: Local control mode.");
setupLocalControlAlarms();
break;
default:
Log.w(TAG, "Unknown power control mode: " + mode);
break;
}
}
/**
* 使用服务器配置刷新闹钟
* @param serialConfigJson 服务器配置 JSON
* @param forceRefresh 是否强制刷新(模式切换时为 true
*/
private void refreshWithServerConfig(String serialConfigJson, boolean forceRefresh) {
cancelAllAlarms();
if (serialConfigJson == null) {
Log.w(TAG, "Server config is null.");
return;
}
if (!forceRefresh && serialConfigJson.equals(lastAppliedSerialConfig)) {
Log.d(TAG, "Server config has not changed. Skipping alarm refresh.");
return;
}
setupServerConfigAlarms(serialConfigJson);
this.lastAppliedSerialConfig = serialConfigJson;
}
/**
* 取消所有闹钟
*/
private void cancelAllAlarms() {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
for (int i = 0; i < MAX_COMMAND_ALARMS; i++) {
Intent intent = new Intent(context, CommandBroadcastReceiver.class);
intent.setAction(CommandBroadcastReceiver.ACTION_SEND_COMMAND);
PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, i, intent,
PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE
);
if (pendingIntent != null) {
alarmManager.cancel(pendingIntent);
pendingIntent.cancel();
}
}
}
/**
* 设置服务器配置的闹钟
*/
private void setupServerConfigAlarms(String serialConfigJson) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
try {
JSONObject serialConfig = new JSONObject(serialConfigJson);
if (serialConfig.has("Commands")) {
JSONArray commands = serialConfig.getJSONArray("Commands");
for (int i = 0; i < commands.length() && i < MAX_COMMAND_ALARMS; i++) {
JSONObject command = commands.getJSONObject(i);
String time = command.getString("Time");
String hex = command.getString("Hex");
String[] timeParts = time.split(":");
int hour = Integer.parseInt(timeParts[0]);
int minute = Integer.parseInt(timeParts[1]);
setupAlarm(alarmManager, i, time, hex, hour, minute);
}
}
Log.d(TAG, "Server config alarms set successfully.");
} catch (Exception e) {
Log.e(TAG, "Failed to parse server config", e);
}
}
/**
* 设置本地控制的闹钟
*/
private void setupLocalControlAlarms() {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
String onTime = PreferenceConfiguration.getPowerOnTime(context);
String offTime = PreferenceConfiguration.getPowerOffTime(context);
int[] onParts = parseTimeParts(onTime);
int[] offParts = parseTimeParts(offTime);
if (onParts != null) {
setupAlarm(alarmManager, ALARM_ID_POWER_ON, onTime,
SerialPortDetector.TEST_CMD_ON,
onParts[0], onParts[1]);
} else {
Log.w(TAG, "Invalid power-on time, alarm skipped: " + onTime);
}
if (offParts != null) {
setupAlarm(alarmManager, ALARM_ID_POWER_OFF, offTime,
SerialPortDetector.TEST_CMD_OFF,
offParts[0], offParts[1]);
} else {
Log.w(TAG, "Invalid power-off time, alarm skipped: " + offTime);
}
Log.d(TAG, "Local control alarms set: ON=" + onTime + ", OFF=" + offTime);
}
/**
* 解析 "HH:mm" 格式时间字符串
* @return int[]{hour, minute},解析失败返回 null
*/
private int[] parseTimeParts(String time) {
if (time == null || time.trim().isEmpty()) {
return null;
}
String[] parts = time.split(":");
if (parts.length < 2) {
return null;
}
try {
int hour = Integer.parseInt(parts[0].trim());
int minute = Integer.parseInt(parts[1].trim());
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return new int[]{hour, minute};
} catch (NumberFormatException e) {
Log.e(TAG, "Failed to parse time: " + time, e);
return null;
}
}
/**
* 设置单个闹钟
*/
private void setupAlarm(AlarmManager alarmManager, int alarmId, String timeStr,
String cmdHex, int hour, int minute) {
Intent intent = new Intent(context, CommandBroadcastReceiver.class);
intent.setAction(CommandBroadcastReceiver.ACTION_SEND_COMMAND);
intent.putExtra(CommandBroadcastReceiver.EXTRA_COMMAND_HEX, cmdHex);
// 传递时间信息和 alarmId用于重新设置下一个闹钟
intent.putExtra(CommandBroadcastReceiver.EXTRA_HOUR, hour);
intent.putExtra(CommandBroadcastReceiver.EXTRA_MINUTE, minute);
intent.putExtra(CommandBroadcastReceiver.EXTRA_ALARM_ID, alarmId);
PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(System.currentTimeMillis());
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(Calendar.DAY_OF_YEAR, 1);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
} else {
alarmManager.set(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
}
Log.d(TAG, "Alarm set: " + timeStr + " with command " + cmdHex);
}
/**
* 初始化 ConfigLoader
*/
private void initConfigLoader() {
if (configReader != null) {
return; // 已初始化
}
configLoadRunnable = new Runnable() {
@Override
public void run() {
if (!TextUtils.isEmpty(configUrl)) {
Log.d(TAG, "Periodically loading config...");
configReader.loadConfig(configUrl);
} else {
Log.e(TAG, "configUrl is empty, skipping config load.");
}
configLoadHandler.postDelayed(this, CONFIG_LOAD_INTERVAL);
}
};
configReader = new ConfigReader(context);
configReader.setOnConfigLoadListener(new ConfigReader.OnConfigLoadListener() {
@Override
public void onConfigLoaded() {
Log.i(TAG, "Config loaded successfully. Refreshing alarms.");
String serialConfig = configReader.getSavedSerialConfig();
refreshWithServerConfig(serialConfig, false);
}
@Override
public void onConfigLoadFailed(String error) {
Log.e(TAG, "Config load failed: " + error);
}
});
// 立即执行一次
configLoadRunnable.run();
}
/**
* 清除缓存的服务器配置
*/
private void clearServerConfigCache() {
this.lastAppliedSerialConfig = null;
}
}

View File

@@ -0,0 +1,61 @@
package cn.ykbox.dashboard.perferences;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
/**
* 配置管理
* TODO 代码中零散的读取配置选项代码都集中到这里
*/
public class PreferenceConfiguration {
private final static String TAG = "PreferenceConfiguration";
private static final String KEY_SERIAL_PORT_PATH = "k_serial_port_path";
private static final String KEY_SERIAL_PORT_BAUD_RATE = "k_serial_baud";
private static final String KEY_SEND_POWER_ON_CMD = "k_send_power_on_cmd";
private static final String KEY_POWER_CONTROL_MODE = "k_power_control_mode";
private static final String KEY_POWER_ON_TIME = "k_power_on_time";
private static final String KEY_POWER_OFF_TIME = "k_power_off_time";
public static String getSerialPortPath(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getString(KEY_SERIAL_PORT_PATH, "");
}
public static void setSerialPortPath(Context context, String portPath) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().putString(KEY_SERIAL_PORT_PATH, portPath).apply();
}
public static int getSerialPortBaudRate(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String baud = prefs.getString(KEY_SERIAL_PORT_BAUD_RATE, "9600");
return Integer.parseInt(baud);
}
public static boolean getSendPowerOnCmd(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getBoolean(KEY_SEND_POWER_ON_CMD, true);
}
public static int getSerialCmdLoop(Context context) {
return 3;
}
public static int getPowerControlMode(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String mode = prefs.getString(KEY_POWER_CONTROL_MODE, "1");
return Integer.parseInt(mode);
}
public static String getPowerOnTime(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getString(KEY_POWER_ON_TIME, "07:30");
}
public static String getPowerOffTime(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getString(KEY_POWER_OFF_TIME, "20:30");
}
}

View File

@@ -1,34 +1,100 @@
package cn.ykbox.dashboard.receiver;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import cn.ykbox.dashboard.serial.SerialControlDevices;
import java.util.Calendar;
import cn.ykbox.dashboard.perferences.PreferenceConfiguration;
import cn.ykbox.dashboard.serial.SerialPortDetector;
public class CommandBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "CommandReceiver";
public static final String ACTION_SEND_COMMAND = "cn.ykbox.dashboard.ACTION_SEND_COMMAND";
public static final String EXTRA_COMMAND_HEX = "EXTRA_COMMAND_HEX";
public static final String EXTRA_PORT_PATH = "EXTRA_PORT_PATH";
public static final String EXTRA_BAUD_RATE = "EXTRA_BAUD_RATE"; // 新增:波特率的 Extra Key
public static final String EXTRA_HOUR = "EXTRA_HOUR";
public static final String EXTRA_MINUTE = "EXTRA_MINUTE";
public static final String EXTRA_ALARM_ID = "EXTRA_ALARM_ID";
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null && ACTION_SEND_COMMAND.equals(intent.getAction())) {
String hexCommand = intent.getStringExtra(EXTRA_COMMAND_HEX);
String portPath = intent.getStringExtra(EXTRA_PORT_PATH);
int baudRate = intent.getIntExtra(EXTRA_BAUD_RATE, 9600);
if (hexCommand != null && !hexCommand.isEmpty() && portPath != null && !portPath.isEmpty()) {
String portPath = PreferenceConfiguration.getSerialPortPath(context);
int baudRate = PreferenceConfiguration.getSerialPortBaudRate(context);
int loop = PreferenceConfiguration.getSerialCmdLoop(context);
if (hexCommand != null && !hexCommand.isEmpty() && !TextUtils.isEmpty(portPath)) {
Log.d(TAG, "Received alarm to send command '" + hexCommand + "' to port '" + portPath + "' at " + baudRate + " baud");
// 使用新的静态方法发送指令
boolean success = SerialControlDevices.sendCommand(portPath, baudRate, hexCommand);
boolean success = SerialPortDetector.sendCommand(portPath, baudRate, hexCommand, loop);
if (!success) {
Log.e(TAG, "Failed to send command via broadcast receiver.");
}
// 重新设置明天的闹钟(实现每天循环)
Integer hour = intent.getIntExtra(EXTRA_HOUR, -1);
Integer minute = intent.getIntExtra(EXTRA_MINUTE, -1);
int alarmId = intent.getIntExtra(EXTRA_ALARM_ID, 0);
if (hour >= 0 && minute >= 0) {
scheduleNextAlarm(context, alarmId, hexCommand, hour, minute);
}
}
}
}
/**
* 调度下一个闹钟(明天同一时间)
*/
private void scheduleNextAlarm(Context context, int alarmId, String cmdHex, int hour, int minute) {
new Handler(Looper.getMainLooper()).post(() -> {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent intent = new Intent(context, CommandBroadcastReceiver.class);
intent.setAction(ACTION_SEND_COMMAND);
intent.putExtra(EXTRA_COMMAND_HEX, cmdHex);
intent.putExtra(EXTRA_HOUR, hour);
intent.putExtra(EXTRA_MINUTE, minute);
intent.putExtra(EXTRA_ALARM_ID, alarmId);
PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
// 计算明天的触发时间
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR, 1);
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
// 使用精确的一次性闹钟
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
} else {
alarmManager.set(
AlarmManager.RTC_WAKEUP,
calendar.getTimeInMillis(), pendingIntent);
}
Log.d(TAG, "Next alarm scheduled for " + hour + ":" + minute + " tomorrow (ID: " + alarmId + ")");
});
}
}

View File

@@ -1,53 +0,0 @@
package cn.ykbox.dashboard.serial;
import android.util.Log;
import tp.xmaihh.serialport.SerialHelper;
/**
* @description: 通过串口设置班牌的功能。本类提供一个静态方法用于发送单次命令。
* @author: Hu Zhang <hu.zhang@live.com>
* @date: 2024/1/5
**/
public class SerialControlDevices {
private static final String TAG = "SerialControlDevices";
/**
* 私有构造函数,防止外部实例化此类。
*/
private SerialControlDevices() {}
/**
* 打开指定串口,发送十六进制命令,然后立即关闭串口。
*
* @param portPath 串口的设备路径 (例如, "/dev/ttyS2").
* @param hexCommand 要发送的十六进制格式的命令字符串.
* @return 如果命令发送成功则返回 true, 否则返回 false.
*/
public static boolean sendCommand(String portPath, int baud, String hexCommand) {
if (portPath == null || portPath.isEmpty() || hexCommand == null || hexCommand.isEmpty()) {
Log.e(TAG, "Port path or command is empty.");
return false;
}
// 每次调用都创建一个新的 SerialHelper 实例
SerialHelper serialHelper = new SerialHelper(portPath, baud) {
@Override
protected void onDataReceived(tp.xmaihh.serialport.bean.ComBean ComRecData) {
// 可以在这里处理返回的数据,但对于单次发送任务,通常不需要
// Log.d(TAG, "Received data from " + portPath + ": " + new String(ComRecData.bRec));
}
};
try {
serialHelper.open();
serialHelper.sendHex(hexCommand);
Log.d(TAG, "Successfully sent command '" + hexCommand + "' to port '" + portPath + "'");
return true;
} catch (Exception e) {
Log.e(TAG, "Error sending command to port " + portPath, e);
return false;
} finally {
serialHelper.close();
}
}
}

View File

@@ -0,0 +1,407 @@
package cn.ykbox.dashboard.serial;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import android_serialport_api.SerialPortFinder;
import tp.xmaihh.serialport.SerialHelper;
import tp.xmaihh.serialport.bean.ComBean;
public class SerialPortDetector {
private static final String TAG = "SerialPortDetector";
// 测试命令
public static final String TEST_CMD_OFF = "ACEAB500ED"; // 关闭设备
public static final String TEST_CMD_ON = "ACEAB400ED"; // 打开设备
// 响应超时时间(毫秒)
private static final long RESPONSE_TIMEOUT = 6000;
private DetectionCallback callback;
private Handler mainHandler;
private String savedPath;
private int baudRate;
public interface DetectionCallback {
void onDetectionStart();
void onDetectionProgress(String portPath, int current, int total);
void onDetectionSuccess(String portPath);
void onDetectionFailed();
}
/**
* 打开指定串口,发送十六进制命令,然后立即关闭串口。
*
* @param portPath 串口的设备路径 (例如, "/dev/ttyS2").
* @param hexCommand 要发送的十六进制格式的命令字符串.
* @return 如果命令发送成功则返回 true, 否则返回 false.
*/
public static boolean sendCommand(String portPath, int baud, String hexCommand, int loop) {
if (portPath == null || portPath.isEmpty() || hexCommand == null || hexCommand.isEmpty()) {
Log.e(TAG, "Port path or command is empty.");
return false;
}
// 每次调用都创建一个新的 SerialHelper 实例
SerialHelper serialHelper = new SerialHelper(portPath, baud) {
@Override
protected void onDataReceived(tp.xmaihh.serialport.bean.ComBean ComRecData) {
// 可以在这里处理返回的数据,但对于单次发送任务,通常不需要
// Log.d(TAG, "Received data from " + portPath + ": " + new String(ComRecData.bRec));
}
};
try {
serialHelper.open();
for(int i = 0; i < loop; i++) {
serialHelper.sendHex(hexCommand);
Log.d(TAG, "Successfully sent command '" + hexCommand + "' to port '" + portPath + "'");
Thread.sleep(1000);
}
return true;
} catch (Exception e) {
Log.e(TAG, "Error sending command to port " + portPath, e);
return false;
} finally {
serialHelper.close();
}
}
public SerialPortDetector(String savedPath, int baudRate) {
this.savedPath = savedPath;
this.baudRate = baudRate;
this.mainHandler = new Handler(Looper.getMainLooper());
}
public void setCallback(DetectionCallback callback) {
this.callback = callback;
}
/**
* 发送关闭设备指令到已保存的串口(不等待响应)
*/
public void sendPowerOffCommand() {
sendCommand(TEST_CMD_OFF);
}
/**
* 发送打开设备指令到已保存的串口(不等待响应)
*/
public void sendPowerOnCommand(int loop) {
sendCommand(TEST_CMD_ON, loop);
}
private void sendCommand(String commandHex) {
sendCommand(commandHex, 1);
}
/**
* 发送指令到已保存的串口(不等待响应)
* @param commandHex 十六进制命令字符串
*/
private void sendCommand(String commandHex, int loop) {
if (TextUtils.isEmpty(savedPath)) {
Log.w(TAG, "No serial port path configured");
return;
}
new Thread(() -> {
SerialHelper serialHelper = null;
try {
serialHelper = new SimpleSerialHelper(savedPath, baudRate);
serialHelper.open();
for(int i = 0; i < loop; i ++) {
serialHelper.sendHex(commandHex);
Log.d(TAG, "Sent command: " + commandHex);
Thread.sleep(1000);
}
} catch (Exception e) {
Log.e(TAG, "Error sending command: " + e.getMessage());
} finally {
if (serialHelper != null) {
try {
serialHelper.close();
} catch (Exception e) {
Log.e(TAG, "Error closing port: " + e.getMessage());
}
}
}
}).start();
}
/**
* 开始串口检测(智能检测)
* 先验证已保存的串口,如果不可用再进行全局检测
*/
public void startDetection() {
startDetection(false);
}
/**
* 开始串口检测(智能检测)
* @param ensurePowerOn 是否确保设备开机(发送两个命令)
*/
public void startDetection(boolean ensurePowerOn) {
new Thread(() -> {
notifyDetectionStart();
if (!TextUtils.isEmpty(savedPath)) {
Log.d(TAG, "Found saved serial port path: " + savedPath);
Log.d(TAG, "Verifying saved port: " + savedPath);
// 验证已保存的串口是否可用
if (testSerialPort(savedPath, baudRate, ensurePowerOn)) {
Log.i(TAG, "Saved serial port is valid: " + savedPath);
notifyDetectionSuccess(savedPath);
return;
} else {
Log.w(TAG, "Saved serial port is invalid, starting full detection");
}
} else {
Log.d(TAG, "No saved serial port path, starting full detection");
}
// 已保存的串口不可用或不存在,进行全局检测
performFullDetection(ensurePowerOn);
}).start();
}
private void performFullDetection(boolean ensurePowerOn) {
SerialPortFinder finder = new SerialPortFinder();
String[] devicePaths = finder.getAllDevicesPath();
if (devicePaths == null || devicePaths.length == 0) {
Log.e(TAG, "No serial ports found");
notifyDetectionFailed();
return;
}
Log.d(TAG, "Found " + devicePaths.length + " serial ports");
// 遍历所有串口
for (int i = 0; i < devicePaths.length; i++) {
String portPath = devicePaths[i];
notifyDetectionProgress(portPath, i + 1, devicePaths.length);
Log.d(TAG, "Testing port: " + portPath + " at " + baudRate + " baud");
if (testSerialPort(portPath, baudRate, ensurePowerOn)) {
Log.i(TAG, "Serial port detected successfully: " + portPath);
notifyDetectionSuccess(portPath);
return;
}
}
Log.e(TAG, "No valid serial port found");
notifyDetectionFailed();
}
/**
* 测试指定的串口
* @param portPath 串口路径
* @param baudRate 波特率
* @param ensurePowerOn 是否确保设备开机(发送两个命令)
*/
private boolean testSerialPort(String portPath, int baudRate, boolean ensurePowerOn) {
TestSerialHelper serialHelper = null;
try {
serialHelper = new TestSerialHelper(portPath, baudRate);
serialHelper.open();
if (ensurePowerOn) {
// 需要确保设备开机,两个命令都要发送
//
Log.d(TAG, "Ensuring device power on by sending both commands");
boolean cmd1Success = sendCommandAndWaitResponse(serialHelper, TEST_CMD_OFF);
Thread.sleep(1000);
boolean cmd2Success = sendCommandAndWaitResponse(serialHelper, TEST_CMD_ON);
// 只要有一个命令收到有效响应就认为串口可用
if (cmd1Success || cmd2Success) {
Log.d(TAG, "Device responded (CMD1: " + cmd1Success + ", CMD2: " + cmd2Success + ")");
return true;
}
return false;
} else {
// 只需要检测串口,任意一个命令有响应即可
if (sendCommandAndWaitResponse(serialHelper, TEST_CMD_OFF)) {
return true;
}
if (sendCommandAndWaitResponse(serialHelper, TEST_CMD_ON)) {
return true;
}
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error testing port " + portPath + ": " + e.getMessage());
return false;
} finally {
if (serialHelper != null) {
try {
serialHelper.close();
} catch (Exception e) {
Log.e(TAG, "Error closing port: " + e.getMessage());
}
}
}
}
/**
* 发送命令并等待响应
*/
private boolean sendCommandAndWaitResponse(TestSerialHelper serialHelper, String command) {
try {
CountDownLatch latch = new CountDownLatch(1);
AtomicBoolean responseReceived = new AtomicBoolean(false);
serialHelper.setResponseListener((isValid) -> {
if (isValid) {
responseReceived.set(true);
}
latch.countDown();
});
// 清空之前的响应标志
serialHelper.resetResponse();
// 发送命令
serialHelper.sendHex(command);
Log.d(TAG, "Sent command: " + command);
// 等待响应
boolean timeout = !latch.await(RESPONSE_TIMEOUT, TimeUnit.MILLISECONDS);
if (timeout) {
Log.d(TAG, "Response timeout for command: " + command);
return false;
}
return responseReceived.get();
} catch (InterruptedException e) {
Log.e(TAG, "Wait interrupted: " + e.getMessage());
return false;
}
}
// 回调通知方法
private void notifyDetectionStart() {
if (callback != null) {
mainHandler.post(() -> callback.onDetectionStart());
}
}
private void notifyDetectionProgress(String portPath, int current, int total) {
if (callback != null) {
mainHandler.post(() -> callback.onDetectionProgress(portPath, current, total));
}
}
private void notifyDetectionSuccess(String portPath) {
if (callback != null) {
mainHandler.post(() -> callback.onDetectionSuccess(portPath));
}
}
private void notifyDetectionFailed() {
if (callback != null) {
mainHandler.post(() -> callback.onDetectionFailed());
}
}
/**
* 简单的串口 Helper用于只发送命令不等待响应
*/
private static class SimpleSerialHelper extends SerialHelper {
public SimpleSerialHelper(String sPort, int iBaudRate) {
super(sPort, iBaudRate);
}
@Override
protected void onDataReceived(ComBean comBean) {
// 不需要处理响应
}
}
/**
* 内部测试用的 SerialHelper
*/
private static class TestSerialHelper extends SerialHelper {
private ResponseListener responseListener;
public interface ResponseListener {
void onResponse(boolean isValid);
}
public TestSerialHelper(String sPort, int iBaudRate) {
super(sPort, iBaudRate);
}
public void setResponseListener(ResponseListener listener) {
this.responseListener = listener;
}
public void resetResponse() {
// 重置响应状态,为下一次测试做准备
}
@Override
protected void onDataReceived(ComBean comBean) {
byte[] data = comBean.bRec;
// 检查是否为有效响应格式: DA XX XX XX XX ED
if (isValidResponse(data)) {
Log.d(TAG, "Valid response received: " + bytesToHex(data));
if (responseListener != null) {
responseListener.onResponse(true);
}
} else {
Log.d(TAG, "Invalid response: " + bytesToHex(data));
if (responseListener != null) {
responseListener.onResponse(false);
}
}
}
/**
* 检查响应是否符合格式: DA XX XX XX XX ED 或者 AC XX XX XX XX ED
*/
private boolean isValidResponse(byte[] data) {
if (data == null || data.length < 6) {
return false;
}
// 检查起始字节是否为 0xDA 或 0xAC
int head = data[0] & 0xFF;
if (head != 0xDA && head != 0xAC) {
return false;
}
// 检查结束字节是否为 0xED
int end = data[data.length - 1] & 0xFF;
return end == 0xED;
}
/**
* 字节数组转十六进制字符串(用于日志)
*/
private String bytesToHex(byte[] bytes) {
if (bytes == null) {
return "null";
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X ", b & 0xFF));
}
return sb.toString().trim();
}
}
}

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -5,166 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="#065994"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.75"
android:scaleY="0.75"
android:translateX="13.5"
android:translateY="13.5">
<path
android:pathData="m36.75,60.38c0,-1.13 -0.38,-2.25 -1.13,-3 -0.75,-0.75 -1.88,-1.5 -3,-1.5 -1.13,0 -2.25,0.75 -3,1.5 -0.75,0.75 -1.5,1.88 -1.5,3 0,1.13 0.38,2.25 1.13,3 0.75,0.75 1.88,1.13 3,1.13 1.13,0 2.25,-0.38 3,-1.13 0.75,-0.75 1.5,-1.88 1.5,-3zM43.13,45.38c0,-1.13 -0.38,-2.25 -1.13,-3 -0.75,-0.75 -1.88,-1.5 -3,-1.5 -1.13,0 -2.25,0.38 -3,1.13 -0.75,0.75 -1.13,2.25 -1.13,3.38 0,1.13 0.38,2.25 1.13,3 0.75,0.75 1.88,1.13 3,1.13 1.13,0 2.25,-0.38 3,-1.13 0.75,-0.75 1.13,-1.88 1.13,-3zM57.75,61.5 L61.13,48.75c0,-0.75 0,-1.13 -0.38,-1.5C60.38,46.5 60,46.13 59.63,46.13L57.75,46.13C57.38,46.5 57,46.88 57,47.63l-3.38,12.75c-1.5,0 -2.63,0.75 -3.75,1.5 -1.13,0.75 -1.88,1.88 -2.25,3.38 -0.38,1.88 -0.38,3.38 0.75,4.88 0.75,1.5 2.25,2.63 3.75,3 1.5,0.38 3.38,0.38 4.88,-0.75 1.5,-0.75 2.63,-2.25 3,-3.75 0.38,-1.5 0.38,-2.63 -0.38,-3.75 0,-1.5 -0.75,-2.63 -1.88,-3.38zM79.88,60.38c0,-1.13 -0.38,-2.25 -1.13,-3 -0.75,-0.75 -1.88,-1.13 -3,-1.13 -1.13,0 -2.25,0.38 -3,1.13 -0.75,0.75 -1.13,1.88 -1.13,3 0,1.13 0.38,2.25 1.13,3 0.75,0.75 1.88,1.13 3,1.13 1.13,0 2.25,-0.38 3,-1.13 0.38,-0.75 1.13,-1.88 1.13,-3zM58.13,39c0,-1.13 -0.38,-2.25 -1.13,-3 -0.75,-1.13 -1.88,-1.5 -3,-1.5 -1.13,0 -2.25,0.38 -3,1.13 -0.75,1.13 -1.13,2.25 -1.13,3.38 0,1.13 0.38,2.25 1.13,3 0.75,0.75 1.88,1.13 3,1.13 1.13,0 2.25,-0.38 3,-1.13 0.75,-0.75 1.13,-1.88 1.13,-3zM73.13,45.38c0,-1.13 -0.38,-2.25 -1.13,-3 -0.75,-0.75 -1.88,-1.13 -3,-1.13 -1.13,0 -2.25,0.38 -3,1.13 -0.75,0.75 -1.13,1.88 -1.13,3 0,1.13 0.38,2.25 1.13,3 0.75,0.75 1.88,1.13 3,1.13 1.13,0 2.25,-0.38 3,-1.13 0.75,-0.75 1.13,-1.88 1.13,-3zM84,60.38c0,6 -1.5,11.25 -4.88,16.13C78.75,77.25 78,77.63 77.25,77.63L30.38,77.63c-0.75,0 -1.5,-0.38 -1.88,-1.13C25.5,71.63 24,66 24,60.38 24,56.25 24.75,52.5 26.25,48.75 27.75,45 30,42 32.63,39c2.63,-3 6,-4.88 9.75,-6.38 3.75,-1.5 7.5,-2.25 11.63,-2.25 4.13,0 7.88,0.75 11.63,2.25 3.75,1.5 6.75,3.75 9.75,6.38 2.63,2.63 4.88,6 6.38,9.75 1.5,3.75 2.25,7.5 2.25,11.63z"
android:strokeWidth="0.0585938"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,3 @@
<resources>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">仪表盘</string>
<string name="title_activity_building_dashboard">教学楼仪表盘</string>
<string name="title_activity_settings">设置</string>
</resources>

View File

@@ -1,22 +1,17 @@
<resources>
<string name="app_name">Dashboard</string>
<string name="dummy_button">Dummy Button</string>
<string name="dummy_content">DUMMY\nCONTENT</string>
<string name="title_activity_building_dashboard">BuildingDashboardActivity</string>
<string name="title_activity_settings">SettingsActivity</string>
<string name="title_activity_building_dashboard">Building Dashboard</string>
<string name="title_activity_settings">Settings</string>
<string name="url_end_point_default_value" translatable="false">/</string>
<!-- Preference Titles -->
<string name="messages_header">Messages</string>
<string name="sync_header">Sync</string>
<!-- Messages Preferences -->
<string name="signature_title">Your signature</string>
<string name="reply_title">Default reply action</string>
<!-- Sync Preferences -->
<string name="sync_title">Sync email periodically</string>
<string name="attachment_title">Download incoming attachments</string>
<string name="attachment_summary_on">Automatically download attachments for incoming emails
</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
<string-array name="power_control_mode_entries">
<item>不控制</item>
<item>根据服务器配置</item>
<item>本地控制</item>
</string-array>
<string-array name="power_control_mode_values">
<item>0</item>
<item>1</item>
<item>2</item>
</string-array>
</resources>

View File

@@ -1,9 +1,66 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory app:title="网页地址">
<PreferenceCategory app:title="服务器端地址">
<EditTextPreference
app:key="k_url"
app:key="k_url_prefix"
app:title="URL 前缀"
app:defaultValue="http://172.18.22.211:8002/Dashboard"
app:summary="用于拼接网页和配置文件链接,网页:{URL前缀}/{URL路径},配置文件:{URL前缀}/data/config.json" />
<EditTextPreference
app:key="k_url_path"
app:title="URL 路径"
app:defaultValue="/index.html"
app:useSimpleSummaryProvider="true"/>
</PreferenceCategory>
<PreferenceCategory app:title="配置物联网关">
<EditTextPreference
app:key="k_serial_port_path"
app:title="串口设备路径"
app:defaultValue=""
app:useSimpleSummaryProvider="true" />
<EditTextPreference
app:key="k_serial_baud"
app:title="串口波特率"
app:defaultValue="9600"
app:useSimpleSummaryProvider="true"
app:isPreferenceVisible="false"/>
<ListPreference
app:key="k_power_control_mode"
app:title="电源插座定时开关"
app:entries="@array/power_control_mode_entries"
app:entryValues="@array/power_control_mode_values"
app:defaultValue="1"
app:useSimpleSummaryProvider="true" />
<EditTextPreference
app:key="k_power_on_time"
app:title="开电源时间"
app:defaultValue="07:30"
app:summary="设置自动开电源的时间格式HH:mm"
app:useSimpleSummaryProvider="true" />
<EditTextPreference
app:key="k_power_off_time"
app:title="关电源时间"
app:defaultValue="20:30"
app:summary="设置自动关电源的时间格式HH:mm"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
app:key="k_send_power_on_cmd"
app:title="APP 启动时重启电源插座"
app:summary="APP 启动时先关闭电源5秒后再打开电源"
app:defaultValue="true" />
<Preference
app:key="k_clear_device"
app:title="串口设备路径"
app:summary="清除当前串口设备路径, 下次启动时自动检测设备。" />
</PreferenceCategory>
<PreferenceCategory app:title="测试物联网关">
<Preference
app:key="k_test_power_on"
app:title="打开电源"
app:summary="发送命令给物联网关,开启供电" />
<Preference
app:key="k_test_power_off"
app:title="关闭电源"
app:summary="发送命令给物联网关,停止供电" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -15,10 +15,11 @@ buildscript {
allprojects {
repositories {
maven { url 'https://jitpack.io' }
maven { url 'https://maven.aliyun.com/repository/public/' }
maven { url 'https://maven.aliyun.com/repository/jcenter' } // 代替 jc
maven { url 'https://repo1.maven.org/maven2/' }
google()
mavenCentral()
maven { url 'https://repo1.maven.org/maven2/' }
}
// 提示过时的API

View File

@@ -1,20 +0,0 @@
@echo off
echo clear builds
rd /s /q %~dp0\app_classtv\build
rd /s /q %~dp0\app_sinclass\build
rd /s /q %~dp0\app_sinclasspad\build
rd /s /q %~dp0\app_sinclasspad2\build
rd /s /q %~dp0\app_startap\build
rd /s /q %~dp0\bjcast\build
rd /s /q %~dp0\cccl\build
rd /s /q %~dp0\demo_coaface\build
rd /s /q %~dp0\demo_serial\build
rd /s /q %~dp0\demo_serviceecd\build
rd /s /q %~dp0\demo_twoscreen\build
rd /s /q %~dp0\serialport\build
rd /s /q %~dp0\serviceecd\build
rd /s /q %~dp0\signageapi\build
rd /s /q %~dp0\signageui\build
rd /s /q %~dp0\signageutil\build

View File

@@ -1,4 +1,6 @@
[versions]
serialport = "2.1.1"
acraHttp = "5.13.1"
agp = "8.8.2"
exoplayer = "2.19.1"
banner = "2.2.2"
@@ -28,6 +30,8 @@ utilcodex = "1.31.1"
xlog = "1.11.1"
[libraries]
serialport = { module = "io.github.xmaihh:serialport", version.ref = "serialport" }
acra-http = { module = "ch.acra:acra-http", version.ref = "acraHttp" }
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
banner = { module = "io.github.youth5201314:banner", version.ref = "banner" }
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }

View File

@@ -1,10 +1,68 @@
# Dashboard Client
## 说明
* 这是一个临时项目,功能很简单,全屏轮播几张图片
## 1. 简介
本项目是一个运行在 Android 设备上的仪表盘Dashboard客户端。
其主要功能是作为一个**仪表盘Dashboard**的展示端:它通过全屏 `WebView` 加载并展示一个远程的网页仪表盘,并根据服务器数据的配置,在预定的时间通过串口与外部硬件设备进行通信。
## TODO
## 2. 主要功能
* 可以指定本地和网络图片
* **网页仪表盘展示**:通过 `WebView` 全屏展示一个可配置的远程 URL。
* **远程配置**:应用会定期从服务器获取 `config.json` 文件,动态更新其行为。
* **定时串口指令**:根据 `config.json` 中的计划,使用 `AlarmManager` 在精确的时间点发送十六进制Hex串口指令。
* **开机自启**:设备启动后,应用会自动运行。
* **网络重试机制**:当加载仪表盘主页失败时,应用会自动进行延时重试。
## 3. 工作流程
1. **启动**:应用启动后,进入全屏模式并加载在设置中配置的远程网页 URL。
2. **加载配置**:应用会定期从服务器的 `/data/config.json` 路径下拉取配置文件。
3. **解析与调度**:解析 `config.json` 中定义的串口设备路径、波特率以及多个定时指令。
4. **设置定时任务**:使用 Android 的 `AlarmManager` 为每一条指令安排一个在未来特定时间触发的广播。
5. **执行指令**:当预定时间到达,`CommandBroadcastReceiver` 被唤醒,并通过 `SerialControlDevices` 类向指定的串口发送 Hex 指令。
## 4. 配置
### 4.1 仪表盘 URL
在应用的设置界面中,可以配置仪表盘的主 URL例如 `http://192.168.1.100:8000/Dashboard`。应用会自动加载该 URL 下的 `index.html`
### 4.2 串口指令 (config.json)
应用会从 `[主 URL]/data/config.json` 加载串口指令。`config.json` 的格式如下:
```json
{
"Commands": [
{
"Time": "08:00",
"Hex": "AABB0101CCDD"
},
{
"Time": "18:00",
"Hex": "AABB0100CCDD"
}
]
}
```
* `DevicePath`: 串口设备节点路径。
* `Baud`: 串口通信的波特率。
* `Commands`: 一个指令数组,包含多个定时任务。
* `Time`: 指令每天执行的时间24小时制
* `Hex`: 需要发送的十六进制指令字符串。
## 5. 如何构建
这是一个标准的 Android Gradle 项目。使用 Android Studio 打开即可,或在命令行中执行以下命令构建:
```bash
# 构建 Release 版本的 APK
./gradlew clean assembleRelease
```
## 6. TODO
* [ ] 恢复并优化图片轮播功能,支持本地与网络图片。
* [ ] 提供更丰富的远程配置选项(如轮播间隔、动画效果等)。
* [ ] 增加 Web 与客户端通过 Javascript Interface 的双向交互。

View File

@@ -1,8 +1 @@
include ':app_dashboard'
//include ':utils'
//project(':utils').projectDir = new File("mod_utils/utils")
//include ':signageapi'
//project(':signageapi').projectDir = new File("mod_signageapi/signageapi")
//include ':serialport'
//project(':serialport').projectDir = new File("mod_serialport/serialport")
//include ':dashboard'