逍遥游

Q7上面跑volumio 2.8,增加一个ESP32C3 的模块, 这个模块有显示屏和按键,显示屏接的是1.3寸OLED 显示屏, 按键接的是一个4个按键的模块,分别接到ESP32C3的GPIO

python 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# -*- coding: utf-8 -*-
import serial
import time
import json
import requests
import logging
from threading import Thread
from Queue import Queue
import sys

# 重新加载sys并设置默认编码
reload(sys)
sys.setdefaultencoding('utf-8')

# 配置日志 - 抑制 requests 和 urllib3 的日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)

SERIAL_PORT = '/dev/ttyACM0'
BAUD_RATE = 115200
VOLUMIO_HOST = 'localhost'
VOLUMIO_PORT = 3000

GET_STATE = 'http://{}:{}/api/v1/getState'.format(VOLUMIO_HOST, VOLUMIO_PORT)
GET_QUEUE = 'http://{}:{}/api/v1/getQueue'.format(VOLUMIO_HOST, VOLUMIO_PORT)

SEEK_UPDATE_INTERVAL = 2
MAX_RETRIES = 3
RETRY_DELAY = 5

# 创建会话对象,支持连接复用
session = requests.Session()

class VolumioController:
def __init__(self):
self.ser = None
self.command_queue = Queue()
self.running = True
self.volumio_available = True
self.serial_available = False
self.last_state_data = None
self.last_queue_data = None
self.data_cache_time = 0
self.queue_cache_time = 0
self.last_position = -1 # 记录上次的位置
self.last_uri = "" # 记录上次的URI,用于检测文件夹切换
self.cache_duration = 1.0 # 状态缓存1秒
self.queue_cache_duration = 2.0 # 队列缓存2秒,减少时间

def init_serial(self):
"""初始化串口连接,支持重试"""
retry_count = 0
while retry_count < MAX_RETRIES and self.running:
try:
self.ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=0.5)
self.serial_available = True
logging.info("串口连接成功: %s", SERIAL_PORT)
return True
except Exception as e:
retry_count += 1
logging.error("串口连接失败 (尝试 %d/%d): %s", retry_count, MAX_RETRIES, str(e))
if retry_count < MAX_RETRIES:
time.sleep(RETRY_DELAY)

logging.error("无法建立串口连接,程序退出")
return False

# def truncate_song_name(self, name, max_length=10):
# """截断歌曲名称,直接截取前max_length个字符"""
# if not name:
# return u"未知歌曲"

# # 确保是unicode字符串
# if not isinstance(name, unicode):
# try:
# name = name.decode('utf-8')
# except:
# name = u"未知歌曲"

# if len(name) <= max_length:
# return name

# # 直接截取前max_length个字符,不加省略号
# return name[:max_length]
def truncate_song_name(self, name, max_chinese=10, max_english=20):
"""截断歌曲名称,如果包含中文就用10个字符,否则用20个字符"""
if not name:
return u"未知歌曲"

# 确保是unicode字符串
if not isinstance(name, unicode):
try:
name = name.decode('utf-8')
except:
return u"未知歌曲"

# 检查是否包含中文字符
has_chinese = any(u'\u4e00' <= char <= u'\u9fff' for char in name)

# 选择最大长度
max_length = max_chinese if has_chinese else max_english

if len(name) <= max_length:
return name

return name[:max_length]

def safe_volumio_request(self, url, timeout=2, use_cache=False, cache_type="state"):
"""安全的Volumio请求,带重试机制和缓存"""
current_time = time.time()

# 使用缓存数据(如果可用且未过期)
if use_cache:
cache_duration = self.cache_duration if cache_type == "state" else self.queue_cache_duration
cache_time = self.data_cache_time if cache_type == "state" else self.queue_cache_time
cache_data = self.last_state_data if cache_type == "state" else self.last_queue_data

if cache_data and current_time - cache_time < cache_duration:
if url == GET_STATE and cache_type == "state":
return self.last_state_data
elif url == GET_QUEUE and cache_type == "queue":
return self.last_queue_data

for attempt in range(MAX_RETRIES):
try:
response = session.get(url, timeout=timeout)
if response.status_code == 200:
self.volumio_available = True
data = response.json()

# 缓存数据
if url == GET_STATE:
self.last_state_data = data
self.data_cache_time = current_time
elif url == GET_QUEUE:
self.last_queue_data = data
self.queue_cache_time = current_time

return data
else:
logging.warning("Volumio返回错误状态码: %d", response.status_code)
except Exception as e:
logging.warning("Volumio请求失败 (尝试 %d/%d): %s", attempt + 1, MAX_RETRIES, str(e))

if attempt < MAX_RETRIES - 1:
time.sleep(1)

self.volumio_available = False
logging.error("Volumio服务不可用")
return None

def get_volumio_status(self):
"""获取Volumio状态,带降级处理"""
state_data = self.safe_volumio_request(GET_STATE, use_cache=True, cache_type="state")
if not state_data:
return self.get_fallback_status()

try:
# 修正字段映射
seek_ms = state_data.get('seek', 0) # 毫秒
seek = int(seek_ms / 1000) # 转换为秒
duration = int(state_data.get('duration', 1) or 1)
title = state_data.get('title', u'无歌曲')
status = state_data.get('status', 'stop')
current_position = int(state_data.get('position', -1)) # 队列中的位置
current_uri = state_data.get('uri', '') # 当前歌曲的URI

# 检测文件夹切换 - 如果URI路径发生变化,强制刷新队列
force_queue_update = False
if current_uri and self.last_uri:
# 提取文件夹路径进行比较
current_folder = '/'.join(current_uri.split('/')[:-1])
last_folder = '/'.join(self.last_uri.split('/')[:-1])
if current_folder != last_folder:
logging.info("检测到文件夹切换,强制刷新队列")
force_queue_update = True

self.last_uri = current_uri

# 简化歌曲名称显示 - 当前歌曲保留完整名称
display_title = self.truncate_song_name(title, 30) # 当前歌曲显示30个字符

# 只有当需要时才获取队列信息
prev_song = u'无'
next_song = u'无'
if status == 'play' or status == 'pause':
# 检查位置是否改变或需要强制更新
position_changed = (current_position != self.last_position)
if position_changed or force_queue_update:
logging.info("位置变化或强制更新,重新获取队列信息")

prev_song, next_song = self.get_queue_info(
current_position,
force_update=(position_changed or force_queue_update)
)
self.last_position = current_position

return {
"title": display_title,
"prevSong": prev_song,
"nextSong": next_song,
"duration": duration,
"seek": seek,
"status": status
}
except Exception as e:
logging.error("解析Volumio状态数据失败: %s, 原始数据: %s", str(e), state_data)
return self.get_fallback_status()

def get_queue_info(self, position, force_update=False):
"""获取队列信息"""
# 修正:只有当位置有效时才获取队列
if position < 0:
return u'无', u'无'

# 如果强制更新,不使用缓存
use_cache = not force_update
queue_data = self.safe_volumio_request(GET_QUEUE, use_cache=use_cache, cache_type="queue")
if not queue_data:
return u'未知', u'未知'

try:
queue = queue_data.get('queue', [])
prev_song = u'无'
next_song = u'无'

if 0 <= position < len(queue):
if position > 0:
prev_item = queue[position - 1]
# 尝试多个可能的字段名
prev_name = prev_item.get('name', prev_item.get('title', u'无'))
# 队列中的歌曲名称截取前10个字符
prev_song = self.truncate_song_name(prev_name, 10)

if position < len(queue) - 1:
next_item = queue[position + 1]
# 尝试多个可能的字段名
next_name = next_item.get('name', next_item.get('title', u'无'))
# 队列中的歌曲名称截取前10个字符
next_song = self.truncate_song_name(next_name, 10)

logging.debug("队列信息: 位置=%d, 上一首=%s, 下一首=%s", position, prev_song, next_song)
return prev_song, next_song
except Exception as e:
logging.error("解析队列信息失败: %s, 队列数据: %s", str(e), queue_data)
return u'未知', u'未知'

def get_fallback_status(self):
"""返回降级状态"""
return {
"title": u"服务不可用",
"prevSong": u"未知",
"nextSong": u"未知",
"duration": 1,
"seek": 0,
"status": "stop"
}

def safe_serial_send(self, data):
"""安全的串口数据发送"""
if not self.serial_available or not self.ser:
return False

try:
# 确保所有字符串都是UTF-8编码的普通字符串,而不是Unicode对象
ascii_safe_data = {}
for key, value in data.items():
if isinstance(value, unicode):
# 将Unicode转换为UTF-8编码的字符串
ascii_safe_data[key] = value.encode('utf-8')
else:
ascii_safe_data[key] = value

# 使用 ensure_ascii=False 来支持中文
json_str = json.dumps(ascii_safe_data, ensure_ascii=False) + '\n'

# 在 Python 2.7 中,需要显式编码为 UTF-8
self.ser.write(json_str.encode('utf-8'))
return True
except Exception as e:
logging.error("串口发送失败: %s", str(e))
self.serial_available = False
self.try_reconnect_serial()
return False

def try_reconnect_serial(self):
"""尝试重新连接串口"""
if self.ser:
try:
self.ser.close()
except:
pass

logging.info("尝试重新连接串口...")
self.serial_available = self.init_serial()

def handle_esp32_command(self, cmd):
"""处理ESP32命令"""
cmd = cmd.strip().upper()
command_urls = {
'PLAY': 'http://{}:{}/api/v1/commands/?cmd=play'.format(VOLUMIO_HOST, VOLUMIO_PORT),
'PAUSE': 'http://{}:{}/api/v1/commands/?cmd=pause'.format(VOLUMIO_HOST, VOLUMIO_PORT),
'NEXT': 'http://{}:{}/api/v1/commands/?cmd=next'.format(VOLUMIO_HOST, VOLUMIO_PORT),
'PREV': 'http://{}:{}/api/v1/commands/?cmd=prev'.format(VOLUMIO_HOST, VOLUMIO_PORT)
}

if cmd in command_urls:
# 执行命令后,强制更新队列信息
self.safe_volumio_request(command_urls[cmd], timeout=1)
# 清除队列缓存,确保下次获取最新数据
self.last_queue_data = None
self.queue_cache_time = 0
# 重置位置跟踪
self.last_position = -1
else:
logging.warning("未知命令: %s", cmd)

def read_serial_commands(self):
"""读取串口命令的独立线程"""
while self.running:
if self.serial_available and self.ser and self.ser.inWaiting():
try:
line = self.ser.readline().strip()
if line:
# Python 2.7 解码处理
try:
line = line.decode('utf-8')
except UnicodeDecodeError:
try:
line = line.decode('gbk')
except:
line = line.decode('latin-1')

logging.info("收到ESP32命令: %s", line)
self.handle_esp32_command(line)
except Exception as e:
logging.error("串口读取错误: %s", str(e))
self.serial_available = False
time.sleep(0.1)

def run(self):
"""主运行循环"""
if not self.init_serial():
return

# 启动串口读取线程
serial_thread = Thread(target=self.read_serial_commands)
serial_thread.daemon = True
serial_thread.start()

last_title = ""
last_status = ""
last_seek_sent = -1
last_seek_time = time.time()
last_health_check = time.time()
last_full_update = 0

while self.running:
try:
current_time = time.time()

# 健康检查(减少频率)
if current_time - last_health_check > 30:
if not self.volumio_available:
logging.info("尝试恢复Volumio连接...")
test_data = self.safe_volumio_request(GET_STATE, timeout=1)
if test_data:
logging.info("Volumio连接已恢复")
last_health_check = current_time

# 获取状态并发送
song_info = self.get_volumio_status()

now = time.time()
# 减少完整状态发送频率
needs_full_update = (song_info['title'] != last_title or
song_info['status'] != last_status or
now - last_full_update > 30) # 每30秒强制更新一次

if needs_full_update:
if self.safe_serial_send(song_info):
last_title = song_info['title']
last_status = song_info['status']
last_seek_sent = song_info['seek']
last_seek_time = now
last_full_update = now
# 如果正在播放且 seek 超过更新时间间隔 → 发送 seek 更新
elif (song_info['status'] == 'play' and
(now - last_seek_time >= SEEK_UPDATE_INTERVAL)):
if song_info['seek'] != last_seek_sent:
if self.safe_serial_send({"seek": song_info['seek']}):
last_seek_sent = song_info['seek']
last_seek_time = now

time.sleep(0.2) # 增加休眠时间,减少请求频率

except Exception as e:
logging.error("主循环发生未预期错误: %s", str(e))
time.sleep(1)

def stop(self):
"""停止运行"""
self.running = False
if self.ser:
try:
self.ser.close()
except:
pass
session.close()

def main():
controller = VolumioController()
try:
controller.run()
except KeyboardInterrupt:
logging.info("收到中断信号,程序退出")
except Exception as e:
logging.error("程序运行出错: %s", str(e))
finally:
controller.stop()

if __name__ == '__main__':
main()


把这个脚本加入系统服务
sudo nano /etc/systemd/system/esp32_remote.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

[Unit]
Description=ESP32 Volumio Remote Control Service
After=network.target

[Service]
Type=simple
User=volumio
WorkingDirectory=/home/volumio
ExecStart=/usr/bin/python /home/volumio/remote_esp32.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target


ESP32 那边代码如下,主要部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422

sudo systemctl daemon-reload
sudo systemctl start esp32_remote.service
sudo systemctl status esp32_remote.service


// 初始化 SH1106,I2C 接口
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE,
/* SCL=*/ 5, // 自定义 SCL 引脚
/* SDA=*/ 4 // 自定义 SDA 引脚
);


// 定义按键引脚
#define BUTTON_PAUSE 8 // GPIO0 连接按键1(暂停)
#define BUTTON_PLAY 20 // GPIO1 连接按键2(播放)
#define BUTTON_NEXT 21 // GPIO2 连接按键3(下一首)
#define BUTTON_PREV 1 // GPIO3 连接按键4(上一首)


#define BUTTON_SONG1 2 // GPIO4 连接按键5(歌曲1)
#define BUTTON_SONG2 10 // GPIO5 连接按键6(歌曲2)
#define BUTTON_SONG3 6 // GPIO6 连接按键7(歌曲3)
#define BUTTON_SONG4 7 // GPIO7 连接按键8(歌曲4)

// 歌曲信息结构体
struct SongInfo {
String title;
String prevSong; // 上一首歌曲
String nextSong; // 下一首歌曲
int duration;
int seek;
String status;
};

// 命令定义
enum Command {
CMD_PLAY,
CMD_PAUSE,
CMD_NEXT,
CMD_PREV,
CMD_PLAY_SONG
};



// 按钮状态跟踪(用于防抖)
unsigned long lastPressTime = 0;
const uint16_t DEBOUNCE_DELAY = 600; // 防抖延迟(ms)

// 添加全局变量用于息屏控制
unsigned long lastActiveTime = 0; // 最后一次活动时间
const unsigned long SCREEN_OFF_DELAY = 10 * 60 * 1000; // 10分钟(毫秒)
bool screenOn = true; // 屏幕状态
SongInfo lastInfo;



// 发送命令到串口
void sendCommand(Command cmd, int dataLength = 0, byte* data = nullptr) {
switch(cmd) {
case CMD_PLAY:
Serial.println("PLAY"); // 使用println自动添加\r\n

break;
case CMD_PAUSE:
Serial.println("PAUSE");

break;
case CMD_NEXT:
Serial.println("NEXT");

break;
case CMD_PREV:
Serial.println("PREV");

break;
case CMD_PLAY_SONG:
if(dataLength > 0 && data != nullptr) {
Serial.print("SONG:");
Serial.println(data[0]); // 使用println

}
break;
}

delay(20); // 给 Linux 足够时间接收
Serial.flush();
}

// 关闭屏幕
void turnOffScreen() {
u8g2.clearBuffer();
u8g2.sendBuffer();
screenOn = false;
}

// 打开屏幕
void turnOnScreen() {
screenOn = true;
// 屏幕内容会在下次updateDisplay调用时刷新
}


// 修改按键处理函数,返回是否有按键按下
bool handleButtonPress() {
// 防抖检查
static unsigned long lastPressTime = 0;
if (millis() - lastPressTime < DEBOUNCE_DELAY) return false;

bool buttonPressed = false;

// 检测按键按下(低电平触发)
if (digitalRead(BUTTON_PLAY) == LOW) {
sendCommand(CMD_PLAY);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_PAUSE) == LOW) {
sendCommand(CMD_PAUSE);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_NEXT) == LOW) {
sendCommand(CMD_NEXT);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_PREV) == LOW) {
sendCommand(CMD_PREV);
lastPressTime = millis();
buttonPressed = true;
}
#if GATEWAY
else if (digitalRead(BUTTON_SONG1) == LOW) {
byte songNum = 1;
sendCommand(CMD_PLAY_SONG, 1, &songNum);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_SONG2) == LOW) {
byte songNum = 2;
sendCommand(CMD_PLAY_SONG, 1, &songNum);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_SONG3) == LOW) {
byte songNum = 3;
sendCommand(CMD_PLAY_SONG, 1, &songNum);
lastPressTime = millis();
buttonPressed = true;
}
else if (digitalRead(BUTTON_SONG4) == LOW) {
byte songNum = 4;
sendCommand(CMD_PLAY_SONG, 1, &songNum);
lastPressTime = millis();
buttonPressed = true;
}
#endif

if (buttonPressed) {
if (!screenOn) turnOnScreen();
lastActiveTime = millis();
}

return buttonPressed;
}


void setup() {

pinMode(BUTTON_PAUSE, INPUT_PULLUP);
pinMode(BUTTON_PLAY, INPUT_PULLUP);
pinMode(BUTTON_NEXT, INPUT_PULLUP);
pinMode(BUTTON_PREV, INPUT_PULLUP);


#if GATEWAY


pinMode(BUTTON_SONG1, INPUT_PULLUP);
pinMode(BUTTON_SONG2, INPUT_PULLUP);
pinMode(BUTTON_SONG3, INPUT_PULLUP);
pinMode(BUTTON_SONG4, INPUT_PULLUP);


#endif

Serial.begin (115200);

//Mount FS

Serial.println("Mounting FS...");
if (!SPIFFS.begin(true)) {
//Serial.println("SPIFFS mount failed");
return;
}

u8g2.begin(); // 初始化屏幕
u8g2.clearBuffer(); // 清空缓冲区
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.setContrast(0); // 设置对比度 (0-255)


// 显示初始化界面
u8g2.drawUTF8(0, 15, "等待 Volumio 数据...");
u8g2.sendBuffer();


lastActiveTime = millis();



}

SongInfo parseJsonFull(String jsonStr) {
SongInfo info;
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, jsonStr);

if (error) {
// 打印错误信息
info.title = "数据错误";
info.prevSong = "未知";
info.nextSong = "未知";
info.duration = 1;
info.seek = 0;
info.status = "stop";
return info;
}

info.title = doc["title"] | "无歌曲";
info.prevSong = doc["prevSong"] | "无";
info.nextSong = doc["nextSong"] | "无";
info.status = doc["status"] | "stop";
info.duration = doc["duration"] | 1;
info.seek = doc["seek"] | 0;

if (info.duration <= 0) info.duration = 1;
if (info.seek < 0) info.seek = 0;
if (info.seek >= info.duration) info.seek = info.duration - 1;

return info;
}



// 解析 JSON 数据
SongInfo parseJson(String jsonStr) {
SongInfo info;
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, jsonStr);

if (error) {
info.title = "数据错误";
info.prevSong = "未知";
info.nextSong = "未知";
return info;
}

// 安全提取字段 (带默认值)
info.title = doc["title"] | "无歌曲";
info.prevSong = doc["prevSong"] | "无";
info.nextSong = doc["nextSong"] | "无";
info.status = doc["status"] | "stop";
info.duration = doc["duration"] | 1; // 防止除零
info.seek = doc["seek"] | 0;

// 强制数据有效性
if (info.duration <= 0) info.duration = 1;
if (info.seek < 0) info.seek = 0;
if (info.seek >= info.duration) info.seek = info.duration - 1;

return info;
}



// 安全映射函数 (防止 min==max 崩溃)
int mapSafe(int value, int in_min, int in_max, int out_min, int out_max) {
if (in_min == in_max) return out_min;
value = constrain(value, in_min, in_max);
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}



// 更新 OLED 显示 (完整歌曲名,无按键提示)
void updateDisplay(const SongInfo &info) {
// 如果屏幕是关闭状态,直接返回
if (!screenOn) {
return;
}

u8g2.clearBuffer();

// 1. 显示当前歌曲标题 (使用中文字体,第一行)
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
String displayTitle = info.title;
// 不进行截断,直接显示完整歌名
u8g2.drawUTF8(0, 12, displayTitle.c_str());

// 2. 显示上一首歌曲 (使用中文字体,第二行)
String prevSong = "前:" + info.prevSong;
// 不进行截断,直接显示完整歌名
u8g2.drawUTF8(0, 24, prevSong.c_str());

// 3. 显示下一首歌曲 (使用中文字体,第三行)
String nextSong = "后:" + info.nextSong;
// 不进行截断,直接显示完整歌名
u8g2.drawUTF8(0, 36, nextSong.c_str());

// 4. 绘制进度条
int progressWidth = mapSafe(info.seek, 0, info.duration, 0, 128);
u8g2.drawFrame(0, 44, 128, 6);
u8g2.drawBox(0, 44, progressWidth, 6);

// 5. 显示时间和状态
u8g2.setFont(u8g2_font_5x7_tf); // 使用5x7小字体
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d/%02d:%02d",
info.seek / 60, info.seek % 60,
info.duration / 60, info.duration % 60);
u8g2.drawStr(0, 58, timeStr);

// 播放状态 (使用中文字体)
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
String statusText = (info.status == "play") ? "播放" : "暂停";
u8g2.drawUTF8(80, 58, statusText.c_str());

u8g2.sendBuffer();
}


// 仅更新进度条和当前播放时间
void updateSeekOnly(int seek, int duration) {
// 绘制进度条
int progressWidth = mapSafe(seek, 0, duration, 0, 128);
u8g2.drawFrame(0, 44, 128, 6);
u8g2.drawBox(0, 44, progressWidth, 6);

// 更新时间显示
u8g2.setFont(u8g2_font_5x7_tf); // 小字体
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "%02d:%02d/%02d:%02d",
seek / 60, seek % 60,
duration / 60, duration % 60);
u8g2.drawStr(0, 58, timeStr);

// 保留播放状态显示
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
String statusText = (lastInfo.status == "play") ? "播放" : "暂停";
u8g2.drawUTF8(80, 58, statusText.c_str());

u8g2.sendBuffer();
}




// 修改检查息屏的函数
void checkScreenTimeout() {
unsigned long currentTime = millis();

// 如果正在播放,保持屏幕开启
if (lastInfo.status == "play") {
if (!screenOn) turnOnScreen();
return;
}

// 如果是暂停状态,检查是否超时
if (lastInfo.status == "pause" || lastInfo.status == "stop") {
if (screenOn && (currentTime - lastActiveTime > SCREEN_OFF_DELAY)) {
turnOffScreen();
}
}
}

// 修改loop函数中的数据处理逻辑
void loop() {
if (Serial.available()) {
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() > 0) {
DynamicJsonDocument doc(2048);
DeserializationError err = deserializeJson(doc, line);

if (!err && doc.containsKey("seek") && !doc.containsKey("title")) {
// 进度更新
int seek = doc["seek"];
updateSeekOnly(seek, lastInfo.duration);
lastInfo.seek = seek;
} else {
// 完整状态更新
SongInfo newInfo = parseJsonFull(line);

// 检查状态是否有重要变化
bool significantChange = (newInfo.title != lastInfo.title ||
newInfo.status != lastInfo.status);

lastInfo = newInfo;
updateDisplay(lastInfo);

// 只有重要变化才重置息屏计时器
if (significantChange) {
lastActiveTime = millis();
}
}
}
}

// 按键操作重置息屏计时器
if (handleButtonPress()) {
lastActiveTime = millis();
}

checkScreenTimeout();
delay(50);
}