用闹钟实现功能
This commit is contained in:
9
.gitmodules
vendored
9
.gitmodules
vendored
@@ -1,9 +0,0 @@
|
||||
[submodule "mod_utils"]
|
||||
path = mod_utils
|
||||
url = git@192.168.196.173:ykbox/mod_utils.git
|
||||
[submodule "mod_signageapi"]
|
||||
path = mod_signageapi
|
||||
url = git@192.168.196.173:ykbox/mod_signageapi.git
|
||||
[submodule "mod_serialport"]
|
||||
path = mod_serialport
|
||||
url = git@192.168.196.173:ykbox/mod_serialport.git
|
||||
@@ -48,6 +48,7 @@ dependencies {
|
||||
|
||||
implementation libs.utilcodex
|
||||
|
||||
implementation libs.okhttp
|
||||
implementation libs.banner
|
||||
implementation libs.glide
|
||||
annotationProcessor libs.glidecompiler
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
android:theme="@style/Theme.Dashboard"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:name=".activity.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_settings" />
|
||||
<activity
|
||||
android:name=".BuildingDashboardActivity"
|
||||
android:name=".activity.BuildingDashboardActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_building_dashboard"
|
||||
android:theme="@style/Theme.Dashboard.Fullscreen" />
|
||||
<activity
|
||||
android:name=".StartActivity"
|
||||
android:name=".activity.StartActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -31,7 +31,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".GalleryActivity"
|
||||
android:name=".activity.GalleryActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="true" />
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.CommandBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -4,6 +4,8 @@ import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import cn.ykbox.dashboard.activity.StartActivity;
|
||||
|
||||
|
||||
public class BootBroadcastReceiver extends BroadcastReceiver {
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
package cn.ykbox.dashboard;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.AsyncTask;
|
||||
@@ -13,22 +15,19 @@ import java.util.concurrent.TimeUnit;
|
||||
public class ConfigReader {
|
||||
private static final String TAG = "ConfigReader";
|
||||
private static final String PREFS_NAME = "AppConfig";
|
||||
private static final String POWER_OFF_TIME_KEY = "PowerOffTVTime";
|
||||
private static final String SERIAL_CONFIG_KEY = "SerialConfig";
|
||||
|
||||
private Context context;
|
||||
private String configUrl;
|
||||
private OkHttpClient httpClient;
|
||||
private OnConfigLoadListener listener;
|
||||
|
||||
// 回调接口
|
||||
public interface OnConfigLoadListener {
|
||||
void onConfigLoaded(String powerOffTime);
|
||||
void onConfigLoaded();
|
||||
void onConfigLoadFailed(String error);
|
||||
}
|
||||
|
||||
public ConfigReader(Context context, String configUrl) {
|
||||
public ConfigReader(Context context) {
|
||||
this.context = context;
|
||||
this.configUrl = configUrl;
|
||||
this.httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -40,56 +39,40 @@ public class ConfigReader {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
// 异步读取配置文件
|
||||
public void loadConfig() {
|
||||
new ConfigLoadTask().execute();
|
||||
public void loadConfig(String configUrl) {
|
||||
new ConfigLoadTask().execute(configUrl);
|
||||
}
|
||||
|
||||
// 从本地获取保存的PowerOffTVTime
|
||||
public String getSavedPowerOffTime() {
|
||||
public String getSavedSerialConfig() {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return prefs.getString(POWER_OFF_TIME_KEY, "23:00"); // 默认值
|
||||
return prefs.getString(SERIAL_CONFIG_KEY, "{}");
|
||||
}
|
||||
|
||||
// 保存PowerOffTVTime到本地
|
||||
private void savePowerOffTime(String powerOffTime) {
|
||||
private void saveSerialConfig(String serialJsonString) {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString(POWER_OFF_TIME_KEY, powerOffTime);
|
||||
editor.putString(SERIAL_CONFIG_KEY, serialJsonString);
|
||||
editor.apply();
|
||||
Log.d(TAG, "PowerOffTVTime saved: " + powerOffTime);
|
||||
Log.d(TAG, "Serial config saved: " + serialJsonString);
|
||||
}
|
||||
|
||||
// 异步任务类
|
||||
private class ConfigLoadTask extends AsyncTask<Void, Void, String> {
|
||||
private class ConfigLoadTask extends AsyncTask<String, Void, JSONObject> {
|
||||
private String errorMessage = null;
|
||||
|
||||
@Override
|
||||
protected String doInBackground(Void... voids) {
|
||||
protected JSONObject doInBackground(String... params) {
|
||||
String configUrl = params[0];
|
||||
try {
|
||||
// 创建HTTP请求
|
||||
Request request = new Request.Builder()
|
||||
.url(configUrl)
|
||||
.addHeader("Accept", "application/json")
|
||||
.build();
|
||||
|
||||
// 执行请求
|
||||
Request request = new Request.Builder().url(configUrl).addHeader("Accept", "application/json").build();
|
||||
Response response = httpClient.newCall(request).execute();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String jsonString = response.body().string();
|
||||
Log.d(TAG, "Received JSON: " + jsonString);
|
||||
|
||||
// 解析JSON
|
||||
JSONObject jsonObject = new JSONObject(jsonString);
|
||||
String powerOffTime = jsonObject.getString("PowerOffTVTime");
|
||||
|
||||
return powerOffTime;
|
||||
return new JSONObject(jsonString);
|
||||
} else {
|
||||
errorMessage = "HTTP请求失败,状态码: " + response.code();
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
errorMessage = "网络连接失败: " + e.getMessage();
|
||||
Log.e(TAG, errorMessage, e);
|
||||
@@ -102,17 +85,27 @@ public class ConfigReader {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(String powerOffTime) {
|
||||
if (powerOffTime != null) {
|
||||
// 保存到本地
|
||||
savePowerOffTime(powerOffTime);
|
||||
protected void onPostExecute(JSONObject jsonObject) {
|
||||
if (jsonObject != null) {
|
||||
try {
|
||||
if (jsonObject.has("Serial")) {
|
||||
JSONObject serialObject = jsonObject.getJSONObject("Serial");
|
||||
saveSerialConfig(serialObject.toString());
|
||||
|
||||
// 通知回调
|
||||
if (listener != null) {
|
||||
listener.onConfigLoaded(powerOffTime);
|
||||
listener.onConfigLoaded();
|
||||
}
|
||||
} else {
|
||||
throw new JSONException("JSON中缺少 'Serial' 字段");
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
String error = "解析JSON字段时出错: " + e.getMessage();
|
||||
Log.e(TAG, error, e);
|
||||
if (listener != null) {
|
||||
listener.onConfigLoadFailed(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 通知错误
|
||||
if (listener != null) {
|
||||
listener.onConfigLoadFailed(errorMessage != null ? errorMessage : "未知错误");
|
||||
}
|
||||
@@ -120,7 +113,6 @@ public class ConfigReader {
|
||||
}
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
public void release() {
|
||||
if (httpClient != null) {
|
||||
httpClient.dispatcher().executorService().shutdown();
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
public class ScheduledCommandTimer {
|
||||
private static final String TAG = "ScheduledCommandTimer";
|
||||
|
||||
private Handler handler;
|
||||
private Runnable timerRunnable;
|
||||
private boolean isTimerRunning = false;
|
||||
|
||||
// 配置参数
|
||||
private long checkInterval = 10 * 1000; // 10秒检查间隔
|
||||
private int targetHour = 14; // 目标时间:小时
|
||||
private int targetMinute = 30; // 目标时间:分钟
|
||||
private long commandInterval = 10 * 1000; // 命令发送间隔10秒
|
||||
private int maxCommandCount = 3; // 最多发送次数
|
||||
|
||||
// 状态变量
|
||||
private int commandSentCount = 0;
|
||||
private boolean hasReachedTargetTime = false;
|
||||
private long firstCommandTime = 0;
|
||||
|
||||
// 回调接口
|
||||
private CommandCallback commandCallback;
|
||||
private TimerStatusCallback statusCallback;
|
||||
|
||||
// 回调接口定义
|
||||
public interface CommandCallback {
|
||||
void onCommandExecute(int currentCount, int totalCount);
|
||||
}
|
||||
|
||||
public interface TimerStatusCallback {
|
||||
void onTimerStarted();
|
||||
void onTimerStopped();
|
||||
void onTargetTimeReached();
|
||||
void onCommandSequenceCompleted();
|
||||
void onTimeCheck(int currentHour, int currentMinute);
|
||||
}
|
||||
|
||||
// 构造函数
|
||||
public ScheduledCommandTimer() {
|
||||
initTimer();
|
||||
}
|
||||
|
||||
public ScheduledCommandTimer(int targetHour, int targetMinute) {
|
||||
this.targetHour = targetHour;
|
||||
this.targetMinute = targetMinute;
|
||||
initTimer();
|
||||
}
|
||||
|
||||
// 建造者模式配置类
|
||||
public static class Builder {
|
||||
private ScheduledCommandTimer timer = new ScheduledCommandTimer();
|
||||
|
||||
public Builder setTargetTime(int hour, int minute) {
|
||||
timer.targetHour = hour;
|
||||
timer.targetMinute = minute;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setCheckInterval(long intervalMs) {
|
||||
timer.checkInterval = intervalMs;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setCommandInterval(long intervalMs) {
|
||||
timer.commandInterval = intervalMs;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setMaxCommandCount(int count) {
|
||||
timer.maxCommandCount = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setCommandCallback(CommandCallback callback) {
|
||||
timer.commandCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setStatusCallback(TimerStatusCallback callback) {
|
||||
timer.statusCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScheduledCommandTimer build() {
|
||||
timer.initTimer();
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
|
||||
private void initTimer() {
|
||||
handler = new Handler(Looper.getMainLooper());
|
||||
timerRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
checkTimeAndSendCommand();
|
||||
|
||||
if (isTimerRunning) {
|
||||
handler.postDelayed(this, checkInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 启动定时器
|
||||
public void start() {
|
||||
if (!isTimerRunning) {
|
||||
isTimerRunning = true;
|
||||
handler.post(timerRunnable);
|
||||
Log.d(TAG, "定时器已启动");
|
||||
|
||||
if (statusCallback != null) {
|
||||
statusCallback.onTimerStarted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止定时器
|
||||
public void stop() {
|
||||
if (isTimerRunning) {
|
||||
isTimerRunning = false;
|
||||
handler.removeCallbacks(timerRunnable);
|
||||
Log.d(TAG, "定时器已停止");
|
||||
|
||||
if (statusCallback != null) {
|
||||
statusCallback.onTimerStopped();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重启定时器
|
||||
public void restart() {
|
||||
stop();
|
||||
resetCommandState();
|
||||
start();
|
||||
}
|
||||
|
||||
private void checkTimeAndSendCommand() {
|
||||
Calendar now = Calendar.getInstance();
|
||||
int currentHour = now.get(Calendar.HOUR_OF_DAY);
|
||||
int currentMinute = now.get(Calendar.MINUTE);
|
||||
|
||||
Log.d(TAG, String.format("时间检查: %02d:%02d, 目标: %02d:%02d",
|
||||
currentHour, currentMinute, targetHour, targetMinute));
|
||||
|
||||
if (statusCallback != null) {
|
||||
statusCallback.onTimeCheck(currentHour, currentMinute);
|
||||
}
|
||||
|
||||
// 检查是否到达指定时间
|
||||
if (currentHour == targetHour && currentMinute >= targetMinute) {
|
||||
if (!hasReachedTargetTime) {
|
||||
hasReachedTargetTime = true;
|
||||
firstCommandTime = System.currentTimeMillis();
|
||||
commandSentCount = 0;
|
||||
Log.d(TAG, "到达目标时间,开始发送命令序列");
|
||||
|
||||
if (statusCallback != null) {
|
||||
statusCallback.onTargetTimeReached();
|
||||
}
|
||||
}
|
||||
|
||||
processCommandSequence();
|
||||
} else {
|
||||
if (hasReachedTargetTime) {
|
||||
resetCommandState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processCommandSequence() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long elapsedTime = currentTime - firstCommandTime;
|
||||
|
||||
// 检查是否在30秒窗口内且未超过最大发送次数
|
||||
if (elapsedTime <= 30 * 1000 && commandSentCount < maxCommandCount) {
|
||||
long expectedCommandTime = firstCommandTime + (commandSentCount * commandInterval);
|
||||
|
||||
if (currentTime >= expectedCommandTime) {
|
||||
commandSentCount++;
|
||||
Log.d(TAG, String.format("执行命令 %d/%d, 已用时: %d毫秒",
|
||||
commandSentCount, maxCommandCount, elapsedTime));
|
||||
|
||||
if (commandCallback != null) {
|
||||
commandCallback.onCommandExecute(commandSentCount, maxCommandCount);
|
||||
}
|
||||
|
||||
// 检查是否完成所有命令
|
||||
if (commandSentCount >= maxCommandCount) {
|
||||
Log.d(TAG, "命令序列执行完成");
|
||||
if (statusCallback != null) {
|
||||
statusCallback.onCommandSequenceCompleted();
|
||||
}
|
||||
resetCommandState();
|
||||
}
|
||||
}
|
||||
} else if (elapsedTime > 30 * 1000) {
|
||||
Log.d(TAG, "30秒窗口超时,重置状态");
|
||||
resetCommandState();
|
||||
}
|
||||
}
|
||||
|
||||
private void resetCommandState() {
|
||||
hasReachedTargetTime = false;
|
||||
commandSentCount = 0;
|
||||
firstCommandTime = 0;
|
||||
Log.d(TAG, "命令发送状态已重置");
|
||||
}
|
||||
|
||||
// 获取当前状态
|
||||
public boolean isRunning() {
|
||||
return isTimerRunning;
|
||||
}
|
||||
|
||||
public boolean hasReachedTarget() {
|
||||
return hasReachedTargetTime;
|
||||
}
|
||||
|
||||
public int getCommandSentCount() {
|
||||
return commandSentCount;
|
||||
}
|
||||
|
||||
public long getRemainingTime() {
|
||||
if (!hasReachedTargetTime) return -1;
|
||||
long elapsedTime = System.currentTimeMillis() - firstCommandTime;
|
||||
return Math.max(0, 30 * 1000 - elapsedTime);
|
||||
}
|
||||
|
||||
// 配置方法
|
||||
public void setTargetTime(int hour, int minute) {
|
||||
this.targetHour = hour;
|
||||
this.targetMinute = minute;
|
||||
resetCommandState();
|
||||
}
|
||||
|
||||
public void setCommandCallback(CommandCallback callback) {
|
||||
this.commandCallback = callback;
|
||||
}
|
||||
|
||||
public void setStatusCallback(TimerStatusCallback callback) {
|
||||
this.statusCallback = callback;
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
public void destroy() {
|
||||
stop();
|
||||
commandCallback = null;
|
||||
statusCallback = null;
|
||||
handler = null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultCallback;
|
||||
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.databinding.ActivityBuildingDashboardBinding;
|
||||
import cn.ykbox.dashboard.receiver.CommandBroadcastReceiver;
|
||||
|
||||
public class BuildingDashboardActivity extends FullscreenActivity {
|
||||
private final static String TAG = "DashboardActivity";
|
||||
|
||||
/**
|
||||
* Whether or not the system UI should be auto-hidden after
|
||||
* {@link #AUTO_HIDE_DELAY_MILLIS} milliseconds.
|
||||
*/
|
||||
private static final boolean AUTO_HIDE = true;
|
||||
|
||||
/**
|
||||
* If {@link #AUTO_HIDE} is set, the number of milliseconds to wait after
|
||||
* user interaction before hiding the system UI.
|
||||
*/
|
||||
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 ScheduledCommandTimer timer;
|
||||
private String mainUrl;
|
||||
private String configUrl;
|
||||
private ConfigReader configReader;
|
||||
private String lastAppliedSerialConfig = null;
|
||||
|
||||
/**
|
||||
* Touch listener to use for in-layout UI controls to delay hiding the
|
||||
* system UI. This is to prevent the jarring behavior of controls going away
|
||||
* while interacting with activity UI.
|
||||
*/
|
||||
private final View.OnTouchListener mDelayHideTouchListener = new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View view, MotionEvent motionEvent) {
|
||||
// --- Periodic Config Loading ---
|
||||
private final Handler configLoadHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable configLoadRunnable;
|
||||
|
||||
private ActivityBuildingDashboardBinding binding;
|
||||
private ActivityResultLauncher<Intent> settingsLauncher;
|
||||
|
||||
private final View.OnTouchListener mDelayHideTouchListener = (view, motionEvent) -> {
|
||||
switch (motionEvent.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
if (AUTO_HIDE) {
|
||||
@@ -55,11 +62,7 @@ public class BuildingDashboardActivity extends FullscreenActivity {
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
private ActivityBuildingDashboardBinding binding;
|
||||
/// ActivityResultLauncher 用于处理设置页面的返回结果
|
||||
private ActivityResultLauncher<Intent> settingsLauncher;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -69,184 +72,162 @@ public class BuildingDashboardActivity extends FullscreenActivity {
|
||||
super.setViews(binding.webview, binding.fullscreenContentControls);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// 初始化 ActivityResultLauncher
|
||||
initSettingsLauncher();
|
||||
setupConfig();
|
||||
initListener();
|
||||
initWebView();
|
||||
initTimer();
|
||||
|
||||
// Upon interacting with UI controls, delay any scheduled hide()
|
||||
// operations to prevent the jarring behavior of controls going away
|
||||
// while interacting with the UI.
|
||||
binding.settingsButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
// 打开设置界面
|
||||
openSettingsActivity();
|
||||
}
|
||||
});
|
||||
initConfigLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
if (timer != null) {
|
||||
timer.start();
|
||||
}
|
||||
|
||||
SharedPreferences pre = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
String url = pre.getString("k_url", "http://10.1.58.176:8002");
|
||||
binding.webview.loadUrl(url); // 加载网页
|
||||
mainUrl = url + "/index.html";
|
||||
configUrl = url + "/data/config.json";
|
||||
|
||||
configLoadHandler.post(configLoadRunnable);
|
||||
binding.webview.loadUrl(mainUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
if (timer != null) {
|
||||
timer.stop();
|
||||
}
|
||||
configLoadHandler.removeCallbacks(configLoadRunnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (timer != null) {
|
||||
timer.destroy();
|
||||
}
|
||||
if (configReader != null) {
|
||||
configReader.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化设置页面的 ActivityResultLauncher
|
||||
*/
|
||||
private void initListener() {
|
||||
binding.settingsButton.setOnClickListener(view -> openSettingsActivity());
|
||||
}
|
||||
|
||||
private void initSettingsLauncher() {
|
||||
settingsLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
|
||||
}
|
||||
result -> {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开设置页面
|
||||
*/
|
||||
private void initWebView() {
|
||||
binding.webview.getSettings().setJavaScriptEnabled(true);
|
||||
binding.webview.setWebViewClient(new WebViewClient());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "configUrl is empty, skipping config load.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, pendingIntent);
|
||||
Log.d(TAG, "Set repeating alarm for " + time + " with command " + hex + " on port " + portPath + " at " + baudRate + " baud");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void openSettingsActivity() {
|
||||
Intent intent = new Intent(this, SettingsActivity.class);
|
||||
settingsLauncher.launch(intent);
|
||||
}
|
||||
|
||||
private void setupConfig(String configUrl) {
|
||||
// 初始化配置读取器
|
||||
configReader = new ConfigReader(this, configUrl);
|
||||
configReader.setOnConfigLoadListener(new ConfigReader.OnConfigLoadListener() {
|
||||
@Override
|
||||
public void onConfigLoaded(String powerOffTime) {
|
||||
// 配置加载成功
|
||||
Log.i("MainActivity", "PowerOffTVTime加载成功: " + powerOffTime);
|
||||
// 可以在这里更新UI或执行其他操作
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigLoadFailed(String error) {
|
||||
// 配置加载失败
|
||||
Log.e("MainActivity", "配置加载失败: " + error);
|
||||
|
||||
// 使用本地保存的配置
|
||||
String savedTime = configReader.getSavedPowerOffTime();
|
||||
Log.i("MainActivity", "使用本地保存的PowerOffTVTime: " + savedTime);
|
||||
}
|
||||
});
|
||||
|
||||
// 加载配置
|
||||
configReader.loadConfig();
|
||||
|
||||
// 或者直接获取本地保存的值
|
||||
String localPowerOffTime = configReader.getSavedPowerOffTime();
|
||||
Log.i("MainActivity", "本地PowerOffTVTime: " + localPowerOffTime);
|
||||
}
|
||||
|
||||
private void initWebView() {
|
||||
binding.webview.getSettings().setJavaScriptEnabled(true); // 启用 JavaScript
|
||||
binding.webview.setWebViewClient(new WebViewClient()); // 防止跳转到外部浏览器
|
||||
}
|
||||
|
||||
private void initTimer() {
|
||||
// 方式1:使用建造者模式
|
||||
timer = new ScheduledCommandTimer.Builder()
|
||||
.setTargetTime(14, 30) // 设置目标时间为14:30
|
||||
.setCheckInterval(10 * 1000) // 10秒检查一次
|
||||
.setCommandInterval(10 * 1000) // 命令间隔10秒
|
||||
.setMaxCommandCount(3) // 最多发送3次
|
||||
.setCommandCallback(new ScheduledCommandTimer.CommandCallback() {
|
||||
@Override
|
||||
public void onCommandExecute(int currentCount, int totalCount) {
|
||||
// 执行你的命令逻辑
|
||||
executeCommand(currentCount, totalCount);
|
||||
}
|
||||
})
|
||||
.setStatusCallback(new ScheduledCommandTimer.TimerStatusCallback() {
|
||||
@Override
|
||||
public void onTimerStarted() {
|
||||
Log.d("MainActivity", "定时器启动");
|
||||
showToast("定时器已启动");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimerStopped() {
|
||||
Log.d("MainActivity", "定时器停止");
|
||||
showToast("定时器已停止");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTargetTimeReached() {
|
||||
Log.d("MainActivity", "到达目标时间");
|
||||
showToast("到达目标时间,开始执行命令");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommandSequenceCompleted() {
|
||||
Log.d("MainActivity", "命令序列完成");
|
||||
showToast("所有命令执行完成");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimeCheck(int currentHour, int currentMinute) {
|
||||
// 可选:更新UI显示当前时间
|
||||
updateTimeDisplay(currentHour, currentMinute);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
// 方式2:直接构造
|
||||
// timer = new ScheduledCommandTimer(14, 30);
|
||||
// timer.setCommandCallback(commandCallback);
|
||||
// timer.setStatusCallback(statusCallback);
|
||||
}
|
||||
|
||||
private void executeCommand(int currentCount, int totalCount) {
|
||||
// 在这里实现你的具体命令逻辑
|
||||
Log.i("MainActivity", String.format("执行命令 %d/%d", currentCount, totalCount));
|
||||
|
||||
// 示例:发送网络请求
|
||||
// sendNetworkRequest();
|
||||
|
||||
// 示例:调用系统服务
|
||||
// callSystemService();
|
||||
|
||||
// 示例:发送广播
|
||||
// sendBroadcast();
|
||||
|
||||
runOnUiThread(() -> {
|
||||
showToast(String.format("执行命令 %d/%d", currentCount, totalCount));
|
||||
});
|
||||
}
|
||||
|
||||
private void showToast(String message) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.os.Build;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import cn.ykbox.dashboard.R;
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.ykbox.dashboard;
|
||||
package cn.ykbox.dashboard.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -11,6 +11,8 @@ import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import cn.ykbox.dashboard.R;
|
||||
|
||||
public class StartActivity extends AppCompatActivity {
|
||||
private Context mContext;
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.ykbox.dashboard.receiver;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
import cn.ykbox.dashboard.serial.SerialControlDevices;
|
||||
|
||||
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
|
||||
|
||||
@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);
|
||||
// 新增:从 Intent 中获取波特率,如果不存在则默认为 9600
|
||||
int baudRate = intent.getIntExtra(EXTRA_BAUD_RATE, 9600);
|
||||
|
||||
if (hexCommand != null && !hexCommand.isEmpty() && portPath != null && !portPath.isEmpty()) {
|
||||
Log.d(TAG, "Received alarm to send command '" + hexCommand + "' to port '" + portPath + "' at " + baudRate + " baud");
|
||||
// 使用新的静态方法发送指令
|
||||
boolean success = SerialControlDevices.sendCommand(portPath, baudRate, hexCommand);
|
||||
if (!success) {
|
||||
Log.e(TAG, "Failed to send command via broadcast receiver.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,53 @@
|
||||
/*************************************************************************
|
||||
* 版权所有 (C) 2024 宁波升维信息技术有限公司. 保留所有权利.
|
||||
*
|
||||
* 本软件是由 宁波升维信息技术有限公司 开发的。
|
||||
* 未经版权所有者明确授权,任何人不得使用、复制、修改、分发本软件及其相关文档。
|
||||
* 本软件包含机密和专有信息,未经授权不得向任何第三方披露。
|
||||
*************************************************************************/
|
||||
package cn.ykbox.dashboard.serial;
|
||||
|
||||
import android.util.Log;
|
||||
import tp.xmaihh.serialport.SerialHelper;
|
||||
|
||||
/**
|
||||
* @description: 通过串口设置班牌的功能
|
||||
* @description: 通过串口设置班牌的功能。本类提供一个静态方法用于发送单次命令。
|
||||
* @author: Hu Zhang <hu.zhang@live.com>
|
||||
* @date: 2024/1/5
|
||||
**/
|
||||
|
||||
package com.nb6868.classtv.serial;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Message;
|
||||
import cn.ykbox.utils.Log;
|
||||
|
||||
import cn.ykbox.signageapi.utils.ByteArrayUtil;
|
||||
import tp.xmaihh.serialport.SerialHelper;
|
||||
import tp.xmaihh.serialport.bean.ComBean;
|
||||
|
||||
|
||||
public class SerialControlDevices extends SerialHelper {
|
||||
public class SerialControlDevices {
|
||||
private static final String TAG = "SerialControlDevices";
|
||||
|
||||
/**
|
||||
* 私有构造函数,防止外部实例化此类。
|
||||
*/
|
||||
private SerialControlDevices() {}
|
||||
|
||||
private OnErrorListener mErrorListener = null;
|
||||
|
||||
public static synchronized SerialControlDevices getInstance() {
|
||||
if (control == null) {
|
||||
control = new SerialControlDevices();
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
private SerialControlDevices() {
|
||||
super("/dev/ttyS3", 9600);
|
||||
try
|
||||
{
|
||||
super.open();
|
||||
Log.i(TAG,"串口打开成功");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "串口打开失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void setErrorListener(OnErrorListener listener) {
|
||||
this.mErrorListener = listener;
|
||||
/**
|
||||
* 打开指定串口,发送十六进制命令,然后立即关闭串口。
|
||||
*
|
||||
* @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(ComBean ComRecData) {
|
||||
Log.d(TAG,"onDataReceived: "+ ByteArrayUtil.toHexString(ComRecData.bRec));
|
||||
|
||||
String serialData = ByteArrayUtil.toHexString(ComRecData.bRec);
|
||||
Message msg = workHandler.obtainMessage();
|
||||
msg.what = SERIAL_MESSAGE;
|
||||
msg.obj = serialData;
|
||||
workHandler.sendMessage(msg);
|
||||
protected void onDataReceived(tp.xmaihh.serialport.bean.ComBean ComRecData) {
|
||||
// 可以在这里处理返回的数据,但对于单次发送任务,通常不需要
|
||||
// Log.d(TAG, "Received data from " + portPath + ": " + new String(ComRecData.bRec));
|
||||
}
|
||||
};
|
||||
|
||||
public void powerOffTV() {
|
||||
sendHex("AABB0600000001060304");
|
||||
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();
|
||||
}
|
||||
|
||||
public interface OnErrorListener {
|
||||
void onError(int error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cn.ykbox.dashboard.utils;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class ByteArrayUtil {
|
||||
|
||||
public static String toHexString(final byte[] bytes) {
|
||||
return toHexString(bytes, "");
|
||||
}
|
||||
|
||||
public static String toHexString(final byte[] bytes, String sep) {
|
||||
return toHexString(bytes, sep, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* byte 数组转化为16进制字符串
|
||||
* @param bytes 待转换的数据
|
||||
* @param sep 字符间插入的的符号
|
||||
* @param upper 是否转成大写
|
||||
* @return 格式为 0102AABBCC 的字符串
|
||||
*/
|
||||
public static String toHexString(final byte[] bytes, String sep, boolean upper) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
sb.append(String.format("%02x", bytes[i] & 0xff));
|
||||
if (!TextUtils.isEmpty(sep) && i != bytes.length - 1)
|
||||
sb.append(sep);
|
||||
}
|
||||
if(upper)
|
||||
return sb.toString().toUpperCase();
|
||||
else
|
||||
return sb.toString().toLowerCase();
|
||||
}
|
||||
|
||||
public static String toHexString(final String input) {
|
||||
return toHexString(input);
|
||||
}
|
||||
|
||||
public static String toHexString(final String input, String sep) {
|
||||
return toHexString(input, sep, true);
|
||||
}
|
||||
|
||||
public static String toHexString(final String input, String sep, boolean upper) {
|
||||
byte[] bytes = input.getBytes(StandardCharsets.UTF_8);
|
||||
return toHexString(bytes, sep, upper);
|
||||
}
|
||||
|
||||
// 字符串转换为 16 进制
|
||||
public static String stringToHex(String input) {
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (char character : input.toCharArray()) {
|
||||
hexString.append(Integer.toHexString((int) character));
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 16进制字符串转化为 byte 数组
|
||||
* @param s 待转换的16进制字符串
|
||||
* @return byte 数据
|
||||
*/
|
||||
public static byte[] toByteArray(String s) {
|
||||
// 去掉所有空白字符
|
||||
s = s.replaceAll("\\s", "");
|
||||
int len = s.length();
|
||||
|
||||
// 字符串长度必须为偶数
|
||||
if(len % 2 != 0)
|
||||
return null;
|
||||
|
||||
byte[] data = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
// 必须为16进制字符
|
||||
int a = Character.digit(s.charAt(i), 16);
|
||||
int b = Character.digit(s.charAt(i+1), 16);
|
||||
if(a == -1 || b == -1 )
|
||||
return null;
|
||||
data[i / 2] = (byte) ((byte) (a << 4) + (byte)b);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.ykbox.dashboard.viewmodel;
|
||||
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
public class AppViewModel extends ViewModel {
|
||||
private static final String TAG = "AppViewModel";
|
||||
|
||||
public MutableLiveData<String> livePowerOffTvHexString = new MutableLiveData<>();
|
||||
public MutableLiveData<String> liveSerialConfig = new MutableLiveData<>();
|
||||
|
||||
public void updateSerialConfig(String config) {
|
||||
liveSerialConfig.postValue(config);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,15 @@
|
||||
<Button
|
||||
android:id="@+id/settings_button"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="设置" />
|
||||
<Button
|
||||
android:id="@+id/power_off_tv_button"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="关电视" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
Submodule mod_serialport deleted from bcdf2d3b5b
Submodule mod_signageapi deleted from 23cdd575cc
Submodule mod_utils deleted from afdadcc932
Reference in New Issue
Block a user