逍遥游

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);
}



Q7 能跑一个volumio 2.8的固件
地址在
2.6
http://updates.volumio.org/vim1/volumio/2.603/volumio-2.603-2019-09-03-vim1.img.zip

2.8
http://updates.volumio.org/aml9xxxarmv7/volumio/2.857/volumio-2.857-2020-12-11-aml9xxxarmv7.img.zip

我运行是下面2.8这个版本

dtb 文件改一下, 改成一个dtb.img,这个文件我忘记是从哪里复制的了

apt 的源 要改一下
deb http://archive.debian.org/debian jessie main contrib non-free
deb http://archive.debian.org/debian-security jessie/updates main contrib non-free

下面来自恩山

PS 哪位大大 有Volumio docker ARM 版本的
hub.docker.com上面找到 只有 X86 版本的
N1用不了

补充一下,一、初绍化volumio
有朋友反馈无法使用以太网,需要替换DTB.img
我用的是无线,所以没有去试,那们朋友如果有回复需要替换那个文件 我再更新
42# @LXPCWL 朋友已经解决了这个问题
无线初始化大概说一下,论坛里面应该有比较详细的
1、写入U盘 后,启动(这个版本可以写入emmc)
2、电脑(我是用笔记本,手机也可以)搜索到一个叫 volumio SSID
3、连接上 查看一下获取到IP地址是不是 192.168.211.X
4、访问http://192.168.211.1:3000
5、按照提示设置一下 volumio连接进你的家庭WIFI

二、打开ssh 访问 http://volumioIP:3000/dev
ssh enable然后就可以 ssh登录volumio 了
用户名密码都是 volumio
root 的密码也是 volumio

图便宜15元一个 ,买了5个 . 谁知道是高安版, 而且没有wifi . 刷机过程也是折腾

  1. 要下载一个兼容的安卓镜像, 幸运的找到一个 “小黄IHO3300AD(905)四川高安最新没有无线线刷2261.img”,如果不是,卡在1%不动

  2. usb 公头用软件烧入, 这个步骤很奇怪, 有时候这个usb口不行,换下一个usb口就可以, 可能是我笔记本usb hub 问题?

  3. 焊好TTL先, 其中有2个跳线断开了, 2R1 2R24 短接, 恢复ttl功能.

  4. 启动进入安卓系统
    2r1 2r24 短接 恢复ttl功能
    ttl 终端 密码
    sh login:root
    Password:Ch4008111666

5.烧好一个armbian镜像. 发觉meson-gxl-s905l2-x7-5g.dtb 可用, 修改uEnv.txt 文件,改过来, 然后extlinux 里面 的conf文件也要把bak去掉,变成extlinux.conf,这样就能启动, 不过网卡没用
要把u-boot-s905x-s905lb.bin 复制 改名为 u-boot.ext, 这样就能启动了 ,并有网卡.

  1. 插入tf卡,启动安卓, 并进入系统,执行reboot update ,就可以启动armbian

  2. 执行armbian-install . 不过安装后没有网卡, 不止什么原因,搞了很久也不想, 折腾到这里结束

Rank0: 1024MB(auto)-2T-18
AddrBus test pass!
-s
Load fip header from eMMC, src: 0x0000c200, des: 0x01400000, size: 0x00004000
aml log : R2048 check pass!
New fip structure!
Load bl30 from eMMC, src: 0x00010200, des: 0x01700000, size: 0x00007600
aml log : R2048 check pass!
Load bl301 from eMMC, src: 0x00018200, des: 0x01700000, size: 0x00002400
aml log : R2048 check pass!
Load bl31 from eMMC, src: 0x0001c200, des: 0x01700000, size: 0x00019600
aml log : R2048 check pass!
Load bl33 from eMMC, src: 0x00038200, des: 0x01700000, size: 0x0007b000
aml log : R2048 check pass!
NOTICE: BL3-1: v1.0(debug):521e8c3
NOTICE: BL3-1: Built : 14:22:49, Jun 5 2018
NOTICE: BL31: GXL secure boot!
NOTICE: BL31: BL33 decompress pass
[Image: gxl_v1.1.3101-a78fa1e 2018-06-04 16:24:21 huan.biao@droid12]
efuse init ops = c5
efuse init hdcp = c, cf9=7
bl30: check_permit, count is 1
bl30: check_permit: ok!
chipid: 0 0 7 1c c 0 32 b4 40 a0 0 c5 not ES chip
[1.633107 Inits done]
INFO: BL3-1: Initializing runtime services
WARNING: No OPTEE provided by BL2 boot loader
ERROR: Error initializing runtime service opteed_fast
INFO: BL3-1: Preparing for EL3 exit to normal world
INFO: BL3-1: Next image address = 0x1000000
INFO: BL3-1: Next image spsr = 0x3c9

U-Boot 2015.01 (May 25 2021 - 14:13:05), Build: jenkins-SC_IHO-3300AD_2140133-00060-3

DRAM: 1 GiB
Relocation Offset is: 36e8c000

内存不够, 需要增加swap

sudo fallocate -l 2G /swapfile # 新建 2G 大小的 swap
sudo chmod 600 /swapfile # 权限要正确
sudo mkswap /swapfile # 格式化成 swap
sudo swapon /swapfile # 启用 swap

free -h 查看

开大内存
export NODE_OPTIONS=”–max-old-space-size=1536”

编译时减少进程
npm install –unsafe-perm –no-audit –no-fund –maxsockets=1 –verbose

新版的armbian 在https://github.com/ophub/amlogic-s9xxx-armbian/releases里面,比我自己几年前编译的好多了, 但是wifi还是没有装驱动,可以手动编译一个

数码视讯的Q7的8189fs 驱动 ,注意, 必须要这个分支

1
2
3
4
5
6
7
8
git clone -b rtl8189fs  https://github.com/jwrdegoede/rtl8189ES_linux.git

然后就是编译
make -j4 ARCH=arm64 KSRC=/usr/lib/modules/$(uname -r)/build
sudo cp 8189fs.ko /usr/lib/modules/$(uname -r)/kernel/drivers/net/wireless/realtek/
sudo depmod -a
sudo modprobe 8189fs

我这个armbian版本的内核比较新.6.1版本, 4.x版本也试过,没问题

先安装必要的包

如果venv没有,先安装,我的python是3.12 , 根据你版本安装

安装下面的包
apt update
apt install python3.12-venv
apt install python3.12-dev
apt install portaudio19-dev

创建虚拟环境
python3 -m venv env
source env/bin/activate

安装包
pip3 install pvporcupine
pip3 install wheel
pip3 install pyaudio
pip3 install requests
pip3 install websocket-client
pip3 install openai

因为我这个电视盒原来有个音频设备, 现在修改第二个声卡为缺省声卡, 就是用usb声卡作为缺省声卡

编辑这个文件, 如果没有创建一个
/etc/asound.conf

1
2
3
defaults.pcm.card 1
defaults.pcm.device 0
defaults.ctl.card 1

调整声音大小用
alsamixer
打开这个程序可以看到usb声卡名称, 就说明上面设置对了

安装一个可以用python i2c 驱动一个显示屏的库
pip3 install luma.oled

前提是用一个CH347T做个一个usb 转i2c 的板
这个板的驱动可以github上面下载编译安装.

linux下面设置环境变量

包括在windows下面的bash等环境

1
2
export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890

Docker

上网桥接
docker network inspect bridge

ip addr show docker0
你会看到一个像 172.17.0.1 的地址,这个就是 Docker 容器与主机之间的网桥地址。

步骤 2:运行 Docker 容器并配置代理
你需要将容器的网络流量通过 Clash 代理,可以通过以下几种方式实现:

方法 1:在容器运行时设置环境变量
运行容器时,通过环境变量设置代理:

bash
复制代码

1
2
3
export HTTP_PROXY=http://172.17.0.1:7890
export HTTPS_PROXY=http://172.17.0.1:7890
export NO_PROXY=localhost,127.0.0.1

验证网络连接:

测试代理是否生效,例如:

bash
复制代码
curl https://www.google.com

  1. 使用 Docker Volumes(卷)
    Docker 卷允许你将容器的数据保存在主机的文件系统上。即使容器被删除,数据仍然保留在卷中。

创建和使用卷:

1
docker run -it --rm -v /path/on/host:/path/in/container node:18 bash

-v /path/on/host:/path/in/container:这将主机的 /path/on/host 目录与容器的 /path/in/container 目录进行挂载。所有写入 /path/in/container 的数据都会存储在主机的 /path/on/host 目录中。
示例:

1
docker run -it --rm -v ~/mydata:/data node:18 bash

在容器中对 /data 目录进行的所有更改都将保存在主机的 ~/mydata 目录中,即使容器停止或删除,数据也不会丢失。

过程记下来怕忘记了

  1. 打开amlogic 烧写软件, 打开Android的镜像.目前比较可行 晶晨S905L-R3300L-V21C-双直播当贝桌面-集成更多遥控适配-解决无线和直播卡顿问题-20180524版.img 这个文件, 打开烧录
  2. 靠近网口的usb (ba860盒子) 口连接到pc
  3. 短接C125 电容 (ba860盒子)
  4. 插入电源,不要松开C125 直到烧写完.
  5. ttl口接到PC,115200, 启动android.
  6. 插入我之前烧好了一个不记得什么系统的tf卡.
  7. 启动后,输入reboot update.
  8. 搞定后,可以换成armbian的系统卡.

下针行间,太冲

左右脚下针这两个穴位, 左脚下位置对一点, 太冲行针试试,第二脚趾有反应,下针位置片了一点,但是附近都又酸胀感, 右脚下不好,没什么感觉.
下完后本来右协有点不舒服, 下完后确实好一点. 第二天, 左甲状腺附近的压痛感也好一些. 右甲状腺附近好一点,但是还是右压痛.

四逆散

最近天气冷,手脚都冷, 感觉有点冰, 可能各种问题的原因把. 但是想单独试试四逆散, 柴胡白芍枳实甘草各6g.吃完,今天下午,手终于不想之前那么冷.
脚穿袜子,鞋 ,可能不是很明显. 但没有脚很暖的感觉. 手确实是暖.
另外,右边章门压痛很明显, 今天竟然不怎么压痛.

关于针灸的对侧治疗体会

  • 之前右协不舒服,针行间, 只针行间,左脚就比右脚有感觉.
  • 自己左胸不舒服,发现一直以来右脚的公孙位置就一直会痒,会长湿疹之类的,自己肺总有湿痰.

不废话,直接上流程

服务区端

mt7621的cpu的一个路由器 ,跑mipsle的程序,

客户端

这个客户端时一个wr703n的路由器, 用来做mqtt server , 跑mips程序,其中把web控制页面透过frp共享出去. 端口为80 .

frpc.ini

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
[common]
server_addr = xxx.xxx.com
server_port = 7000


# decide if exit program when first login failed, otherwise continuous relogin to frps
# default is true
login_fail_exit = true


# auth token
token = xxxxxxxxxxxxx


#[ssh]
#type = tcp
#local_ip = 127.0.0.1
#local_port = 22
#remote_port = 6000


[tai_web]
type = http
local_port = 80
privilege_mode = true
custom_domains = abc.abc.com


custom_domains = abc.abc.com 这个域名必须域名解析器加上,让其解析到 服务器ip,或者服务器的域名.

下面自启动进程

必须先安装 nohup

1
2
opkg update
opkg install coreutils-nohup

增加下面这个文件

/etc/init.d/frpc

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh /etc/rc.common
# Copyright (C) 2006-2011 OpenWrt.org

START=99

start() {
sleep 60; nohup /root/frp_0.35.1_linux_mips/frpc -c /root/frp_0.35.1_linux_mips/frpc.ini >/tmp/nohup.out 2>&1 &
}

stop() {
kill -9 `ps | grep '/root/frp_0.35.1_linux_mips/frpc' | grep -v 'grep' | awk '{print $1}'`
}

在/etc/rc.d/ 增加一个链接文件

ln -s /etc/init.d/frpc /etc/rc.d/S99frpc