小米粥声控音箱总体计划

关注树莓派很久了,只是没有很感兴趣的应用场景,就没有买来玩。几个月前偶然得到一个小度音箱,发现了新大陆,各种语音控制功能,便捷性不言而喻,还买了一些外部设备可以通过小度控制,发现有红外遥控器可以控制家里的大部分红外家电,奈何码库不是很全,有些设备还是不能控制的,而且不支持定制功能。恰好在知乎看到了一些 geek 视频,想着自己也做一个,可以支持红外数据的定制,做到自由遥控。于是乎说干就干,从一个什么硬件都不懂的小白一步步的了解了点硬件知识,软件部分相对好实现一些。主要计划的功能是通过语音来控制红外家电、温湿度监控以及智能提醒等功能,先完成主体框架然后再不断开发插件形式来增强可玩性。

计划主要分为两个子系统

  1. 软件子系统,主要实现语音到文字和文字到语音的转换,逻辑功能的处理等。
  2. 硬件子系统提供收音、音箱、温湿度传感器、红外收发、系统供电等能力的支持。

总体功能点进度如下

  • ok 显示,信息简单展示界面,计划采用 OLED12832 屏。
  • ok 收音,收集外接语音信息。
  • ok 音响,输出系统响应结果。
  • 温湿度,收集设备所处环境的温度和湿度。
  • ok 风扇,硬件系统散热。
  • ok 红外收/发,红外设备系统的录入和红外信号的发射,用于控制红外家电。
  • 供电模块,给音响和树莓派硬件供电。
  • ok pcb 电路版设计,传感器集成。
  • 3d 打印外壳,最后根据硬件的排列情况定制一个简洁的外壳。
  • ok 语音汉字互转,计划采用讯飞 API 接口实现,后面尝试做简单的语音识别模型。
  • ok 逻辑控制和输出输入设备控制模块,基于硬件传感器数据的采集和信息的归纳整理能力。

硬件部分

一直以来都是做的软件,这次从 0 到 1 一点点学的硬件,到 PCB 打样,焊板。没有遵循设计规范,只是按照能用的级别做的。

v0.2

实验数据收集

树莓派4 GPIO 引脚

一、电源输出引脚

3v3、5v代表:3.3伏特和5伏特,是输出供电的正极,也就是我们常说的Vcc

GND代表接地和输出供电的负极

特别注意:每个引脚最大输出电流为16毫安(mA),且同一时刻所有引脚的总输出电流不超过51毫安

二、GPIO

GPIO(General Purpose I/O Ports)意思为通用输入/输出端口,通俗地说,就是一些引脚,可以通过它们输出高低电平或者通过它们读入引脚的状态-是高电平或是低电平。GPIO是个比较重要的概念,用户可以通过GPIO口和硬件进行数据交互(如UART),控制硬件工作(如LED、蜂鸣器等),读取硬件的工作状态信号(如中断信号)等。GPIO口的使用非常广泛。掌握了GPIO,差不多相当于掌握了操作硬件的能力。树莓派有26个GPIO接口,其中有一部分是复用接口。

  1. 引脚3、5为IC总线复用接口
  2. 引脚7为(GCLK)全局时钟引脚复用接口
  3. 引脚19、21、23为SPI总线复用接口
  4. 引脚8、10为串口复用接口,TX发送,RX接收
  5. 引脚12、32、33、35为PWM复用接口

三、IC总线

IC是内部整合电路的称呼,是一种串行通讯总线,使用多主从架构,由飞利浦公司在1980年代为了让主板、嵌入式系统或手机用以连接低速周边装置而发展。IC的正确读法为”Inter-Integrated Circuit” 。

  • SDA:数据线
  • SCL:时钟线

四、SPI总线

SPI是串行外设接口(Serial Peripheral Interface)的缩写,是一种高速的,全双工,同步的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚,同时为PCB的布局上节省空间,提供方便,正是出于这种简单易用的特性,如今越来越多的芯片集成了这种通信协议。

  • MISO:数据输入
  • MOSI:数据输出
  • SCLK:时钟信号
  • SS:使能信号

五、UART总线

UART是一种通用串行数据总线,用于异步通信。该总线双向通信,可以实现全双工传输和接收。在嵌入式设计中,UART用于主机与辅助设备通信,如汽车音响与外接AP之间的通信,与PC机通信包括与监控调试器和其它器件,如EEPROM通信。
可以理解为计算机的串口。RS232、TTL。

  • RX是接收
  • TX是发送

六、PWM脉冲宽度调制

脉冲宽度调制是一种模拟控制方式,其根据相应载荷的变化来调制晶体管基极或MOS管栅极的偏置,来实现晶体管或MOS管导通时间的改变,从而实现开关稳压电源输出的改变。这种方式能使电源的输出电压在工作条件变化时保持恒定,是利用微处理器的数字信号对模拟电路进行控制的一种非常有效的技术。脉冲宽度调制是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术,广泛应用在从测量、通信到功率控制与变换的许多领域中。

AD 教程

AD 使用与硬件电路图画图和PCB图设计使用的。主要是看了B站的一个入门视频教程,然后再不断搜搜改改来实现的。

https://blog.csdn.net/wxh0000mm/article/details/70237722
https://www.bilibili.com/video/av94518044?p=1
https://www.zhihu.com/question/32069273

单位转换

  • 1.0mil = 0.025mm
  • 1.2mil = 0.030mm
  • 1.25mil = 0.032mm

DHT11 温湿度传感器

下面是温湿度传感器的基本电路图,这里本来是3pin方案到树莓派的,板子上也画好了,只不过在焊接的时候没有处理好,现在系统始终无法正确读数,只是在测试期间能正常读。

https://shumeipai.nxez.com/2019/10/06/reading-temperature-and-humidity-from-dht11-with-raspberry-pi.html

OLED 12832

这里使用了 Adafruit_Python_SSD1306 库来驱动液晶屏显示。

https://shumeipai.nxez.com/2019/04/29/use-the-ssd1306-oled-display-on-the-raspberry-pi.html

IR 收发

红外发射图,这里只画了两个,实际我是配置了4个红外发射二极管,限流电阻调整成 100R。

红外接收图,这里直接使用已经简单封装的传感器

IR的收发是主要调试的功能:

红外录入功能使用:
安装 Linux 下的红外控制库:

1
2
sudo apt-get update
sudo apt-get install lirc

更新 /boot/config.txt 文件来开启红外收发接口:

1
2
# Uncomment this to enable the lirc-rpi module
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=18,gpio_in_pull=up

更新 /etc/lirc/lirc_options.conf 文件来控制当前是接收模式还是发射模式,修改完重启服务生效:

1
device=/dev/lirc0

测试能否正常接收到红外信号:

1
2
3
4
5
6
7
8
mode2 -d /dev/lirc0

space 16777215
pulse 8999
space 4457
pulse 680
space 1627
......

可以通过 lirc 录制简单的红外设备生成遥控文件,如果空调这种比较复杂的不太好弄。

1
2
3
4
5
6
# 查看按键名称,这里一个红外码是绑定到一个按键上的,你需要找一些你录制的按键然后记下来。
irrecord -l
# 开启录制命令,这个录制过程比较复杂,需要先判断环境噪音,然后随机按键,最后才是录制按键,而我的有些红外设备按键无法录上有点奇怪,目前只有台灯的录进去了。
irrecord ~/lircd.conf
# 如果有问题可以录制 raw code
irrecord -f ~/lircd.conf

录制好的文件内容像下面这样,如果没有内容则说明没有录制上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
begin remote
name myir
flags RAW_CODES|CONST_LENGTH
eps 30
aeps 100
gap 108055
begin raw_codes
name KEY_1
9062 4462 621 531 627 532
626 531 626 532 629 531
601 556 627 531 628 530
628 1610 629 1611 603 1636
603 1636 629 1612 629 1609
631 1609 630 1610 627 1612
630 530 629 1608 629 532
626 534 625 532 628 1609
629 532 628 529 630 1609
629 530 626 1612 629 1610
629 1610 629 540 633 1596
629
name KEY_2
9067 4455 632 528 630 528
633 524 631 529 630 529
630 528 630 530 630 528

最后要把录制的文件内容复制到对应目录,重启,让 lirc 服务能加载上:

1
sudo cp ~/xx.lircd.conf /etc/lirc/lircd.d/xx.lircd.conf

实际上发送按键需要执行的命令包含你复制的文件名(device-name)以及按键名(KEY_1):

1
irsend SEND_ONCE <device-name> KEY_1

红外输入输出参考

https://www.pythonheidong.com/blog/article/191812/
https://www.jianshu.com/p/96f16846dfa3
https://segmentfault.com/a/1190000014135418
https://www.jianshu.com/p/9cfb0bf02006
https://www.cnblogs.com/huanglufei/articles/5562330.html
https://www.jianshu.com/p/abdcd3e06726

软件部分

简单的将软件部分分为前台功能和后台功能,前台功能主要是面向用户使用层面,后台功能主要是配置相关功能。

前台功能分为三个模块,输入模块、逻辑处理模块和输出模块。

其中热词唤醒方案使用的 snowboy ,语音文字互转采用的讯飞免费接口,后面可以考虑实现一些简单的部分。

按照这个方案,后续只要不断配置和扩展功能即可,主要处理流程不会有太大变化产生。

语音部分参考

https://www.jianshu.com/p/a1c06020f5fd
https://www.cnblogs.com/lovesKey/p/11080448.html
https://www.cnblogs.com/DragonFire/p/9212935.html
https://www.xfyun.cn/doc/asr/voicedictation/API.html
https://www.xfyun.cn/doc/tts/online_tts/API.html
https://www.xfyun.cn/doc/asr/voicedictation/Audio.html

软件部分目前不打算公开,主要写的太烂。等优化后再放出来。

总结

目前一期实现了核心部分的功能,可以语音控制普通红外家电,耗时有两周(晚上),目前的时间精力上也只能做到这样,毕竟工作和生活还要占据绝大部分时间的。使用上流程比较简单,插电开机自启动后就可以了,只是语音和音箱部分还没有很方便的集成到整个项目里面。下一期做的时候计划优化电路,支持更多的传感器,然后把麦和音箱集成进去,再做一个外壳。
整个项目从计划到实施还是学到了一些,主要是硬件方面上的了解,电路原理图、PCB画图打样、硬件电路 IO 接口标准等,软件部分并没有太多的实践,准备放到三期做软件层面的优化,把外部 API 调用改成自己训练的语音模型。

程序员升级打怪之路

这个标题看起有点鸡汤文,不过我还是建议对以下总结出的几点做些深入思考,这些会在今后的工作中越来越多的感受到它的作用。

寻找你行业内的专家

找到你所属行业内的专家,这些人往往做事高效并且很有才华。你要做的是跟随他们所关注的方向,学习他们做事的方法,思考如何应用到你的工作和生活上。找到他们,和他们去交流思考,提出自己的观点和想法。不要仅仅把眼光放到身边的人身上,这样会局限住你的视野。

每天都写新代码

工作重复枯燥?也许有时候我们只是懒得思考,用最顺手的方式把工作做完,容易形成惯性思维。为什么会有很多的复制粘贴?简单的修改来适配当前需求,这里我们更需要的是想想能不能把这段逻辑抽象出来变得更通用,整个模块的设计是否不够合理,多想一想多做一点,下一次再来需求也许可以提升十倍的效率。

底层的原理更重要

客观的说,更快进步的方法之一是忽略掉那些并不能提高技能的东西,比如语言语法和配置工具,这些技能属于“知其然”,而你更需要的是“知其所以然”。有一次去医院科室挂号使用的是先到先叫的模式,而在急诊室挂号是按照轻重缓急分成四个等级的,危重病人优先抢救的模式。这不就和操作系统中的任务调度概念是一样的,优先级调度模式,这些底层的概念才是一通百通真正提高帮助你的东西。我在尝试去找行业经典论文看。

学会调研

作为程序员会比较容易脑子一热,有一个想法很容易趁热着急写代码,但往往缺乏思考写出来的代码不能尽如人意。这时候你更需要的是慢下来,好好思考一下,也许这些别人已经做过,有更好的方案,看看别人是如何做的。先调研再实施,这样会彻底改变你解决问题的思路。

学好英语

真的是这样,如果你英语不好,那么会比别人走更多的弯路,就像走在密林深处看不清路一样。不得不承认很多优秀框架的官方文档还是英文为主,如果再经过翻译里面的很多语义语境会丢失,在项目的社区中,你还能与作者们去交流你学习中遇到的问题。

如何去做

说了这么多,看着就好像道理我都懂,但是我不知道怎么做。我这里先总结几个点,也是自己在不断尝试学习的方法。

  1. 看行业经典论文,比如 mapreduce、raft 这些都是一通百通的底层概念。
  2. 研究优秀框架的源代码,理解核心原理,尝试造轮子。
  3. 每天学英语,尝试在开源社区与作者们进行互动。
  4. 找到一两位行业专家,向他们学习和请教问题。
  5. 坚持以上几点。

end.

Java 动态代理实现 ORM

ORM(Object/Relational Mapper),即“对象-关系型数据映射组件”。对于O/R,即 Object(对象)和Relational(关系型数据),表示必须同时使用面向对象和关系型数据进行开发。本文简述通过 Java 动态代理机制实现关系数据与 POJO 对象的映射。

代理

静态代理

静态代理其实就是指设计模式中的代理模式。
代理模式为其他对象提供一种代理以控制对这个对象的访问。

静态代理模式在增强现有的接口业务功能方面有很大的优点,但是大量使用这种静态代理,会使我们系统内的类的规模增大,并且不易维护。

动态代理

为了解决静态代理的问题,引入动态代理的概念,在编译时或者运行时,可以在需要代理的地方动态生成代理,减轻代理类和类在系统中冗余的问题。

Java 动态代理基于经典代理模式,引入了一个 InvocationHandler,InvocationHandler 负责统一管理所有的方法调用。

InvocationHandler

InvocationHandler 接口定义:

1
2
3
4
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}

每一个动态代理类都必须要实现 InvocationHandler 这个接口,通过代理类的实例调用一个方法时,这个方法的调用就会被转发为由 InvocationHandler 这个接口的 invoke 方法来进行调用。

Proxy

Proxy 这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是 newProxyInstance 这个方法,可以获得一个动态的代理对象:

1
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,  InvocationHandler h)  throws IllegalArgumentException

实现

参照 mybaits 的用法实现基本的映射能力。

注解

首先定义了三个注解,一个作用在类上 DaoMapper 作用在类上标记这是一个映射类,然后定义注解 Selector 作用在方法上标记查询作用,定义注解 Param 作用在参数上为预编译位的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DaoMapper {
}

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Selector {
String value();
}

@Documented
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
String value();
}

定义一个实体类,与数据库的表字段映射上。增强 feature 可以自动做驼峰转换,这里没有实现。

1
2
3
4
5
6
7
8
9
@Data
public class BaseLineModel {
public static final String TABLE = "baseline";

private Integer id;
private String report_name;
private Integer report_period;
private LocalDateTime creation_date;
}

定义dao层接口,加上注解

1
2
3
4
5
6
@DaoMapper
public interface BaseLineDao {

@Selector("select * from "+ BaseLineModel.TABLE +" where report_name = #{reportName}")
BaseLineModel select(@Param("reportName") String report_name);
}

JDBC OP

做到一个很简单的 JDBC 操作工具类,字段映射处理也写到了这里。实现了查询操作,将入参 sql template 以及参数按顺序传入,生成 prepareStatement 后执行,再将返回结果映射到 model 对象。这里的连接池管理、自动重连、配置管理等增强 features 非重点,不做实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 查询
* @param clazz model类
* @param sql
* @param params
* @param <T>
* @return
*/
public <T> T query(Class<T> clazz, String sql, Object... params) throws SQLException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
Object model = clazz.newInstance();
try (Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/cat", "root", "123456")) {
PreparedStatement statement = conn.prepareStatement(sql);
int flag = 1;
for (Object obj : params) {
setValue(statement, flag, obj);
flag++;
}
ResultSet resultSet = statement.executeQuery();
resultSet.afterLast();
resultSet.previous();
fullRes(resultSet, model);
}
return (T) model;
}

映射函数,通过自动寻找 setter 方法填充结果,这里只实现了三种字段。

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
private static void fullRes(ResultSet resultSet, Object model) throws SQLException, InvocationTargetException, IllegalAccessException, NoSuchMethodException {
Field[] declaredFields = model.getClass().getDeclaredFields();
for (Field field : declaredFields) {
String fieldName = field.getName();
if (fieldName.toUpperCase().equals(fieldName)) {
continue;
}
String setFuncName = "set" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
String fieldType = field.getGenericType().toString();

Object object = resultSet.getObject(fieldName);
if (fieldType.equals("class java.lang.String")) {
Method m = model.getClass().getMethod(setFuncName, String.class);
m.invoke(model, object);
} else if (fieldType.equals("class java.lang.Integer")) {
Method m = model.getClass().getMethod(setFuncName, Integer.class);
m.invoke(model, object);
} else if (fieldType.equals("class java.time.LocalDateTime")) {
Method m = model.getClass().getMethod(setFuncName, LocalDateTime.class);
if (object instanceof Timestamp) {
object = ((Timestamp) object).toLocalDateTime();
}
m.invoke(model, object);
}
}
}

动态代理部分

定义一个 MapperMethod 类,实例化的时候提取接口方法的注解信息解析成 JDBC 需要的参数以及记录接口方法的返回对象, execute 执行。

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

public class MapperMethod<T> {
private String sql;
private Class<?> resType;
private int[] paramsIndex;


public MapperMethod(Method method) {
this.resType = method.getReturnType();
String sourceSql = method.getAnnotation(Selector.class).value();
Parameter[] parameters = method.getParameters();
int flag = 0;
this.paramsIndex = new int[parameters.length];
for (Parameter parameter: parameters) {
String paramName = parameter.getAnnotation(Param.class).value();
String paramFullName = String.format("#{%s}", paramName);
int indexOf = sourceSql.indexOf(paramFullName);
this.paramsIndex[flag] = indexOf;
flag++;
this.sql = sourceSql.replace(paramFullName, "?");
}
}

public Object execute(Object[] objects) {
JdbcUtil jdbcUtil = new JdbcUtil();
try {
return jdbcUtil.query(this.resType, this.sql, objects);
} catch (SQLException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
}
return null;
}
}

定义动态代理类,在实例化的时候记录代理接口,以及代理方法类缓存,调用接口的时候会被动态代理到 invoke 函数执行,然后交由 MapperMethod 代理方法实例执行。

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
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;

public class MapperProxy<T> implements InvocationHandler {

private final Class<T> mapperInterface;

private final Map<Method, MapperMethod> methodCache;

public MapperProxy(Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {
this.mapperInterface = mapperInterface;
this.methodCache = methodCache;
}

@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(objects);
}

private MapperMethod cachedMapperMethod(Method method) {
MapperMethod mapperMethod = methodCache.get(method);
if (Objects.isNull(mapperMethod)) {
mapperMethod = new MapperMethod(method);
methodCache.put(method, mapperMethod);
}
return mapperMethod;
}
}

最后代理工厂类,接收被 DaoMapper 作用的接口,并通过 newInstance 方法创建代理类实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MapperProxyFactory<T> {

private final Class<T> mapperInterface;

private Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<>();

public MapperProxyFactory(Class<T> mapperInterface) {
if (Objects.isNull(mapperInterface.getAnnotation(DaoMapper.class))) {
throw new RuntimeException("缺少注解 DaoMapper");
}
this.mapperInterface = mapperInterface;
}


public T newInstance() {
final MapperProxy<T> mapperProxy = new MapperProxy<>(mapperInterface, methodCache);
return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy);
}
}

执行,创建一个代理工厂,然后创建 BaseLineDao 的代理对象, 调用 select 方法,实际上调用到代理对象的 invoke 方法,然后交由 mapperMethod.execute 方法执行:

1
2
3
4
5
6
public static void main(String[] args) {
MapperProxyFactory mapperProxyFactory = new MapperProxyFactory(BaseLineDao.class);
BaseLineDao baseLineDao = (BaseLineDao) mapperProxyFactory.newInstance();
BaseLineModel test1 = baseLineDao.select("TEST1");
System.out.println(test1);
}

扩展

TODO:

  1. Java动态代理与 cglib 动态代理的异同点。
  2. 动态代理的实现原理。

总结

通过这个个简单的实践,了解了 Java 动态代理的使用方法以及对象关系数据的映射处理。

参考

https://zhuanlan.zhihu.com/p/60805342
https://www.zhihu.com/question/20794107/answer/658139129