Skip to main content

AI编程与思维牢笼

· 5 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

加速

info

本文仅仅探讨AI编程这个领域,现在的AI往往代指含有图像视频生成的多模态AGI。

2021年,我第一次接触AI编程:GitHub Copilot,当时AI的能力主要体现为:输入注释或部分代码,即可生成整段优质的代码。

2025年,氛围编程让更多的人可以在不了解细节的情况下完成项目的搭建。

4年过去了,给我感觉是:AI加速了整个社会的差距

  • 在学校,优秀学生与垫底学生的效率相较从前更大了。

之前差生至少需要通过搜索引擎查找资料,再整理加工才能完成作业。而现在只要问题描述足够具体那差生只需要两次复制粘贴就能完成作业,差生提交完的第二天,都复述不出作业的要求。

  • 在公司,2个初级程序员的工资约20k,反而不如聘请一个15k的中级程序员配合成本不到1k的AI助手

AI能力线以下的工作岗位将更加稀缺。这意味着毕业生需要在某个领域达到超越AI的水平。这种要求客观上进一步加速了学习周期的延长,把普本计科学生推向继续教育。

思维

那为什么AI能完成的事情还要去学习呢?

我最近很喜欢出一些最潮流的技术项目,其一是较为有趣,其二是更能锻炼学生思维。

学习的过程大脑神经元应该是活跃的,如果很顺利的吸收了某个知识,那么大概率很快就会忘记。

如果通过发问、追问、试错、反思、总结,曲折的吸收了某个知识,神经元被激活的更加充分,理解也会更强。

如果你直接AI生成了答案,记忆效果会大大降低,只用AI生成你不想记忆的内容

因此,给自己试错的时间,给大家试错的时间。

我们人类的学习是有结构的,要想会 AI 不能完成的事,要先学那部分 AI 也能完成的事

AI不是领导

AI会有一些愚蠢的低级错误,例如在cmd命令行中,这个命令报错:

PS C:\Users\allen> bcdedit /delete /f {f7f65faa-5515-11ef-b3e2-d8359383915d}

指定的删除命令无效。

运行"bcdedit /?"获取命令行帮助。

参数不正确。

即使是当前最先进的AI模型也无法正确解答,例如claude-4.5-sonnet-thinkingdeepseek-r1gorkgpt5-high

但如果你系统学习过终端命令,很快就能意识到问题所在:{}在PowerShell中是特殊字符(表示脚本块),需要加上双引号:

bcdedit /delete /f "{f7f65faa-5515-11ef-b3e2-d8359383915d}"

和公司新来的实习的大学生一样,有一腔热情,部分也愿意学习,但是往往没有系统的学习缺少DEBUG的直觉。

人应该系统的学习相关的知识,不要通过AI编程补充所有的知识性的细节,不要过度依赖其DEBUG能力,当一次无法成功时,后续追问的成功率会大打折扣,立刻人工接管。

推荐调试流程:

  1. 理解问题:翻译错误信息+自主思考
  2. 验证思路:与AI交流你的分析

他人的想法

在部分领导和自媒体眼中,AI近乎无所不能,认为有了AI加持就像拥有神笔的马良。这种认知会导致他们分配超出实际负荷的工作量。

为了赶工期,你不得不变本加厉地使用AI,陷入无暇系统思考的恶性循环

和一个赌徒不停的拉动老虎机的拉杆一样,你输入提示词然后等待,期待下次AI给出答案是终极大奖。

我们都知道赌徒的下场。

GithubPage智能DNS解析

· 6 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

好久不见,今天分享一下我的网站是如何做智能DNS解析的案例。

现在看到这个网站被部署在一个 GitHub 仓库,我域名解析指向了 GitHub,因此可以通过域名直接访问这个网站。

但是目前在中国大陆访问比较慢,于是我采购了一台中国大陆的服务器。目标:

  • 在大陆的 IP 地址访问,解析到大陆服务器。
  • 在境外访问,解析到 GitHub。

项目原本的增量更新逻辑不变:即通过 GitHub Actions,每次变更自动更新不一致的文件。

实现这个需求的关键在于使用智能DNS解析,也就是根据访问者的地理位置将域名解析到不同的 IP 地址。

域名解析修改

在阿里云服务选择云解析 DNS。相关配置如下:

主机记录记录类型解析线路记录值
wwwCNAME境外你的Github用户ID.github.io
@CNAME境外你的Github用户ID.github.io
wwwA中国地区我的服务器IP
@A中国地区我的服务器IP

服务器配置

在服务器上,需要部署一个Web服务器(如 Nginx 或 Apache),并配置它来服务项目静态文件。大部分服务器都是开箱即有。我使用的是宝塔面板,添加PHP项目,简单的配置了一下域名和SSL证书。找到项目指向的文件夹/www/wwwroot/jiangmiemie.com,后续将 GitHub 生成的静态资源传输到这里即可。

如何将项目文件同步到这台服务器上。这里计划使用rsync增量同步,使用ssh链接。

rsync

rsyncscp 更适合同步大量文件,因为它可以只传输有变化的部分。

我的这台服务器是阿里云系统(不是 Ubuntu),需要使用命令:yum install -y rsync进行安装。

ssh

需要在防火墙中打开的端口:2280443

info

虽然我们可以直接通过账号密码登录 ssh,但是账号密码权限太高,因此设置一个仅用于部署的 ssh 密钥。

# 生成密钥对(在服务器上)
ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_deploy_key -N ""

# 查看生成的文件
ls -la ~/.ssh/
# 会看到:
# github_deploy_key (私钥)
# github_deploy_key.pub (公钥)

# 将公钥添加到authorized_keys
cat ~/.ssh/github_deploy_key.pub >> ~/.ssh/authorized_keys

# 设置正确权限(过低没有权限,过高ssh会认为文件不安全)
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/github_deploy_key

# 测试密钥是否工作(在服务器上)应该能直接连接,不需要密码
ssh -i ~/.ssh/github_deploy_key root@localhost

# 显示私钥内容(复制到GitHub Secrets)
cat ~/.ssh/github_deploy_key

GitHub Workflow 修改

完整配置如下,主要变更内容为Setup SSHDeploy to server

因为GitHub Actions是所有人都能看到的,因此为了安全,你需要把服务器 IP 地址、连接信息等配置为密钥变量。下面代码中${{ secrets.SSH_PRIVATE_KEY }}这样格式的都是密钥变量。

把你的 IP 地址和刚刚终端显示的私钥复制到 GitHub Secrets。

info

在你的 GitHub 仓库页面,进入 Settings -> Secrets and variables -> Actions

点击 New repository secret

Name 填写一个有意义的名称,比如我设置的是SSH_PRIVATE_KEYIP

  • IP的 Secret 框中,仅粘贴你的 IP 地址。例如:192.168.10.11

  • SSH_PRIVATE_KEY的 Secret 框中,粘贴从-----BEGIN OPENSSH PRIVATE KEY----------END OPENSSH PRIVATE KEY-----完整内容。

name: Build HTML
on: push
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: |
npm install
npm run build
- uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- name: Deploy to server
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.IP }} >> ~/.ssh/known_hosts
rsync -avz --exclude='.user.ini' ./build/ root@${{ secrets.IP }}:/www/wwwroot/jiangmiemie.com
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_IP: ${{ secrets.IP }}
  • ssh-keyscan -H ${{ secrets.IP }} >> ~/.ssh/known_hosts

表示将服务器的公钥添加到 known_hosts 文件中。

  • rsync -avz --exclude='.user.ini' ./build/ root@${{ secrets.IP }}:/www/wwwroot/jiangmiemie.com

表示将./build/文件夹同步到/www/wwwroot/jiangmiemie.com文件夹。

参数作用详细说明
rsync远程同步工具比 scp 更智能,只传输变化的部分
-a归档模式保留文件权限、时间戳、符号链接等所有属性
-v详细输出显示传输过程的详细信息
-z压缩传输传输时压缩数据,减少网络带宽
--exclude='.user.ini'排除文件跳过服务器自动生成的系统文件
./build/源目录本地构建好的静态文件目录
root@IP:/path/目标地址服务器上的部署目录

每次你推送代码到 GitHub,工作流会自动构建并将变化同步到服务器,一次修改,处处生效!

ROS2视觉运动算法仿真

· 42 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

本章节通过ROS2官方提供的Gazebo虚拟仿真,实现视觉运动算法仿真。

info

什么是视觉运动控制算法?

运动控制算法主要是通过传感器,将采集到的数据,转化为运动控制指令。

传感器包含:视觉、重力、力反馈、加速度、测距等多种类型。特斯拉的自动驾驶使用的方案中视觉是最主要的信息来源。

运动控制从易到难有:履带结构、差速结构、阿克曼结构、全向结构、足腿结构。

info

为什么要虚拟仿真?

目前较为先进的腿足结构机器人想要完成高难度动作,例如:后空翻、前空翻、韦伯斯特空翻。一旦失败会对本不充裕的原型机造成严重的损坏。

所以通过仿真软件训练,有如下好处:

  • 可以在低风险的情况下完成算法的调试和优化。
  • 对于复杂算法,可以同时启动多个仿真软件,进行并行训练,提高训练效率。
warning

虚拟仿真需要一定的算力。本地使用前,首先确保你的电脑包含显卡。

  • ✔推荐使用原生Ubuntu最大限度发挥硬件性能。非原生环境下可能出现消息异常、时间异常等问题。

  • 高算力的电脑可以使用wsl安装Ubuntu。

  • ❌使用VM虚拟机会产生较大的延迟。

info

用机器学习中的概念来类比,Gazebo好比是Pytorch,ROS2好比是Python。

你希望使用Pytorch开始深度学习,所以要查看最新版Pytorch适配的Python版本(3.10+),需要英伟达显卡才能加速,再查看Python支持的操作系统(Mac、Linux、Windows)。

搞清楚之后,再从系统开始,安装Python,最后Pytorch和显卡驱动安装成功。谋定而后动。

ROS2框架

ROS 2需要依赖于Ubuntu系统,其微控制器相较于普通MCU成本更高。主要用于高级机器人。

通信优势

丰富的通信类型

ROS 2节省自己搭建网络、定义通信类型、管理连接的时间。

通信模式作用特点应用场景
话题:发布-订阅 (Publish-Subscribe)单向、异步数据流高效、解耦,适用于传感器数据流、状态更新传感器数据流(如相机图像、激光雷达数据)、机器人状态更新(如位置、速度)
服务:服务端-客户端 (Service-Client)同步、请求-响应阻塞式通信,用于需要明确结果的任务触发特定动作(如抓取物体)、查询物体 ID
动作 (Actions)长时任务处理异步、支持进度反馈和取消移动机器人到指定位置、机械臂执行复杂任务
参数 (Parameters)动态配置节点行为非实时通信,用于启动时或运行时的配置调整调整 PID 控制器增益、修改传感器发布频率

自动发现机制

同为ROS 2,在同一网段下的不同设备可以自动发现

info

自动发现前提:指要求中继设备允许子设备自由通信。

中继设备可能是路由器、或者手机的热点、随身WIFI等。

允许子设备自由通信有利有弊,如果你在商场或者咖啡馆连接他们的WIFI,你的电脑可能提醒你:你的网络活动可能被其他设备发现。自由通信会带来一定的安全隐患。因此并不是所有的路由器都默认允许允许子设备自由通信。

有的设备会有防火墙,临时关闭防火墙:sudo ufw disable,另外要允许UDP多播端口(默认7400~7500)。

如果一切正常,你应该可以通过ping <其他机器的IP>并联通成功。

此时你可以通过ros2运行节点发布消息,其他设备的接收者会收到你的消息。

隔离机制

如果你同个网段下,有无人机和机器狗。你想将其编队为:空军 + 陆军;又或者5台无人机 + 机器狗为一组。

ROS 2提供了通信隔离机制

在同个编队的所有设备终端中执行:export ROS_DOMAIN_ID=1。(值范围为0~101,0为默认值)

这个命令是设置了一个环境变量。ROS 2通过判断这个环境变量来识别是否为一组。不同ID节点无法通信。

因此如果你想将其分为两组,只需要在一半的设备上改为非0的,相同的ID即可。

多设备跨域通信

如果你的设备分别在两个城市,可以通过配置一台公网下的服务器(也可以在两个网段下,配置一个都能访问的设备作为中继),将两个城市的设备连接到这台服务器上。这种通信方式被称为Fast-DDS

在服务器上运行:fastdds discovery --server-id 0

服务器开放11888端口。sudo ufw allow 11888/tcp

在设备上运行:export ROS_DISCOVERY_SERVERS=<服务器IP>:<服务器端口>。(端口默认11888)

设置ROS中间件为rmw_fastrtps_cpp(别人写好的包):export RMW_IMPLEMENTATION=rmw_fastrtps_cpp 然后重新监听:ros2 daemon stop,再重新启动:ros2 daemon start

模块仓库

ROS 2有大量写好的机器人相关的包,包也称:模块、功能包、库。

安装相机标定: sudo apt install ros-jazzy-camera-calibration

安装SLAM: sudo apt install ros-jazzy-slam-toolbox

你会发现你只需要安装一个包,就可以获得一个功能。和Python通过pip安装包一样。安装后即可实现"一行代码"实现某个功能。

ROS 2安装

安装过程中注意:ROS 2的版本往往与ubuntu的版本有关。查看https://docs.ros.org/可获得目前主流的版本。

设置编码

sudo apt update && sudo apt install locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
export LANG=en_US.UTF-8

添加源

sudo apt update && sudo apt install curl gnupg lsb-release 
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(source /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

安装ROS 2

sudo apt update

sudo apt upgrade

# 如果你是ubuntu 24.04,那么安装jazzy版本
sudo apt install ros-jazzy-desktop

设置环境变量

# 立刻执行脚本
source /opt/ros/jazzy/setup.bash

# 之后每次开启终端执行执行脚本
echo " source /opt/ros/jazzy/setup.bash" >> ~/.bashrc

你也可以使用Docker简化这个过程。

复杂的功能需要很多依赖,系统中默认的Python3出于安全考虑,可能不让你在全局环境中安装包。代码的提示给了很多选项,例如安装在虚拟环境中,删除安全文件。加上关键字,你应该学会通过翻译软件,翻译报错信息。它让你看某个文件,也请学会使用翻译软件,翻译文件内容。

error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.

If you wish to install a non-Debian-packaged Python package,
create a virtual environment using python3 -m venv path/to/venv.
Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
sure you have python3-full installed.

If you wish to install a non-Debian packaged Python application,
it may be easiest to use pipx install xyz, which will manage a
virtual environment for you. Make sure you have pipx installed.

See /usr/share/doc/python3.12/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.

根据提示,得到的解决方式之一:pip3 install 你的包 --break-system-packages

或者可以把externally managed文件删除。

sudo apt install -y python3-pip

sudo pip3 install rosdepc --break-system-packages

sudo rosdepc init

rosdepc update

节点基操

节点是通信的基本单位,节点可以变成:发布者、订阅者、服务端、客户端、动作、参数。

创建节点

创建节点前可以先创建一个目录

mkdir -p ~/workspace/src

如何在ROS2中创建一个功能包呢?我们可以使用这个指令:

ros2 pkg create --build-type <build-type> <package_name>

ros2命令中:

  • pkg:表示功能包相关的功能;
  • create:表示创建功能包;
  • build-type:表示新创建的功能包是C++还是Python的,如果使用C++或者C,那这里就跟ament_cmake,如果使用Python,就跟ament_python;
  • package_name:新建功能包的名字。
# 创建Python功能包
ros2 pkg create --build-type ament_python learning_node

# 创建C++功能包
ros2 pkg create --build-type ament_cmake learning_node

只需执行其中一个命令即可,如果你会Python,就执行创建Python节点的命令。命令创建一个名为learning_node文件夹。

这里我推荐大家创建节点名的时候,使用有辨识性的名字。例如:ros2 pkg create --build-type ament_python learning_node

info

注意:对于节点同名,ROS 2在编译时的处理方式不是使用第一个节点或最后一个节点,而是会报错。

编写代码

接着在learning_node文件夹我们就可以准备编写代码了,此时你的路径应该是:

~/workspace
|_src
|_learning_node/
|_learning_node
|_ __init__.py
|_package.xml
|_setup.py
|_setup.cfg
|_test/
|_resource/

此时你就可以编写代码了,在__init__.py同级下创建一个.py文件。

符合文件命名规则前提下,你想叫什么都可以,但是最好有一定的含义。

如果你的功能是打印Hello World,那么你就可以叫node_helloworld.py

python的函数通常是需要调用的,例如abc()函数,你需要在代码中输入abc(),才会调用这个函数。和C++有且仅有一个main函数不同,那么如何确定哪个函数是入口?更进一步思考:不同的指令对应不同的函数。

因此有了setup.py最下方的入口点定义。编写完成后记得修改setup.py最下方的入口点。

详细内容参可以参考现成的仓库:

info

拉取git仓库,可免去自己从0开始手敲代码。如果你喜欢自己从0开始抄写代码,也可以跳过本部分。

git安装:sudo apt install git

拉取命令:git clone https://gitee.com/guyuehome/ros2_21_tutorials.git

这个命令会把仓库克隆到本地。执行后,你应该能在当前文件夹下看到名为ros2_21_tutorials的文件夹,内含多个子文件夹。每个子文件夹对应一个功能包。因为ros2是模块化的,不同的功能被拆解为不同的功能包后期更好维护。

注意:如果你之前在src里创建了一些Python节点之后,又把ros2_21_tutorials文件夹也放置在src里,那么你需要检查你的创建的节点名,是否与ros2_21_tutorials内已有节点重名。

编译

# 安装colcon(此步全局仅在第一次执行时需要执行,如果失败会导致colcon build 命令不可用)
sudo apt install python3-colcon-ros

# 进入工作空间
cd ~/workspace

# 编译工作空间所有功能包
# 不论其是直接在src下,还是在src下的子文件夹下
colcon build

# 当你只有一个包更改,不希望全编译,仅编译learning_node
colcon build --packages-select learning_node

colcon build命令会将所有功能包,编译成可执行文件。

编译成功后,就可以在工作空间中看到自动生产的build、log、install文件夹了。

  • src,代码空间,未来编写的代码、脚本,都需要人为的放置到这里;
  • build,编译空间,保存编译过程中产生的中间文件;
  • install,安装空间,放置编译得到的可执行文件和脚本;
  • log,日志空间,编译和运行过程中,保存各种警告、错误、信息等日志。

我们把编译后,含有build、log、install、src文件夹的workspace称为工作空间

如果是Python这样无需编译的,会将代码直接拷贝到install文件夹下。

编译过程可能出现的报错:

  • 节点重名:对与节点同名,ros的处理方式不是使用第一个节点或最后一个节点,而是会报错。应该删除多余节点。确保你src文件夹下,所有的xml文件中,节点的名称是唯一的。

  • 语法错误:如果代码语法错误,则会编译失败,需要手动解决。

  • 缺少依赖包:如果你严格跟随参考资料且为新系统,首先检查你是否安装过程网络异常。如果是曾用系统,检查是否含有多个python环境。

# 删除缓存
rm -rf build/ install/ log/

# 修改环境变量PATH的值,把系统默认的环境提到最前
export PATH="/usr/bin:$PATH"

# PYTHON_EXECUTABLE 是一个CMake变量,用于指定构建过程中应该使用哪个Python解释器
export PYTHON_EXECUTABLE=/usr/bin/python3 && colcon build --cmake-args -DPYTHON_EXECUTABLE=/usr/bin/python3

在不更换工作空间的情况下,以下命令仅在第一次执行时需要执行。如果你的工作空间路径不是~/workspace,请将~/workspace替换为你的工作空间路径。

# 立刻执行脚本
source ~/workspace/install/setup.sh

# 之后每次开启终端执行执行脚本
echo "source ~/workspace/install/setup.sh" >> ~/.bashrc

运行

运行脚本 : ros2 run learning_node node_helloworld

查看正在运行的node : ros2 node list

rclpy

rclpy模块,ROS 2 Python标准接口 (ROS 2 canonical API with Python)

官网:https://docs.ros.org/en/rolling/p/rclpy/index.html

受限于篇幅,只简单介绍其中初始化部分

入门体验

下面是一个标准的初始化代码init支持传入多种参数。

import rclpy                                     # ROS2 Python接口库
from rclpy.node import Node # ROS2 节点类
import time

def main(args=None): # ROS2节点主入口main函数
rclpy.init(args=args) # ROS2 Python接口初始化
node = Node("node_helloworld") # 创建ROS2节点对象并进行初始化

while rclpy.ok(): # ROS2系统是否正常运行
node.get_logger().info("Hello World") # ROS2日志输出
time.sleep(0.5) # 休眠控制循环时间
node.destroy_node() # 销毁节点对象
rclpy.shutdown() # 关闭ROS2 Python接口
初始化

rclpy.init(*, args: List[str] | None = None, context: Context | None = None, domain_id: int | None = None, signal_handler_options: rpyutils.import_c_library.SignalHandlerOptions | None = None)→ InitContextManager

直接初始化比上下文初始化拥有更多的参数。

Parameters:

  • args – 命令行参数列表。
  • context – 要初始化的上下文。如果为 None,则使用默认上下文。
  • domain_id – ROS 域 ID。
  • signal_handler_options – 指示要安装的信号处理程序。如果 None,则在初始化默认上下文时将安装 SIGINT 和 SIGTERM。【类似于 Python 的信号(signal)机制,是操作系统发出的异步事件。而非异常,异常是程序内部发生的同步事件。】

Returns:

  • 一个 InitContextManager,可以与 Python 上下文管理器一起使用进行清理。
循环执行

ROS 2 中的 Executor(执行器)是用来管理和运行 ROS 2 节点中所有回调函数的核心组件。你可以把它看作是一个调度器,它决定了节点里的各种任务(例如接收新消息、处理服务请求、定时器触发)何时执行。

Executor 负责调度和执行节点中的回调函数。一个 ROS 2 节点可以有多个回调函数,比如:

  • 订阅者的回调函数:每当收到一个新消息时被调用。

  • 服务的请求回调函数:每当收到一个服务请求时被调用。

  • 定时器的回调函数:每隔一定时间被调用。

节点本身并不会自动运行这些回调函数,它需要一个 Executor 来 "spin"(自旋),也就是不断地检查是否有就绪的任务并执行它们。ROS 2 将节点和执行器分开,提供了灵活性。你可以选择不同的 Executor 来适应你的应用需求:

  • SingleThreadedExecutor:这是最简单的执行器,它在一个单独的线程里顺序执行所有就绪的回调函数。

  • MultiThreadedExecutor:这个执行器使用一个线程池,可以同时执行多个回调函数,这对于需要高并发处理的节点非常有用。

rclpy.spin(node: Node, executor: Executor | None = None)→ None:执行工作,直到与执行器关联的上下文关闭。

  • node – 要添加到执行器以检查工作的节点。
  • 要使用的执行器,如果是 None,则为全局执行器。

rclpy.spin_once(node: Node, *, executor: Executor | None = None, timeout_sec: float | None = None)→ None:执行一项工作或等待超时到期。

只要该回调在超时到期之前准备就绪,提供的执行器就会执行一个回调。如果未提供执行器(即 None),则使用全局执行器。如果全局执行器具有部分完成的协程,则完成的工作可能是针对提供的节点以外的节点。

  • node – 要添加到执行器以检查工作的节点。
  • executor – 要使用的执行器,如果是 None,则为全局执行器。
  • timeout_sec – 等待几秒钟。如果为 “无” 或“负数,则永久阻止”。如果为 0,请不要等待。

rclpy.spin_until_future_complete(node: Node, future: Future[Any], executor: Executor | None = None, timeout_sec: float | None = None)→ None : 执行工作,直到Future完成。

  • node – 要添加到执行器以检查工作的节点。
  • 要等待的 future 对象。直到 future.done() 返回 True 或与执行器关联的上下文为 shutdown。
  • 要使用的执行器,如果是 None,则为全局执行器。
  • 等待几秒钟。如果为 None 或负数,则阻止直到未来完成。如果为 0,请不要等待。
关闭

rclpy.shutdown(*, context: Context | None = None, uninstall_handlers: bool | None = None)→ None

关闭以前初始化的上下文(如果尚未关闭)。这也将关闭全局执行器,如果已关闭会报错。

  • context – 要失效的上下文。如果为 None,则使用默认上下文
  • uninstall_handlers – 如果为 None,则在关闭默认上下文时将卸载信号处理程序。如果为 True,则将卸载信号处理程序。如果为 False,则不会卸载信号处理程序。和前面signal_handler_options对应上。

rclpy.try_shutdown(*, context: Context | None = None, uninstall_handlers: bool | None = None)→ None

关闭以前初始化的上下文(如果尚未关闭)。这也将关闭全局执行器,如果已关闭不会报错。

  • context – 要失效的上下文。如果为 None,则使用默认上下文
  • uninstall_handlers – 如果为 None,则在关闭默认上下文时将卸载信号处理程序。如果为 True,则将卸载信号处理程序。如果为 False,则不会卸载信号处理程序。和前面signal_handler_options对应上。

上下文

上下文管理器Context与直接使用rclpy基本一致,但背后其实是线程管理,每个独立的上下文管理器都是独立的线程。

如果使用rclpy.init(args=args)则无法完成创建2个不同的domian_id的节点。

from rclpy.node import Node                      # ROS2 节点类
from rclpy.context import Context # ROS2 上下文类
import time

def main(args=None): # ROS2节点主入口main函数
# 使用Context上下文管理器的正确方式
context = Context()
context.init(args,domain_id=1)
node = Node("node_helloworld", context=context) # 创建节点并关联上下文

context2 = Context()
context2.init(args,domain_id=2)
node2 = Node("node_helloworld", context=context2) # 创建节点并关联上下文

while context.ok() and context2.ok(): # 检查上下文是否正常
node.get_logger().info("{}".format(context.get_domain_id())) # ROS2日志输出
node2.get_logger().info("{}".format(context2.get_domain_id())) # ROS2日志输出
time.sleep(0.5) # 休眠控制循环时间
context2.shutdown()
context.shutdown()

使用上下文管理器Context 的with语法可以自动init(不支持参数),并在with语句结束时调用shutdown,代码更加简洁。

类似于Python的 with openclose

from rclpy.node import Node                      # ROS2 节点类
from rclpy.context import Context # ROS2 上下文类
import time

def main(args=None): # ROS2节点主入口main函数
# 使用Context上下文管理器的正确方式
with Context() as context:
node = Node("node_helloworld", context=context) # 创建节点并关联上下文
while context.ok(): # 检查上下文是否正常
# 输出当前的domain id ,默认是0
# 修改domain id方式1 :修改当前终端变量
# export ROS_DOMAIN_ID=1
node.get_logger().info("{}".format(context.get_domain_id()))
time.sleep(0.5) # 休眠控制循环时间
初始化

context.init(args: List[str] | None = None, *, initialize_logging: bool = True, domain_id: int | None = None)→ None

为给定上下文初始化 ROS 通信。

  • args – 命令行参数列表。
  • initialize_logging – 是否初始化整个过程的日志记录。默认值是初始化日志记录。
  • domain_id – 要用于此上下文的域 ID。如果为无(默认值),则使用域 ID 0。
状态检察

context.get_domain_id()→ int:获取上下文的domain ID。

context.ok()→ bool:检查上下文是否未关闭。

跟踪

track_node(node: Node)→ None:跟踪与此上下文关联的节点。当上下文被销毁时,它将销毁它跟踪的每个节点。

untrack_node(node: Node)→ None:如果一个节点在上下文之前被销毁,我们不再需要跟踪它是否销毁了上下文,所以在此处将其删除。

关闭上下文

context.shutdown()→ None:关闭此上下文,会等待正在进行的操作完成,然后清理资源。好比执行系统关机,如果已关闭会报错。

on_shutdown(callback: Callable[[], None])→ None:添加关机时调用的回调。

try_shutdown()→ None:尝试关闭此上下文(如果尚未关闭),如果已关闭也不会报错。

context.destroy()→ None:销毁上下文,立即释放资源,不等待正在进行的操作。好比直接断电。

通信示例

ROS2的基础通信类型的示例代码都在ros2_21_tutorials仓库中。拉取到src文件夹中,参考上文编译、运行即可。

话题

  • 运行话题的发布者节点 : ros2 run learning_topic topic_helloworld_pub
  • 运行话题的订阅者节点 : ros2 run learning_topic topic_helloworld_sub

可以看到发布者循环发布“Hello World”字符串消息,订阅者也以几乎同样的频率收到该话题的消息数据。

服务

  • 运行服务的服务端节点 : ros2 run learning_service service_adder_server
  • 运行服务的客户端节点 : ros2 run learning_service service_adder_client 2 3

动作

  • 运行动作的服务端节点 : ros2 run learning_action action_move_server
  • 运行动作的客户端节点 : ros2 run learning_action action_move_client

参数

等价于机器学习中的超参数即:在开始训练前设置的参数,例如:学习率、隐藏层大小、迭代次数等等。

ROS机器人系统中的参数是全局字典,可以运行多个节点中共享数据。
  • 运行节点 :ros2 run learning_parameter param_declare
  • 在另外终端设置参数 :ros2 param set param_declare robot_name turtle

Gazebo

Gazebo是一款开源的3D仿真软件,用于模拟机器人在复杂环境中的行为。它提供了丰富的物理引擎和传感器模型,可以模拟机器人的运动、碰撞、传感器数据等。

# 安装gz
sudo apt-get install ros-jazzy-ros-gz

# 测试(参数 empty.sdf 表示空场景)
# 测试能否正常启动且无卡顿,gz_args:=empty.sdf表示使用空场景
ros2 launch ros_gz_sim gz_sim.launch.py gz_args:=empty.sdf

# 我的Gazebo版本
"""
Gazebo Sim, version 8.9.0
Copyright (C) 2018 Open Source Robotics Foundation.
Released under the Apache 2.0 License.
"""

gazebosim有大量下载好的模型(models)与场景(worlds)可以使用。

想简单点就选一个自带摄像头的机器人模型(model),如果有中意的机器人模型没摄像头的,可以自己加一个摄像头。

选赛道类的,有明显的车道线,可以较好的测试算法。如果场景过于简单可以适当的添加一些模型,例如:路灯、树木、箱子等。

从结构上来说,场景(world)包含 N 个模型(model),模型包含 M 个传感器(sensor)。

虚拟仿真的世界观和Blender的世界观类似,一个世界需要有物理引擎、光源、模型等等。首先我们需要简单了解下这样的世界观。

sdf格式中世界可接收的子元素:http://sdformat.org/spec?ver=1.4&elem=world

sdf格式中模型可接收的子元素:http://sdformat.org/spec?ver=1.4&elem=model

gazebo的传感器库:https://gazebosim.org/libs/sensors/

sdf格式中传感器可接收的子元素http://sdformat.org/spec?ver=1.4&elem=sensor

这种结构化的文档可读性要比传统的纯文档观感好很多。

URDF

URDF(Unified Robot Description Format)是一种用于描述机器人模型的XML格式。

一个两轮机器人运动示例官方教程

通过这个部分的学习,你可以学会如何操纵你的小车运动。

其中pose标签有6个参数,官方文档认为比较简单没讲,这里提一下:

  • 前三个参数表示三维空间坐标(取值范围为空间内即可)
  • 后三个参数表示绕某个轴的角度(控制物体面朝什么方向)
方向正值含义颜色标识
X 轴前后方向向前(North)🔴 红色
Y 轴左右方向向左(West)🟢 绿色
Z 轴上下方向向上🔵 蓝色
角度旋转方向常用值
Roll绕 X 轴旋转翻滚通常为 0
Pitch绕 Y 轴旋转俯仰通常为 0
Yaw绕 Z 轴旋转左右转头0, 1.57, 3.14, -1.57

Yaw 角度对照表

Yaw (弧度)角度朝向示意
0朝向 +X(前)
1.5790°朝向 +Y(左)
3.14180°朝向 -X(后)
-1.57-90°朝向 -Y(右)

传感器

基于不同的目的,我们会选择不同的传感器结合不同的算法来实现。

传感器应用场景优势劣势
激光雷达SLAM建图、避障精度高,不受光照影响价格贵,无颜色信息
摄像头目标识别、视觉SLAM成本低,信息丰富受光照影响,计算量大
深度相机室内SLAM、3D重建提供RGB+深度,实时性好仅室内可用,范围有限(5m)
IMU姿态估计、传感器融合高频输出,不依赖环境存在漂移,需融合其他传感器

机器人专业可以参考官方文档教程,其中使用的是IMU 传感器、接触式传感器和激光雷达传感器。

视觉方案地图与模型

人工智能专业可以参考下面的纯视觉方案的地图与小车(为了流畅,只加载了少量树木与人模型):

点击展开/折叠
simple.sdf
<?xml version="1.0" ?>
<sdf version="1.8">
<world name="Moving_robot">
<physics name="1ms" type="ignored">
<max_step_size>0.001</max_step_size>
<real_time_factor>1.0</real_time_factor>
</physics>
<plugin
filename="gz-sim-physics-system"
name="gz::sim::systems::Physics">
</plugin>
<plugin
filename="gz-sim-user-commands-system"
name="gz::sim::systems::UserCommands">
</plugin>
<plugin
filename="gz-sim-scene-broadcaster-system"
name="gz::sim::systems::SceneBroadcaster">
</plugin>
<plugin
filename="gz-sim-sensors-system"
name="gz::sim::systems::Sensors">
<render_engine>ogre2</render_engine>
</plugin>

<light type="directional" name="sun">
<cast_shadows>true</cast_shadows>
<pose>0 0 10 0 0 0</pose>
<diffuse>0.8 0.8 0.8 1</diffuse>
<specular>0.2 0.2 0.2 1</specular>
<attenuation>
<range>1000</range>
<constant>0.9</constant>
<linear>0.01</linear>
<quadratic>0.001</quadratic>
</attenuation>
<direction>-0.5 0.1 -0.9</direction>
</light>

<include>
<uri>
https://fuel.gazebosim.org/1.0/OpenRobotics/models/Oak tree
</uri>
</include>

<include>
<uri>
https://fuel.gazebosim.org/1.0/OpenRobotics/models/Pine Tree
</uri>
</include>

<model name="ground_plane">
<static>true</static>
<pose>0 0 -0.05 0 0 0</pose>
<link name="link">
<collision name="collision">
<geometry>
<box>
<size>90 60 0.1</size>
</box>
</geometry>
</collision>
<visual name="visual">
<geometry>
<box>
<size>90 60 0.1</size>
</box>
</geometry>
<material>
<ambient>1 1 1 1</ambient>
<diffuse>1 1 1 1</diffuse>
<specular>0.1 0.1 0.1 1</specular>
<pbr>
<metal>
<albedo_map>file:///home/allen/workspace/map.png</albedo_map>
</metal>
</pbr>
</material>
</visual>
</link>
</model>

<model name='vehicle_blue' canonical_link='chassis'>
<pose>-2.5607891082763667 17.635400772094727 -2.1121493773534894e-05 -8.8815699145246897e-10 7.3326400526134407e-06 2.6270999059034565e-13</pose>
<link name='chassis'>
<pose relative_to='__model__'>0.5 0 0.4 0 0 0</pose>
<inertial> <!--inertial properties of the link mass, inertia matix-->
<mass>1.14395</mass>
<inertia>
<ixx>0.126164</ixx>
<ixy>0</ixy>
<ixz>0</ixz>
<iyy>0.416519</iyy>
<iyz>0</iyz>
<izz>0.481014</izz>
</inertia>
</inertial>
<visual name='visual'>
<geometry>
<box>
<size>2.0 1.0 0.5</size> <!--question: this size is in meter-->
</box>
</geometry>
<!--let's add color to our link-->
<material>
<ambient>0.0 0.0 1.0 1</ambient>
<diffuse>0.0 0.0 1.0 1</diffuse>
<specular>0.0 0.0 1.0 1</specular>
</material>
</visual>
<collision name='collision'> <!--todo: describe why we need the collision-->
<geometry>
<box>
<size>2.0 1.0 0.5</size>
</box>
</geometry>
</collision>
</link>

<!--let's build the left wheel-->
<link name='left_wheel'>
<pose relative_to="chassis">-0.5 0.6 0 -1.5707 0 0</pose> <!--angles are in radian-->
<inertial>
<mass>2</mass>
<inertia>
<ixx>0.145833</ixx>
<ixy>0</ixy>
<ixz>0</ixz>
<iyy>0.145833</iyy>
<iyz>0</iyz>
<izz>0.125</izz>
</inertia>
</inertial>
<visual name='visual'>
<geometry>
<cylinder>
<radius>0.4</radius>
<length>0.2</length>
</cylinder>
</geometry>
<material>
<ambient>1.0 0.0 0.0 1</ambient>
<diffuse>1.0 0.0 0.0 1</diffuse>
<specular>1.0 0.0 0.0 1</specular>
</material>
</visual>
<collision name='collision'>
<geometry>
<cylinder>
<radius>0.4</radius>
<length>0.2</length>
</cylinder>
</geometry>
</collision>
</link>

<!--copy and paste for right wheel but change position-->
<link name='right_wheel'>
<pose relative_to="chassis">-0.5 -0.6 0 -1.5707 0 0</pose> <!--angles are in radian-->
<inertial>
<mass>1</mass>
<inertia>
<ixx>0.145833</ixx>
<ixy>0</ixy>
<ixz>0</ixz>
<iyy>0.145833</iyy>
<iyz>0</iyz>
<izz>0.125</izz>
</inertia>
</inertial>
<visual name='visual'>
<geometry>
<cylinder>
<radius>0.4</radius>
<length>0.2</length>
</cylinder>
</geometry>
<material>
<ambient>1.0 0.0 0.0 1</ambient>
<diffuse>1.0 0.0 0.0 1</diffuse>
<specular>1.0 0.0 0.0 1</specular>
</material>
</visual>
<collision name='collision'>
<geometry>
<cylinder>
<radius>0.4</radius>
<length>0.2</length>
</cylinder>
</geometry>
</collision>
</link>

<frame name="caster_frame" attached_to='chassis'>
<pose>0.8 0 -0.2 0 0 0</pose>
</frame>

<!--caster wheel-->
<link name='caster'>
<pose relative_to='caster_frame'/>
<inertial>
<mass>1</mass>
<inertia>
<ixx>0.1</ixx>
<ixy>0</ixy>
<ixz>0</ixz>
<iyy>0.1</iyy>
<iyz>0</iyz>
<izz>0.1</izz>
</inertia>
</inertial>
<visual name='visual'>
<geometry>
<sphere>
<radius>0.2</radius>
</sphere>
</geometry>
<material>
<ambient>0.0 1 0.0 1</ambient>
<diffuse>0.0 1 0.0 1</diffuse>
<specular>0.0 1 0.0 1</specular>
</material>
</visual>
<collision name='collision'>
<geometry>
<sphere>
<radius>0.2</radius>
</sphere>
</geometry>
</collision>
</link>

<!--camera link-->
<link name='camera_link'>
<pose relative_to='chassis'>0.95 0 0.3 0 0 0</pose> <!--positioned at the very front of chassis-->
<inertial>
<mass>0.1</mass>
<inertia>
<ixx>0.000166667</ixx>
<iyy>0.000166667</iyy>
<izz>0.000166667</izz>
</inertia>
</inertial>
<visual name='visual'>
<geometry>
<box>
<size>0.1 0.1 0.1</size>
</box>
</geometry>
<material>
<ambient>0.0 0.0 0.0 1</ambient>
<diffuse>0.0 0.0 0.0 1</diffuse>
<specular>0.0 0.0 0.0 1</specular>
</material>
</visual>
<collision name='collision'>
<geometry>
<box>
<size>0.1 0.1 0.1</size>
</box>
</geometry>
</collision>

<!--camera sensor-->
<sensor name="camera" type="camera">
<camera>
<horizontal_fov>1.047</horizontal_fov>
<image>
<width>640</width>
<height>480</height>
</image>
<clip>
<near>0.1</near>
<far>100</far>
</clip>
</camera>
<always_on>1</always_on>
<update_rate>30</update_rate>
<visualize>true</visualize>
<!--camera sensor topic-->
<topic>camera</topic>
</sensor>
</link>

<!--connecting these links together using joints-->
<joint name='left_wheel_joint' type='revolute'> <!--continous joint is not supported yet-->
<pose relative_to='left_wheel'/>
<parent>chassis</parent>
<child>left_wheel</child>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz> <!--can be defined as any frame or even arbitrary frames-->
<limit>
<lower>-1.79769e+308</lower> <!--negative infinity-->
<upper>1.79769e+308</upper> <!--positive infinity-->
</limit>
</axis>
</joint>

<joint name='right_wheel_joint' type='revolute'>
<pose relative_to='right_wheel'/>
<parent>chassis</parent>
<child>right_wheel</child>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
<limit>
<lower>-1.79769e+308</lower> <!--negative infinity-->
<upper>1.79769e+308</upper> <!--positive infinity-->
</limit>
</axis>
</joint>

<!--different type of joints ball joint--> <!--defult value is the child-->
<joint name='caster_wheel' type='ball'>
<parent>chassis</parent>
<child>caster</child>
</joint>

<!--camera joint - fixed to chassis-->
<joint name='camera_joint' type='fixed'>
<parent>chassis</parent>
<child>camera_link</child>
</joint>

<!--diff drive plugin-->
<plugin
filename="gz-sim-diff-drive-system"
name="gz::sim::systems::DiffDrive">
<left_joint>left_wheel_joint</left_joint>
<right_joint>right_wheel_joint</right_joint>
<wheel_separation>1.2</wheel_separation>
<wheel_radius>0.4</wheel_radius>
<odom_publish_frequency>1</odom_publish_frequency>
<topic>cmd_vel</topic>
</plugin>
</model>

<!--左右移动的路障行人-->
<actor name="actor_walking">
<skin>
<filename>https://fuel.gazebosim.org/1.0/Mingfei/models/actor/tip/files/meshes/walk.dae</filename>
<scale>1.0</scale>
</skin>
<animation name='walk'>
<filename>https://fuel.gazebosim.org/1.0/Mingfei/models/actor/tip/files/meshes/walk.dae</filename>
</animation>
<script>
<loop>true</loop>
<delay_start>0.000000</delay_start>
<auto_start>true</auto_start>
<trajectory id="0" type="walk">
<waypoint>
<time>0</time>
<pose>0 -16 1.0 0 0 1.57</pose>
</waypoint>
<waypoint>
<time>3</time>
<pose>0 -2 1.0 0 0 1.57</pose>
</waypoint>
<waypoint>
<time>4</time>
<pose>0 -2 1.0 0 0 1.57</pose>
</waypoint>
<waypoint>
<time>4.5</time>
<pose>0 -2 1.0 0 0 -1.57</pose>
</waypoint>
<waypoint>
<time>7.5</time>
<pose>0 -16 1.0 0 0 -1.57</pose>
</waypoint>
<waypoint>
<time>8.5</time>
<pose>0 -16 1.0 0 0 -1.57</pose>
</waypoint>
<waypoint>
<time>9</time>
<pose>0 -16 1.0 0 0 1.57</pose>
</waypoint>
</trajectory>
</script>
</actor>

</world>
</sdf>

ros_gz_bridge

数据流向

  • 摄像头 → /camera 话题 → 你的视觉算法节点
  • 你的算法节点 → /cmd_vel 话题 → 机器人运动控制

这个项目中涉及到2种通信信息格式的转换:

从ROS2_Gazebo流出的传感器数据,需要转为Python能处理的数据格式。

内置摄像头插件:会自动将摄像头数据发布到 /camera 话题

启动后可以通过rviz2查看接收到的图片。

从算法节点流入ROS2_Gazebo的控制信息,需要是ROS2_Gazebo能理解的数据格式。

运动控制插件gz::sim::systems::DiffDrive 插件会监听 /cmd_vel 话题来控制机器人运动

我们把需要执行的命令写入到Makefile里,便于后续执行。

Makefile
.PHONY: world sim img cam bridge go left right stop rv

# 启动原世界(推荐自己再加点树木、行人模拟复杂的真实世界)
world:
gz sim -r world.sdf

# 启动简单车辆世界
sim:
gz sim -r simple.sdf

# 桥接摄像头 (simple.sdf 的 /camera)
cam:
ros2 run ros_gz_bridge parameter_bridge /camera@sensor_msgs/msg/Image@gz.msgs.Image

# 桥接控制话题 (simple.sdf 的 /cmd_vel)
bridge:
ros2 run ros_gz_bridge parameter_bridge /cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist

# 前进
go:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

# 左转
left:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.5}}"

# 右转
right:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: -0.5}}"

# 停止
# -1表示只发送1次
stop:
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

# 启动 rviz2
rv:
rviz2

Makefile 本质是自动化脚本,把常用的项目编译、测试、部署等重复性命令写进去,省得每次手动输入。

make sim等价于:gz sim -r simple.sdf

对于持续性的命令,例如:世界的启动、话题的桥接、长效的控制命令。你需要在不同的终端中执行。

调试命令

# 查看GZ中的话题
gz topic -l

# 查看ros2中活跃的话题
ros2 topic list

# 查看图像话题数据
ros2 topic echo /camera/image
# 查看速度控制话题
ros2 topic echo /model/my_robot/cmd_vel

算法节点

tip

你是否需要去了解一个算法?

从功利角度来说:

  • 当你需要改进这个算法时,需要了解它的工作原理和实现细节。
  • 当你需要调参时,需要了解参数背后的意义。

如果某个算法默认参数表现足够,其实可以先不用知道它是怎么实现的。

下面代码实现了一个视觉运动控制节点,从/camera话题订阅图像数据,并处理后发布到/cmd_vel话题。

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
import numpy as np
from geometry_msgs.msg import Twist

class VisualMotionController(Node):
def __init__(self):
super().__init__('visual_motion_controller')

# 订阅摄像头话题 (simple.sdf 中是 /camera)
self.image_subscription = self.create_subscription(
Image,
'/camera',
self.process_and_control,
10
)

# 发布控制话题 (simple.sdf 中是 /cmd_vel)
self.velocity_publisher = self.create_publisher(
Twist,
'/cmd_vel',
10
)

self.bridge = CvBridge()
self.move_speed = 0.5
self.turn_speed = 0.5
self.step = 0 # 计步器
self.get_logger().info('视觉运动控制节点已启动')


def process_and_control(self, msg):
"""处理图像并生成控制命令"""
try:
# 将ROS图像消息转换为OpenCV格式
cv_image = self.bridge.imgmsg_to_cv2(msg, 'bgr8')
cv2.imshow('car',cv_image)
cv2.waitKey(1)
self.step += 1

# 这里可以添加你的视觉处理算法
# 例如:检测障碍物、识别路径等
# gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
# edges = cv2.Canny(gray, 50, 150)
# 在这里编写你的运动控制算法

# 简单的控制逻辑示例
cmd = Twist()
if self.step < 100:
# 前进100帧
cmd.linear.x = self.move_speed
cmd.angular.z = 0.0
self.get_logger().info(f'前进中... 步数: {self.step}')
elif self.step < 160:
# 左转60帧
cmd.linear.x = 0.0
cmd.angular.z = self.turn_speed
self.get_logger().info(f'左转中... 步数: {self.step}')
else:
# 重置计步器
self.step = 0

self.velocity_publisher.publish(cmd)

except Exception as e:
self.get_logger().error(f'处理图像时出错: {str(e)}')


def main(args=None):
"""主函数"""
rclpy.init(args=args)

# 创建视觉运动控制节点
controller = VisualMotionController()

try:
# 启动节点
rclpy.spin(controller)
except KeyboardInterrupt:
controller.get_logger().info('节点被用户中断')
finally:
# 发送停止指令
stop_cmd = Twist()
controller.velocity_publisher.publish(stop_cmd)
# 清理资源
controller.destroy_node()
rclpy.shutdown()


if __name__ == '__main__':
main()

启动命令:

# 编译工作空间(以learning_node为例)
colcon build --packages-select learning_node

# 启动节点(以learning_node为例)
ros2 run learning_node node_helloworld

2024年年末总结与回顾

· 14 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

思考

需要避免的变化

随着年龄的增长,有两种变化需要避免:

  • 固执自己的世界观,忽视其他年龄段人群的需求与视角。
  • 夸夸其谈自己的想法,却对事情的真相一无所知。

教育供应商

原本用于考核学生的标准被用来考核指导老师,导致老师倾向于选择提供答案的厂商。机构与教师的核心目标是消课,提供适合长度的 PPT/教案。

在 AI 时代,可以采用 AI 智能课的形式辅助学生学习:将电脑屏幕二分,一侧显示课件,另一侧提供 AI 助手,便于学生提问。这样,机构和教师的工作量转变为考核学生及处理问题。

飞轮

为了实现某个场景学习技术 这个场景强化技术 取得的收益会持续提供正向奖励并提供新的场景

譬如英语。为了看懂国外的科技文献,学习了英语。因此获得了出国留学的机会。留学的经历又会进一步强化英语。不断向前,最终形成了正向循环。

常见的飞轮还有:技术、建模、驾车

维度

有些事情对生活帮助很大,但是对工作帮助很小。 譬如读书、旅游、运动。

有些事情对生活帮助很小,但是对工作帮助很大。 譬如技术、项目、方案。

换个角度,有的技术改变对自己提升很大,但是对产品帮助很小。 譬如异步、K8S、Docker。你花了大部分时间,但是大部分情况下用户感知不到。

在选择做事的时候,选择每个维度都能收益的,才是最优解。一些技术革新不易推动或者手下的人忙活许久产品没有起色的原因,可能就是没有让每个人的每个维度都收益。

整理

项目的高度重复性促使我创建一个通用的第三方库。在开发中,代码的简洁易读是首要任务。

2024 年做了很多碎片化的工作,这里花了一周的时间把可以复用的功能整理到 etool 工具库中了。

2024 年出游了国内的一些 4A、5A 景区。

搬回了深圳,换了新家,花了很多心思装点这个家。

我喜欢未知,愿意接受新事物。 在别人的车上,别人放什么歌我就听什么歌。不会想着切自己的音乐。聚餐吃饭,除了很特别的食物(如折耳根)我都可以接受并愿意尝试。随手拿起一本书,一部电影,我都愿意发现它们有价值的一面。

我觉得人生是一种经历,我会期待更丰富的经历。 走路会更喜欢去没去过的路线、没吃过的小吃,同样一部很好看的电信或者综艺,我也最多会刷两遍,当然对应的,我会在第一次看它们的时候,就全神贯注,努力记住并体会它们的优秀。

我会把有价值的经历长久记录下来。 譬如读书、方案、照片。并且定期回看至少每年都会回顾、复读。确保经历对自己的长久影响,不让这些有价值的记忆在角落蒙尘。

我会定期整理收藏夹。 一段时间内(一个月)不清空收藏夹就停止新知识的学习,以此来不让自己成为收藏夹大师。

我做事喜欢全神贯注。 吃饭过程中想到一个方案会马上放下食物,去完成这个方案。我觉得饭每天都吃,但灵感不是每刻都有。

我讨厌无意义的制度。 因为我珍惜并享受我的生命,我也热爱智能家居并做了很多研究,因为我觉得这在节省我的时间,让我能全身心投入到生活中。

我会记大账,不喜欢记细账。 譬如这个月最主要花销是哪些,不会在意今天这顿饭多吃一点少吃一点

我只能学习当下能学以致用的知识。 因为过于长远的未来总是不确定的,当下 5 年以内的未来是确定的。

学习

这里重点的学习了大模型相关的知识,并尝试了使用大模型来解决一些问题。

2025 年,我想放慢更新博客的频率,每个季度更新一次,并尝试写一些有深度的项目和文章,毕竟项目在优不在多。

日常

学习建模技术

最近我间歇性学习建模技术,发现自己更倾向于选择中间款软件,它能快速生成及格的作品。

一些阅读摘要

  • “愿意自由换取保障的人,既得不到自由,也得不到保障。”——哈耶克

  • “只有金钱会向穷人伸手,而权力不会。”——哈耶克

  • “如果允许人类自由迁徙,那么人流的方向,就是文明的方向。”——哈耶克

房间布网

网络知识 核心知识 1: 信号在穿墙后会衰减 核心知识 2:路由器的信号芯片优于大部分手机/电脑的天线

主路由器发射穿墙信号,墙另一侧,无线中继路由器接收到的信号大于设备 A 大于设备 B。于是,可以让设备 A、设备 B 连接无线中继路由器,实现与无线中继路由器一样的网速。

猫尿清洗说明

猫尿具有留存时间长,渗透性高

若猫尿在沙发枕套上,需要拆开枕套、枕芯。

枕套使用专用除猫尿清洗剂+汰渍/奥妙,浸泡一夜+洗衣液洗净,自然阴干。

枕芯被污染需要直接更换,尺寸为枕套的尺寸+5cm(80cm50cm 的枕套,配 85cm55cm 的枕芯)

购车后

买车了,买完车除了要交保险之外,还需买一些必要的物品:行车记录仪、实习车标、挪车号码牌、车载除甲醛香薰、手机导航支架。

侧方位停车复习:开至肩膀与库中齐平,向车库反向打成 45 度,倒车回方向直至左右两边距离合适停正。

通勤学习

指定学习计划是利用碎片化时间的首要目标,阅读书籍、观看 TED 演讲或自己的收藏会让学习过程更有成就感。应避免泛信息输入(如新闻、推荐刷屏),而应选择适当的半精准信息输入(如读书、博客、GitHub 等)。通勤后及时整理记录。

随着职业地位的提升,不必要的内耗逐渐减少。高中时非常在意学校的排名,而如今在大厂面前都统一成了“双非”。想要进入世界一流的企业,需要有相应的世界排名。只有世界级的排名与项目才能证明你的价值。

每个阶段有不同的评价标准。从学生时期的院校评价,到工作初期的薪资待遇,再到中年的事业与家庭评价,均体现出人生的不同阶段。

平淡生活的解药

平淡生活源于外部世界缺乏新输入。当工作多年没有变化,内部的平淡便开始显现,只有不断成长,才能支撑更远的目标与更好的生活。

如果我们把感情看作 0 和游戏。那么总会会有人因长期利益受损而对这段关系感到不满。

因此,只有当一方的所求不是另一方的付出时,0 和才会被打破。即:某一行为是二者都收益的。

企业与员工关系亦是如此,企业提供平台员工发挥,员工将在平台创造的利润与企业分成。

最近我发现迷你工作室非常适合我。迷你工作室通常由 1-5 人组成,项目可以是软件、游戏或网站。

成立工作室是为了开发票,能接到更多的项目。在 AI 时代,有两种发展方向:加入大公司研究通用人工智能,或选择中小型公司做垂类人工智能,专注于特定领域的模型开发。

除了开发人工智能模型外,还可以拓展其应用,如语音控制家庭设备。技术的爆发得益于高速通信和丰富的硬件资源。

经济下行时创业或找工作的挑战包括投资回报难、企业扩张减慢及岗位的消失,传统岗位正在逐步被淘汰,需尽早转型。

电脑新配

最近新买了一台台式机,主要用于编程(高速大内存)、建模跑模型(显存优先)和数据分析(高速硬盘)。

对于大模型来说,显存优先。32B 的大模型本地需要 20G、常规的文生图模型本地需要 24G。

消费级显卡性价比更高,3090 Ti 支持 Nvlink 很适合后期组双卡,或升级5090。

配件描述价格 (元)
显卡微星 超龙 RTX3090Ti 24G8099
CPUAMD 锐龙 R9 7950X 16 核 32 线程3695
硬盘英睿达固态硬盘 1T M.2 PCIE5.01767
内存光威内存 龙武 32G*2 64G 6400 C32 马甲条1330
主板华硕 TUF GAMING B650M PLUS 蓝牙 WIFI DDR51170
电源航嘉 MVP1200 额定 1200W 白金全模组 黑色1258
机箱及风扇航嘉 GX750A 掠夺者 黑色 ATX 360 + 链力无光风扇*6300
水冷散热钛钽 SJ-A080 360WH295
屏幕乐视显示器 32 英寸 1K 75hz699
键盘Lofree 洛斐小浪蓝牙机械键盘(旅行版)599
鼠标Razer 雷蛇炼狱蝰蛇 V2X 极速版双模无线电池笔记本蓝牙电竞鼠标249

工具推荐

Windows 11 的 PowerToys 是一个非常强大的工具,它可以帮助你更高效地使用 Windows 系统。找到鼠标、窗口永久置顶、截图定格等功能。

Windows平台的显示键盘按键的产品

软件名称费用特色功能推荐指数
Carnac免费C#开发,极快、极小、无BUG⭐⭐⭐⭐⭐
Keyviz免费界面最美观,动画效果好,windows下有一些显示BUG⭐⭐⭐⭐⭐
NohBoard免费全键盘虚拟键盘显示,高度可定制⭐⭐⭐⭐
showKeyBoard免费功能最全面,支持统计分析,界面丑,快,无BUG⭐⭐⭐⭐⭐
KeyCastOW免费较小体积,绿色便携⭐⭐⭐⭐

机器学习与LLM舆情分析

· 10 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

该项目适合作为小型的项目原型,适合教学和练手。最初这个项目的灵感源于我的个人需求,我需要一个工具来查看主流话题,同时又不想下载一堆 APP 来接收推送。

项目开源地址:https://github.com/jiangyangcreate/AI-Practice-Collection/SocialMood

项目目录结构

SocialMood/
├── docs/ # 文档和静态页面目录
│ └── index.html # 数据可视化的静态页面,展示词云图等图表
├── src/ # Python代码主目录
│ ├── _dataclean.py # 数据清洗脚本,负责数据清洗和处理
│ ├── _model.py # 模型脚本,负责情绪分析
│ ├── _pyechart.py # 数据可视化脚本,负责数据可视化
│ ├── _database.py # 数据库操作模块,处理SQLite数据存储
├── README.MD # 项目说明文档,包含项目介绍和使用说明
├── .gitignore # Git忽略文件配置,用于排除不需要版本控制的文件
├── .nojekyll # 用于GitHub Pages的配置文件
├── requirements.txt # Python依赖包列表
├── setup.py # 项目依赖安装脚本,负责安装必要的包和模型
├── run.py # 项目运行脚本,负责运行爬虫和数据处理
├── news.db # SQLite数据库文件,存储新闻数据

使用流程

# 安装依赖
cd python
python setup.py

# 运行数据抓取,生成静态网页
python run.py

依赖安装

所需依赖可以通过 setup.py 下载安装。因为有些模块不是pip就算安装好的。

setup.py
import subprocess
import sys
import nltk


def install_requirements():
print("正在安装依赖...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])

def install_browser():
print("正在安装浏览器...")
subprocess.check_call([sys.executable, "-m", "playwright", "install"])

def download_nltk_data():
print("正在下载NLTK词典文件...")
nltk.download('vader_lexicon')

def download_model():
try:
import ollama
ollama.pull('qwen2.5')
except Exception as e:
print(f"下载模型失败:{e}")

def main():
try:
install_requirements()
install_browser()
download_nltk_data()
download_model()
print("所有安装步骤已完成!")
except subprocess.CalledProcessError as e:
print(f"安装过程中出现错误:{e}")
sys.exit(1)

if __name__ == "__main__":
main()

主要功能

该系统的主要功能包括:

  • 抓取热搜数据:从微博、抖音、B站等平台抓取热搜数据。

这里也可以通过API获取,爬取注意不要变成DDOS攻击。

  • 数据处理:使用 Pandas 进行数据清洗与处理。

使用pandas主要是处理一些文本型的数据,譬如10万要换算为100000。

使用jieba分词用于后续词云图生成,需要剔除一些单字与标点符号。当然,最近b站很喜欢在标题中加空格,所以要先去空格再分词。

有些数据的热度值还没计算出来,可以使用幂律分布的线性回归填补热度缺失值。这里使用指数回归、普通线性回归效果都不好。

幂律分布常见的样子:个人UP主粉丝排名前几名粉丝差距有百万,像指数分布。但是后续排名的up粉丝差距就很小,接近线性分布。如果我随机扣掉一个排名的up主的粉丝数,让你预测,你可以预测多准。我面对的大概就是这样的一个问题。

下面是我的解决方案:

src/_dataclean.py
def estimate_missing_heat(self):
"""估算缺失的热度值"""
features = ["排名"]

target = "处理后热度"

def impute_group(group):
X = group[features]
y = group[target]

# 如果没有缺失值,直接返回原始组
if not y.isnull().any():
return group

# 幂律分布拟合(通过对数变换实现)
X_train = X[y.notnull()]
y_train = y[y.notnull()]

# 将 X_train 转换为浮点数类型
X_train = X_train.astype(float)

# 对X和y进行对数变换,处理可能的零值
log_X_train = np.log(X_train + 1)
log_y_train = np.log(y_train + 1)

lr = LinearRegression()
lr.fit(log_X_train, log_y_train)

# 获取拟合的系数
slope = lr.coef_[0]
intercept = lr.intercept_
# print(f"拟合的幂律方程为: y = {np.exp(intercept):.2f} * x^{slope:.2f}")

# 预测缺失值
X_missing = X[y.isnull()]
if not X_missing.empty:
# 将 X_missing 转换为浮点数
X_missing = X_missing.astype(float)
log_X_missing = np.log(X_missing + 1)
log_predictions = lr.predict(log_X_missing)
predictions = np.exp(log_predictions) - 1 # 反向变换

# 确保预测值不小于0
predictions = np.maximum(predictions, 0)

# 确保预测值符合排名顺序
for i, pred in enumerate(predictions):
rank = X_missing.iloc[i]["排名"]
higher_ranks = y[(X["排名"] < rank) & y.notnull()]
lower_ranks = y[(X["排名"] > rank) & y.notnull()]

if len(higher_ranks) > 0:
pred = min(pred, higher_ranks.min())
if len(lower_ranks) > 0:
pred = max(pred, lower_ranks.max())

predictions[i] = pred

group.loc[y.isnull(), target] = predictions

return group

# 对每个信息来源分别进行缺失值填充
self.df = self.df.groupby("信息来源").apply(impute_group)

# 删除仍然为None的行(如果有的话)
self.df = self.df.dropna(subset=["处理后热度"])
self.df["处理后热度"] = self.df["处理后热度"].astype(int)
  • 情绪分析:监测和分析公众情绪。算出单条标题的情绪数值之后,标准化到 (-1,1) 这个区间之中。最后通过热度与排名计算出对社会的情感影响力。正数数则是积极影响,负数则是负面影响。

如果你的电脑性能还不错,可以使用本地模型作为情绪分析的核心,根据自己的设备选择模型大小。

src/_model.py
class Model:
@classmethod
def get_sentiment_score(cls,text):

prompt = "请仅对用户提供的句子进行情感评分,并返回介于-1到1之间的分数。-1表示非常负面,1表示非常正面,0表示中立。无需提供其他信息或上下文。"

messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": text}
]
response: ChatResponse = chat(model='qwen2.5', messages=messages)
return float(response.message.content)

数据存储

系统使用 sqlite3 保存中间数据,此部分为基本的增删改查,相关代码在 _database.py 中,数据文件为 news.db,包含两张表:

  • news:清洗后的可用数据。
  • raw_html:原始网页数据。

这样可以保证速度的同时,可以保持文件夹的整洁,如果数据量大可以直接平滑的迁移到正式数据库中。

数据可视化

接下来绘制一些图表,包括:

  • 各个平台的情绪红绿图。
  • 公众情绪的涨跌折线图。
  • 每日全网词云图。

通过pyecharts的脚手架,导出为静态网页。这里为了代码直观,封装为了类。

为了保证协调统一,这里将所有图表的绘制都封装到了一个类中,都使用pyecharts。table的绘制使用pyecharts的components的Table类,这个类默认会将超链接转义,查看源代码发现内容有一个参数 escape_data 用于设置是否转义。但是没有被暴露出来,已经提交了PR,如果你的escape_data=False报错,可以自己修改源代码。

src/_pyechart.py
from pyecharts.charts import Bar, Line, Pie, WordCloud, Page
from pyecharts.components import Table
from pyecharts import options as opts
import os
class ChartGenerator:
def create_table(self,data) -> Table:
"""Generate a table."""
table = Table()
headers = data.columns.tolist()
rows = data.values.tolist()
table.add(headers, rows,escape_data=False)
return table

def create_bar_chart(self, data , y_label="Sentiment",title="正负面情绪") -> Bar:
"""Generate a bar chart with positive, negative, and absolute sentiment scores."""

x_data, y_data_positive, y_data_negative, y_data_absolute = zip(*data)
bar = (
Bar()
.add_xaxis(x_data)
.add_yaxis("积极", y_data_positive, stack="stack1", category_gap="50%")
.add_yaxis("消极", y_data_negative, stack="stack2", category_gap="50%")
.add_yaxis("绝对值", y_data_absolute, stack="stack3", category_gap="50%")
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
xaxis_opts=opts.AxisOpts(name="来源"),
yaxis_opts=opts.AxisOpts(name="情绪得分"),
legend_opts=opts.LegendOpts(pos_top="10%", pos_right="10%"),
)
)
return bar

def create_line_chart(self,data,y_label="情绪得分",title="绝对情绪变化折线图") -> Line:
"""Generate a line chart."""
x_data, y_data = zip(*data)
line = (
Line()
.add_xaxis(x_data)
.add_yaxis(y_label, y_data, is_smooth=True)
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
xaxis_opts=opts.AxisOpts(name="日期"),
legend_opts=opts.LegendOpts(is_show=False)
)
)
return line

def create_pie_chart(self, data_pairs, title="信息来源占比") -> Pie:
"""Generate a pie chart."""
pie = (
Pie()
.add("", data_pairs)
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
legend_opts=opts.LegendOpts(pos_bottom="0%") # Move legend to the bottom
)
.set_series_opts(label_opts=opts.LabelOpts(formatter="{b}: {d}%"))
)
return pie

def create_wordcloud(self, words, title="词云") -> WordCloud:
"""Generate a word cloud."""
wordcloud = (
WordCloud()
.add("", words, word_size_range=[20, 100],shape="circle")
.set_global_opts(title_opts=opts.TitleOpts(title=title, pos_left="center"))
)
return wordcloud

def render_charts(self, charts, output_file=None):
"""Render multiple charts to a single HTML file."""
if output_file is None:
output_file = os.path.join("docs", "index.html")

page = Page(layout=Page.SimplePageLayout)
page.add(*charts)
page.render(output_file)


代码设置

随着时间的推移,爬虫部分的代码可能需要自己修改。你也可以在main函数中,将debug设为True,这样不会真的爬取,而是调用本地的已爬取的网页。生成后的内容在docs/index.html

微信小程序蓝牙通信示例

· 19 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

作为开发者,最讨厌的事情莫过于多平台适配,在手机端由于大家型号不同,编个APP通过蓝牙控制显然是不方便的,于是做了一个蓝牙小程序来与ESP32通信。

界面设计

这里我想设计成方块按键的格式,所以创建一个矩阵,然后在矩阵对应的位置添加上按钮。非常的简单,只是有一些差异需要注意。

wxml标签

蓝牙小程序标签与html略有不同,以下是小程序标签(即wxml标签)与 HTML 略有不同的标签的对比表:

wxml标签HTML 标签描述
<view><div>用于容器和布局,类似于 HTML 中的 <div>
<text><span>用于文本显示,类似于 HTML 中的 <span>
<button><button>用于创建按钮,与 HTML 中的 <button> 功能相同。
<image><img>用于显示图片,类似于 HTML 中的 <img>,但属性有所不同。
<navigator><a>用于页面导航,类似于 HTML 中的 <a> 标签。
<picker>N/A用于多种选择器,HTML 中无直接对应的标签。
<scroll-view>N/A用于可滚动的视图区域,HTML 中无直接对应的标签。
<swiper>N/A用于滑动视图容器,HTML 中无直接对应的标签。
<map><iframe>用于展示地图,类似于 HTML 中嵌入地图的方式。
<swiper-item>N/A<swiper> 配合使用,HTML 中无直接对应的标签。
<rich-text>N/A用于展示富文本,HTML 中无直接对应的标签。
<block>N/A无实际渲染效果,类似于 HTML 中的 <template>

JS

以下是微信小程序的 JavaScript(JS)与网页的 JavaScript 的对比表格:

特性/功能微信小程序 JS网页 JS描述
全局对象wxwindow微信小程序中使用 wx 对象来调用特定的 API,而在网页 JS 中,所有的全局对象都挂载在 window 对象下。
API 调用基于 wx 对象提供的 API,如 wx.request()wx.navigateTo()使用浏览器提供的 API,如 fetch()window.location微信小程序有一套独立的 API,专门用于微信环境下的开发,无法直接使用标准的浏览器 API。
页面与组件管理通过小程序的 PageComponent 函数定义页面和组件通过 HTML 文件和 JavaScript 结合使用前端框架或直接操作 DOM微信小程序使用特殊的 PageComponent 函数来定义页面和组件,网页 JS 则通过 DOM 结合 JavaScript 实现页面和组件管理。
数据绑定使用 this.setData() 进行数据绑定和更新通常使用 innerHTMLtextContent 或前端框架(如 React 的 setState微信小程序使用 this.setData() 来绑定和更新数据,而在网页 JS 中,常通过直接操作 DOM 或使用前端框架来更新数据。
生命周期函数提供页面与组件的生命周期函数,如 onLoadonShow通过事件绑定或框架提供的生命周期函数(如 React 的 componentDidMount微信小程序有特定的生命周期函数供开发者使用,而网页 JS 通常需要结合框架或事件来处理生命周期管理。
路由与导航使用 wx.navigateTo()wx.redirectTo() 等方法进行页面跳转通过改变 window.location 或使用 history.pushState() 进行路由小程序的路由机制是由微信管理的,开发者需要使用专门的 API 进行导航,而网页 JS 可以直接操作 URL。
模块化使用 require() 和模块化文件系统使用 ES6 import/export 或 CommonJS 模块系统小程序内置的模块化系统与 Node.js 类似,使用 require() 导入模块,而网页 JS 中可以使用 ES6 模块或 CommonJS 模块系统。
网络请求使用 wx.request() 发起 HTTP 请求使用 fetch()XMLHttpRequest 发起 HTTP 请求微信小程序提供了 wx.request() 方法用于网络请求,而网页 JS 通常使用 fetch()XMLHttpRequest
文件系统访问通过 wx.getFileSystemManager() 访问文件系统通过 File API、Blob、FileReader 等访问文件小程序提供了 wx.getFileSystemManager() 接口来管理文件系统,而网页 JS 可以使用浏览器提供的 File API。
样式与布局使用 WXML 和 WXSS 定义页面结构和样式使用 HTML 和 CSS 定义页面结构和样式微信小程序使用 WXML 和 WXSS 分别来代替 HTML 和 CSS,专门为小程序定制。
事件处理事件绑定使用 bindtapcatchtap 等绑定事件事件绑定使用 addEventListener 或内联 onclick微信小程序的事件处理是通过特定的属性绑定事件,而网页 JS 可以直接使用标准的事件绑定方法。
调试与工具使用微信开发者工具进行调试使用浏览器的开发者工具进行调试小程序开发和调试通常在微信开发者工具中进行,而网页开发则依赖于浏览器提供的开发者工具。
存储提供 wx.setStorage()wx.getStorage() 进行数据持久化存储使用 localStoragesessionStorage 进行数据存储微信小程序的存储 API 类似于浏览器的 localStorage,但使用 wx 提供的 API 进行调用。
原生 API 调用不支持直接调用浏览器或操作系统的原生 API可以使用浏览器 API 或通过插件访问系统 API微信小程序无法直接调用浏览器或操作系统的原生 API,而网页 JS 则可以直接使用这些 API。
平台限制运行于微信环境,仅支持在微信客户端中运行运行于浏览器环境,可以在任何支持的浏览器中运行小程序只能在微信客户端中运行,而网页 JS 则可以在任何现代浏览器中运行。

页面代码

界面部分因为手机的尺寸实在太多,所以我创建一个矩阵,然后把有按键的地方加上边框实现规则布局。中间输入设备名称。

在正常的网页开发过程之中。缺省是非常常见的,也就是某一个属性大门不去填写的时候浏览器会默认给一个属性。但是在小程序开发过程之中,他并不会给一个默认的属性或者是说他给的默认属性与浏览器的不同。这就会导致在模拟的时候看到的界面是一个样子(因为使用的是浏览器渲染),真机调试的时候又是另一个样子。因为在手机上跑的服务是小程序自己的编译后的,跑在手机上的小程序环境。

所以我们需要把必要的属性全部都填写上。

<scroll-view class="scrollarea" scroll-y type="list">
<view class="container">
<text>蓝牙连接状态:{{status}}</text>
<div class="button-lines">
<input type="text" placeholder="请输入设备名称" bindinput="onDeviceNameInput" />
</div>
<!-- 3x7 矩阵布局 -->
<view class="button-grid">
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendUp" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="connectDevice">连接</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendA">A</button></view>
<view class="cell"></view>
</view>
<view class="row">
<view class="cell"><button bindtouchstart="sendLeft" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendRight" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendB">B</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendD">D</button></view>
</view>
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendDown" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendData">文件</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendC" >C</button></view>
<view class="cell"></view>
</view>
</view>
</view>
</scroll-view>

样式代码

/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.container {
padding: 20px;
}
.button-lines {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
input{
border: 1px solid #ccc;
padding: 8px;
margin-right: 5px;
width: 50%;
height: 30%;
}
.button-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

.row {
width: 100%;
display: flex;
justify-content: space-between;
}

.cell{
width: 12%;
height: 50px;
margin: 5px;

display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}

.cell button{
border: 1px solid #ccc;
}
button {
width: 100%;
height: 100%;
box-sizing: border-box;
}

逻辑设计

考虑到不同设备的蓝牙名称不同,因此我在页面的中间设计一个输入框,输入对应的设备名称(大小写敏感)后,点击连接按钮,即可触发搜索接口。为了让自己知道是否已经连接上,我在输入框的上面添加了一个状态显示,考虑到部分用户不能理解红色绿色的默认含义,我使用了中文来描述连接状态。

底部做了一些按键发送数据的功能,包括:中文上下左右、英文ABCD、还有大文件一键传输(我设置了范围为txt和py)

tip

网页开发中,浏览器的渲染主线程会在解析DOM树的时候给所有HTML节点根据权重添加上属性,而小程序中,一旦缺省关键的属性,在开发界面会正常显示,上真机就会异常,这点尤其需要注意。

蓝牙设备的搜索、连接等功能由微信的API接口提供,其中蓝牙的权限上,如果使用的是:仅在使用中允许,在部分安卓手机上,会出现切后台再返回时蓝牙权限丢失的情况。因此改为:每次使用时询问权限。目前在官方论坛上留言了,我更倾向于是安卓设备的问题。

另外安卓中蓝牙权限与位置权限关联,因此仅开启蓝牙权限依然无法使用。

微信的蓝牙接口搜索到的设备便不再出现,假设我周边存在设备A、设备B、设备C

如果我首先输入了设备B,蓝牙搜索API根据信号强弱依次返回:设备A、设备B(判定成功,建立连接)

此时我再输入设备A,点击连接,就会出现搜不到设备的情况,当然这里是可以优化的,设置一个点击按钮:刷新。不过我右上角点击重新进入小程序也是可以的,所以这里就不是很有必要加这个逻辑判断。

蓝牙设备的连接非常简单,根据参考文档一步一步来即可,需要注意的是,发送中文时可能会乱码,JS原生的解码又不能用,所以我导入了一个包import TextEncoder from './miniprogram-text-encoder'来自动判断文本是中文还是英文执行对应的转化。

既然蓝牙可以通信,传输中文和英文,那么是不是可以传本书过去?首先尝试直接传输,发现接收方只收到了前20字节,后续数据丢失。那么修改程序,将文件分片、每次发20个字节,发送完成之后在发送一个END标记。

和之前发送数据的代码写在一起,就变成了这样:


import TextEncoder from './miniprogram-text-encoder'

Page({
data: {
status: '未连接',
deviceId: null,
serviceId: null,
characteristicId: null,
deviceName: 'None' // 默认设备名称
},
onLoad() {
this.initBluetooth();
},
onDeviceNameInput(e) {
console.log(e.detail.value);
this.setData({
deviceName: e.detail.value,
})
;
},
initBluetooth() {
const that = this;
wx.openBluetoothAdapter({
success(res) {
console.log('初始化蓝牙适配器成功');
that.startBluetoothDevicesDiscovery();
wx.showToast({
title: '蓝牙权限成功',
icon: 'success',
duration: 2000
});
},
fail(res) {
console.log('初始化蓝牙适配器失败', res);
wx.showToast({
title: '蓝牙权限失败',
icon: 'error',
duration: 2000
});
}
});
},
startBluetoothDevicesDiscovery() {
const that = this;
console.log(that.data.deviceName, '57');

// 如果 deviceName 是 "None",不进行蓝牙设备搜索
if (that.data.deviceName === "None") {
console.log('设备名称为 "None",不进行蓝牙设备搜索');
return;
}

wx.startBluetoothDevicesDiscovery({
success(res) {
console.log('开始搜索蓝牙设备');
that.onBluetoothDeviceFound();
},
fail(res) {
console.log('搜索蓝牙设备失败', res);
}
});
},
onBluetoothDeviceFound() {
const that = this;
wx.onBluetoothDeviceFound((devices) => {
devices.devices.forEach(device => {
console.log('发现设备名称:', device.name); // 打印所有发现的设备名称
if (device.name === that.data.deviceName) {
wx.showToast({
title: '发现蓝牙设备',
icon: 'success',
duration: 2000
});
that.createBLEConnection(device.deviceId);
}
});
});
},
createBLEConnection(deviceId) {
const that = this;
wx.createBLEConnection({
deviceId: deviceId,
success(res) {
console.log('连接蓝牙设备成功');
that.setData({
status: '已连接',
deviceId: deviceId
});
that.getBLEDeviceServices(deviceId);
},
fail(res) {
console.log('连接蓝牙设备失败', res);
}
});
},
getBLEDeviceServices(deviceId) {
const that = this;
wx.getBLEDeviceServices({
deviceId: deviceId,
success(res) {
console.log('获取服务成功:', res.services);
for (let i = 0; i < res.services.length; i++) {
if (res.services[i].isPrimary) {
that.getBLEDeviceCharacteristics(deviceId, res.services[i].uuid);
return;
}
}
}
});
},
getBLEDeviceCharacteristics(deviceId, serviceId) {
const that = this;
wx.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: serviceId,
success(res) {
console.log('获取特征值成功:', res.characteristics);
for (let i = 0; i < res.characteristics.length; i++) {
if (res.characteristics[i].properties.write) {
that.setData({
serviceId: serviceId,
characteristicId: res.characteristics[i].uuid
});
return;
}
}
}
});
},
connectDevice() {
this.startBluetoothDevicesDiscovery();
},
sendData() {
const that = this;
// 选择本地 TXT 或 PY 文件
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['txt', 'py'],
success(res) {
const filePath = res.tempFiles[0].path;
const fileName = res.tempFiles[0].name;

// 读取文件内容为 ArrayBuffer
wx.getFileSystemManager().readFile({
filePath: filePath,
success(readRes) {
const fileBuffer = readRes.data;
console.log(readRes.data)
const chunkSize = 20; // 每次发送20字节
const totalChunks = Math.ceil(fileBuffer.byteLength / chunkSize);

// 发送文件名称和分片数
const fileInfo = `${fileName}|${totalChunks}`;
const fileInfoBuffer = that.stringToArrayBuffer(fileInfo);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: fileInfoBuffer,
success(res) {
console.log('文件信息发送成功');
},
fail(res) {
console.error('文件信息发送失败', res);
}
});

// 逐块发送文件数据
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, fileBuffer.byteLength);
const chunk = fileBuffer.slice(start, end);
const progress = ((i + 1) / totalChunks) * 100;

// 发送当前块数据
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: chunk,
success(res) {
console.log(`数据发送成功: ${i + 1}/${totalChunks} (${progress}%)`);
if (i === totalChunks - 1) {
// 发送结束标志
const endBuffer = that.stringToArrayBuffer('END');
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: endBuffer,
success(res) {
console.log('所有数据发送完成');
}
});
}
},
fail(res) {
console.error(`数据发送失败: ${i + 1}/${totalChunks}`, res);
}
});
}
},
fail(err) {
console.error('文件读取失败', err);
}
});
},
fail(err) {
console.error('文件选择失败', err);
}
});
},

// 将字符串转换为 ArrayBuffer
stringToArrayBuffer(str) {
const base64 = wx.arrayBufferToBase64(new TextEncoder().encode(str).buffer);
return wx.base64ToArrayBuffer(base64);
},

// 发送控制消息
sendMessage(message) {
const that = this;
const buffer = that.stringToArrayBuffer(message);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: buffer,
success(res) {
console.log(`消息发送成功: ${message}`);
},
fail(res) {
console.error(`消息发送失败: ${message}`, res);
}
});
},
// 松开按钮时发送消息
handleTouchEnd() {
this.sendMessage('释放');
},
sendUp() {
this.sendMessage('上');
},
sendDown() {
this.sendMessage('下');
},
sendLeft() {
this.sendMessage('左');
},
sendRight() {
this.sendMessage('右');
},
sendA() {
this.sendMessage('A');
},
sendB() {
this.sendMessage('B');
},
sendC() {
this.sendMessage('C');
},
sendD() {
this.sendMessage('D');
}

安卓系统有一个"运行时允许权限",该权限在不可复现的场景下会出现后台程序还在运行,但权限未授予。可以改为本次使用允许,让每次使用时都询问获取权限。

后话

这里是完整代码

程序的优化是无穷无尽的,所以这里我只实现了最少的功能,如果项目对你有帮助,不用问我,直接拿去用。

摄像头云台控制指令解析

· 12 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

购买了一款云台摄像头,你可以在淘宝搜这个关键词知道它长什么样子。

它默认能通过 RS232 协议控制云台转动,但是现在新的主板已经没有这种圆形的接口了,基本都是 USB。

他也可以通过遥控器控制,但是我想尝试自己编写逻辑代码通过键盘控制。

效果是通过监听键盘上下左右等事件,调用对应云台运动的指令,运动到合适角度之后可以按下空格停止云台运动,按下 ESC 退出控制程序。

也可以按下某个按键如数字1,直接运动到预设角度。

咨询商家后,商家提供了 RS232 协议的指令集,所以这里通过 python 的 serial 库尝试通过 USB 口对其云台调用。

RS232 协议说明书

CommandCommand PacketComments
Stop8x 01 06 01 VV WW 03 03 FFVV: Pan Speed
Left8x 01 06 01 VV WW 01 03 FFWW: Tilt Speed
Right8x 01 06 01 VV WW 02 03 FFYYYY: Pan Position
Up8x 01 06 01 VV WW 03 01 FFZZZZ: Tilt Position
Down8x 01 06 01 VV WW 03 02 FF
UpLeft8x 01 06 01 VV WW 01 01 FF
UpRight8x 01 06 01 VV WW 02 01 FF
DownLeft8x 01 06 01 VV WW 01 02 FF
DownRight8x 01 06 01 VV WW 02 02 FF
Absolute Position8x 01 06 02 VV WW 0Y 0Y 0Y 0Y 0Z 0Z 0Z 0Z FF
Relative Position8x 01 06 03 VV WW 0Y 0Y 0Y 0Y 0Z 0Z 0Z 0Z FF
Home8x 01 06 04 FF
Reset8x 01 06 05 FF

这里有一些复合指令,譬如 UpRight:向上的同时向右,如果是手柄控制比较好,键盘控制比较鸡肋,所以这里我们实现:上下左右、暂停、复位、绝对定位这几个能用到与可能会用到的。

基数转换

这里的绝对定位和相对定位部分,出现了0Y 0Y 0Y 0Y0Z 0Z 0Z 0Z,我希望传入一个 10 进制的角度,譬如0、90、180,怎么映射到其中呢?这就体现我们学完二进制之后的敏感度了,把 20 转成 2 进制的过程是:

20 ÷ 2 = 10 余数: 0
10 ÷ 2 = 5 余数: 0
5 ÷ 2= 2 余数: 1
2 ÷ 2=1 余数: 0
1 ÷ 2= 0 余数: 1
按余数倒序排列: 10100

这里我们观察范例,对数据做了拆分,即如果需要把10进制映射到16进制上,譬如17转成16进制是11,那么应该变成0101。每个位置之间插入0

所以可以写出如下代码:

def calculate_pan_position_bytes(pan_pos_value):
HEX_VALUES = [4096, 256, 16, 1] # 定义常量
pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
pan_pos_ints.append(pan_pos_value // value)
pan_pos_value %= value
# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)
# 将17转化为16进制,应该是11,拆分加0,应该是0101
# 前方补0到总长度为8位,结果与我们预期一致
print(calculate_pan_position_bytes(17))
# 00000101

接下来通过代入0到6000这样的数值传输给串口后发现,只能向左转。

0对应居中,4500对应向左转180,数字再大也是转到底。

4500比180 = 25比1,所以我们输入角度,乘以25就得到了对应的信号值。

根据手册说明水平转动范围为355度,一半则是177.5度,与肉眼观察基本一致,Z轴的范围是上下各21度。

刚刚只能向左转,那么向右转的答案就呼之欲出了,要么是补码(异或运算后加1),要么是首位为符号位。我们添加上限位和映射,先用补码试试完成这个函数(结果直接成了)。

def calculate_pan_position_bytes(pan_pos_value, axis_type):
"""
计算轴(旋转)的位置字节。

参数:
pan_pos_value (int): 位置值,
axis_type (str): 轴的类型 ('y' or 'Y' for Y-axis, others for Z-axis)

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""
if axis_type.lower() == "y":
pan_pos_value = max(-177.5, min(pan_pos_value, 177.5)) # 限制取值范围
else:
pan_pos_value = max(-21, min(pan_pos_value, 21)) # 限制取值范围

pan_pos_value = int(pan_pos_value * 25) # 将角度转换为步长
pan_direction = "-" if pan_pos_value < 0 else "+" # 设定旋转方向
pan_pos_value = abs(pan_pos_value) # 取绝对值

HEX_VALUES = [4096, 256, 16, 1] # 定义常量

pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
if pan_direction == "+":
pan_pos_ints.append(pan_pos_value // value)
else: # 异或操作
pan_pos_ints.append((pan_pos_value // value)^ 0xF)
if i == 3 : # 最后一个数字,取反后加1
pan_pos_ints[-1] = pan_pos_ints[-1]+1
pan_pos_value %= value

# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)

代码目标效果

希望具体的指令都可以通过 Python 函数来实现,同时暴露出所有可能需要修改的参数。最后关联键盘事件。例如:

camera_control.py
import keyboard
from usbcamera import *
from usbcamera import move_to_absolute_position
"""
设备 "/dev/ttyUSB0" 的云台旋转至绝对定位:
Y轴转到180度,速度为9.
Z轴转到30度,速度为10
"""
move_to_absolute_position(vv=9, ww=10, Y=180, Z=30, device="COM16")

# 关联键盘事件和控制函数
keyboard.on_press_key("up", lambda _: turn_up(device="COM16"))
keyboard.on_press_key("down", lambda _: turn_down(device="COM16"))
keyboard.on_press_key("left", lambda _: turn_left(device="COM16"))
keyboard.on_press_key("right", lambda _: turn_right(device="COM16"))
keyboard.on_press_key("enter", lambda _: move_home(device="COM16"))
keyboard.on_press_key("space", lambda _: turn_stop(device="COM16"))
# 按下数字1则转动到水平最左,垂直最下,可以根据自己需要多预设几个目标角度。
keyboard.on_press_key("1", lambda _: move_to_absolute_position(vv=10, ww=10, Y=180, Z=-30, device="COM16"))

# 让脚本保持运行状态以捕获事件
keyboard.wait("esc") # 按 'esc' 键退出

信号机制

  • 当收到左转信号时,摄像头会持续左转,直到到达限位位置或接收到新指令。

  • 如果想要提前结束左转,可以在发送左转信号一定时间后发送停止指令,摄像头收到停止指令时会停止。

  • 每个云台旋转操作会持续一定时间,如果在旋转期间收到其他指令,会终止旧指令,执行当前指令。

逻辑代码

通常在 Windows 系统上,串口名称通常是 COMx(如 COM1、COM2),而在 Linux 系统上通常是/dev/ttyUSBx(如/dev/ttyUSB0)。

usbcamera.py
#!/usr/bin/env python3
# coding:utf-8

import serial
import serial.tools.list_ports
import time

# VISCA命令集
commands = {
"stop": "81010601{vv}{ww}0303FF",
"left": "81010601{vv}{ww}0103FF",
"right": "81010601{vv}{ww}0203FF",
"up": "81010601{vv}{ww}0301FF",
"down": "81010601{vv}{ww}0302FF",
"upleft": "81010601{vv}{ww}0101FF",
"upright": "81010601{vv}{ww}0201FF",
"downleft": "81010601{vv}{ww}0102FF",
"downright": "81010601{vv}{ww}0202FF",
"absolute_position": "81010602{vv}{ww}{Y}{Z}FF",
"relative_position": "81010603{vv}{ww}{Y}{Z}FF",
"home": "81010604FF",
"reset": "81010605FF",
}


def send_visca_command(command, device):
"""
通过串口向摄像机发送VISCA命令。

参数:
command (str): 要发送的VISCA命令,格式为十六进制字符串。

返回:
response (bytes): 从摄像机接收到的响应。
"""
try:
ser = serial.Serial(device, 9600, timeout=1) # 初始化串口
command_bytes = bytearray.fromhex(command) # 将命令转换为字节
ser.write(command_bytes) # 发送命令
response = ser.read_all() # 读取响应
ser.close() # 关闭串口
return response
except:
ports_list = list(serial.tools.list_ports.comports())
if len(ports_list) <= 0:
print("未发现端口")
else:
for comport in ports_list:
if "USB" in str(comport):
print("发现USB端口:", comport.device, comport.description)


def calculate_pan_speed_bytes(pan_speed_value):
"""
计算轴(旋转)的位置字节。

参数:
pan_speed_value (int): 速度值,0-16

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""

pan_speed_value = max(0, min(pan_speed_value, 16)) # 限制取值范围

# 转为2位16进制
return f"{pan_speed_value:02X}"


def calculate_pan_position_bytes(pan_pos_value, axis_type):
"""
计算轴(旋转)的位置字节。

参数:
pan_pos_value (int): 位置值,
axis_type (str): 轴的类型 ('y' or 'Y' for Y-axis, others for Z-axis)

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""
if axis_type.lower() == "y":
pan_pos_value = max(-177.5, min(pan_pos_value, 177.5)) # 限制取值范围
else:
pan_pos_value = max(-21, min(pan_pos_value, 21)) # 限制取值范围

pan_pos_value = int(pan_pos_value * 25) # 将角度转换为步长
pan_direction = "-" if pan_pos_value < 0 else "+" # 设定旋转方向
pan_pos_value = abs(pan_pos_value) # 取绝对值

HEX_VALUES = [4096, 256, 16, 1] # 定义常量

pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
if pan_direction == "+":
pan_pos_ints.append(pan_pos_value // value)
else: # 异或操作
pan_pos_ints.append((pan_pos_value // value)^ 0xF)
if i == 3 : # 最后一个数字,取反后加1
pan_pos_ints[-1] = pan_pos_ints[-1]+1
pan_pos_value %= value

# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)


def create_command(command_key, vv=10, ww=10, Y=None, Z=None):
"""
创建VISCA命令。

参数:
command_key (str): 命令键名。
vv (str): 水平方向速度,取值范围为0-16
ww (str): 垂直方向速度,取值范围为0-16
Y (str): 控制水平旋转的位置。
Z (str): 控制垂直旋转的位置。

返回:
command (str): 格式化后的VISCA命令字符串。

异常:
ValueError: 当命令需要Y和Z参数时,若未提供,则抛出异常。
"""
if command_key in ["home", "reset"]:
return commands[command_key]
if command_key in ["absolute_position", "relative_position"]:
if Y is None or Z is None:
raise ValueError("Y和Z为位置命令,必须提供")
return commands[command_key].format(
vv=calculate_pan_speed_bytes(vv),
ww=calculate_pan_speed_bytes(ww),
Y=calculate_pan_position_bytes(Y, "y"),
Z=calculate_pan_position_bytes(Z, "z"),
)

return commands[command_key].format(
vv=calculate_pan_speed_bytes(vv),
ww=calculate_pan_speed_bytes(ww),
)


# 控制函数示例
def turn_stop(vv=0, ww=0, device="/dev/ttyUSB0"):
return send_visca_command(create_command("stop", vv, ww), device)


def turn_left(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("left", vv, ww), device)


def turn_right(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("right", vv, ww), device)


def turn_up(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("up", vv, ww), device)


def turn_down(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("down", vv, ww), device)


def move_home(device="/dev/ttyUSB0"):
return send_visca_command(create_command("home"), device)


def move_to_absolute_position(vv=10, ww=10, Y=0, Z=0, device="/dev/ttyUSB0"):
return send_visca_command(create_command("absolute_position", vv, ww, Y, Z), device)

后话

硬件相比软件来说,资料比较少,所以编写过程主要靠经验。

猜测轴旋转的角度和 4 个参数对应关系是最有意思的过程,有趣的功能背后全是数学。

用Flask搭建屏幕共享工具

· 7 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结
  1. 当你的电脑无法通过视频线连接到电视机,可能是由于线缆长度不足或者接口不兼容,而你的电视机恰好支持浏览器功能。

  2. 当你在外面参加培训或交流活动,现场只提供了一个WiFi网络。大家刚刚熟悉,马上就要开始屏幕分享,你需要一个快速的方式让大家都能看到你的屏幕。

这个时候,你就需要一个工具来捕获和分享你的屏幕和音频(包括设备音频和麦克风输入),并通过网页形式与他人共享。这样,观众无需下载任何会议软件,仅需打开浏览器即可观看。

当然有时候你需要提供必要的文件,比如代码,文档等。所以这个程序还允许你上传与下载文件。上传的文件会保存在当前目录下的upload文件夹中,你也可以从upload文件夹中下载文件。

例如一个伙伴上传了文件,另一个伙伴可以从这个地址下载文件。这个项目我做了一次较大的调整,原本是用python编写的屏幕和麦克风捕获,为了跨平台使用的同时保持代码的简洁,使用了JS重构了代码,使用通用的浏览器获取本地权限的方式,希望这个项目对你有帮助。

安装依赖

pip install flask

项目目录结构

项目下的文件夹:

templates 文件夹中包含一个 upload.html 文件,用于页面展示。

uploads 文件夹用于保存上传的文件。

static 文件夹用于放置js代码逻辑:检测摄像头是否存在。

your_project/
├── templates/
│ └── upload.html
│ └── stream.html
├── uploads/
├── static/
└── app.py

核心代码

替换上传表单的样式

upload.html
<!doctype html>
<html lang="zh-CN">
<head>
<style>
/* 美化上传按钮 */
.file-upload {
position: relative;
display: inline-block;
overflow: hidden;
cursor: pointer;
background-color: #ff6600;
color: #fff;
padding: 10px 20px;
border: none;
border-radius: 5px;
transition: background-color 0.3s;
font-size: 16px;
}
.file-upload:hover {
background-color: #e65c00;
}
.file-upload input[type="file"] {
position: absolute;
font-size: 18px;
right: 0;
top: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
/* 显示文件名 */
.file-name {
margin-top: 10px;
font-size: 14px;
color: #f0f0f0;
}
input[type="submit"] {
background-color: #ff6600;
color: #fff;
border: none;
padding: 10px 20px;
cursor: pointer;
border-radius: 5px;
transition: background-color 0.3s;
font-size: 16px;
margin-top: 10px;
}
input[type="submit"]:hover {
background-color: #e65c00;
}
</style>
<!-- 引入设备检测的 JavaScript 文件 -->
<script src="{{ url_for('static', filename='js/deviceDetection.js') }}"></script>
<script>
// 显示选中的文件名
function displayFileName(input) {
const fileNameElement = document.getElementById('file-name');
if (input.files.length > 0) {
fileNameElement.textContent = `已选择文件: ${input.files[0].name}`;
} else {
fileNameElement.textContent = '';
}
}
// 显示上传表单
window.onload = function() {
const uploadContainer = document.getElementById('upload-form-container');
const uploadForm = uploadContainer.querySelector('form');
uploadForm.style.display = 'flex';
}
</script>
</head>
<body>
<h2>上传新文件</h2>
<div id="upload-form-container">
<form method="post" enctype="multipart/form-data">
<label class="file-upload">
上传文件
<input type="file" name="file" required accept="image/png, image/jpeg, image/gif, image/jpg" onchange="displayFileName(this)">
</label>
<div class="file-name" id="file-name"></div>
<input type="submit" value="确认">
</form>
</div>
</body>
</html>

映射路由与文件允许范围代码

app.py
# app.py

from flask import Flask, request, redirect, url_for, send_from_directory, render_template
import os

app = Flask(__name__)

# 设置上传文件夹
UPLOAD_FOLDER = 'uploads'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

# 允许的文件扩展名
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/')
def index():
return render_template('index.html')

@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
# 检查是否有文件上传
if 'file' not in request.files:
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return redirect(request.url)
if file and allowed_file(file.filename):
filename = file.filename
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return redirect(url_for('upload_file'))

# 显示上传表单和文件列表
files = os.listdir(app.config['UPLOAD_FOLDER'])
return render_template('upload.html', files=files)

@app.route('/uploads/<filename>')
def download_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)

# 新增的路由:设备选择页面
@app.route('/media_selection')
def media_selection():
return render_template('media_selection.html')

# 新增的路由:媒体接口页面
@app.route('/media_interface')
def media_interface():
# 获取用户选择的参数
screen = request.args.get('screen') == 'on'
camera = request.args.get('camera') == 'on'
sound = request.args.get('sound') == 'on'

# 至少选择一项已经在前端验证,这里不再重复
return render_template('media_interface.html', screen=screen, camera=camera, sound=sound)

if __name__ == '__main__':
app.run(debug=True,host='0.0.0.0',port=8080)

使用浏览器接口设备检测

deviceDetection.js

// 检测摄像头是否存在
function hasCamera() {
return navigator.mediaDevices.enumerateDevices()
.then(devices => {
return devices.some(device => device.kind === 'videoinput');
})
.catch(err => {
console.error('Error detecting camera:', err);
return false;
});
}

// 检测麦克风是否存在
function hasMicrophone() {
return navigator.mediaDevices.enumerateDevices()
.then(devices => {
return devices.some(device => device.kind === 'audioinput');
})
.catch(err => {
console.error('Error detecting microphone:', err);
return false;
});
}

// 显示检测结果
function displayDeviceStatus() {
hasCamera().then(camera => {
const cameraStatus = document.getElementById('camera-status');
if (camera) {
cameraStatus.textContent = '摄像头: 已连接';
cameraStatus.style.color = 'green';
} else {
cameraStatus.textContent = '摄像头: 未连接';
cameraStatus.style.color = 'red';
}
});

hasMicrophone().then(microphone => {
const microphoneStatus = document.getElementById('microphone-status');
if (microphone) {
microphoneStatus.textContent = '麦克风: 已连接';
microphoneStatus.style.color = 'green';
} else {
microphoneStatus.textContent = '麦克风: 未连接';
microphoneStatus.style.color = 'red';
}
});
}

// 当页面加载完成后执行
window.addEventListener('load', displayDeviceStatus);

function detectDevices() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
console.log('浏览器不支持设备检测');
return;
}

navigator.mediaDevices.enumerateDevices()
.then(function(devices) {
let hasCamera = false;
let hasMicrophone = false;

devices.forEach(function(device) {
if (device.kind === 'videoinput') {
hasCamera = true;
}
if (device.kind === 'audioinput') {
hasMicrophone = true;
}
});

const cameraCheckbox = document.getElementById('camera-checkbox');
const soundCheckbox = document.getElementById('sound-checkbox');

if (hasCamera) {
cameraCheckbox.disabled = false;
} else {
cameraCheckbox.disabled = true;
cameraCheckbox.checked = false;
}

if (hasMicrophone) {
soundCheckbox.disabled = false;
} else {
soundCheckbox.disabled = true;
soundCheckbox.checked = false;
}
})
.catch(function(err) {
console.error('设备检测时出错:', err);
});
}

// 当页面内容加载完毕后执行设备检测
document.addEventListener('DOMContentLoaded', detectDevices);

总结

浏览器是现在网络应用的标配,使用浏览器接口获取设备权限,可以实现简单优雅的跨平台使用。每种语言都有自己的优势,Python的生态强大,JS的灵活性高,两者结合可以实现很多有趣的应用。

自制智能家居流程

· 26 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

如果你想要实现这样一个功能:当窗外开始下雨,窗户自动关闭

如果你想知道解决方案,可以直接跳到最后一部分。

我们仅看前半部分,那么你需要:检测窗外是否有雨水,并把这个信息传递给窗户控制器

这个过程中,你需要:

  • 传感器:检测窗外是否有雨水/或者获取网络上的天气信息
  • 单片机主控板:可以接收传感器的数据,并收发网络请求
  • 执行器:窗户控制器,可以接收单片机的指令,控制窗户的开关
  • 供电:保证传感器和主控板的正常工作

这个过程你需要知道以下内容:单片机如何烧录程序、传感器如何连接、如何获取传感器数据、如何发送网络数据、如何制作外壳(例如 3D 打印)。

传感器基础知识

  • 负极表示符号: - / G / Gnd / 黑色
  • 正极表示符号:+ / V / Vcc / 红色
  • 信号管脚: S 可以表示信号,根据传感器的不同,参数范围是 0-1023 或 0 1
  • 模拟量信号管脚表示符号: A 参数范围在 0-1023
  • 数字量信号管脚表示符号:D 参数为 0 或 1
tip

如果标识与颜色发生冲突,一般以标识为准:例如接口上写着 V,但连接线颜色为黑,一般当作正极处理。

对于绝大多数单片机来说,当单片机通电时,所有的引脚都带电(含信号管脚)。因此传感器的正负极理论上可以任意连接,只需保证信号管脚连接指定的即可。

A 口的功能比 D 口更加强大,因为 A 口可以接收模拟信号,而 D 口只能接收数字信号。因此部分传感器 D 接 A 也可以正常读数。

info

信号管脚可读可写。

如果某个信号管脚未接任何传感器(即:悬空状态),亦可读出高/低电平,悬空状态引脚的电平是不稳定的,易受其他接口影响跳变(单片机常常会有端口冲突的概念,即某个引脚的某个功能会影响其他引脚)。为了解决这一问题,推荐使用上拉和下拉。

  • 如果接线方式是GND-大电阻(10KΩ)-信号管脚,则信号管脚的电压会被拉低,此时读数会固定到0。称为下拉。
  • 如果接线方式是VCC-大电阻(10KΩ)-信号管脚,则信号管脚的电压会被拉高,此时读数会固定到1。称为上拉。

接上传感器后,信号管脚的电压会随着传感器的变化而变化(因为大部分的传感器工作原理都是敏感电阻。)。

把电阻换成蜂鸣器,写入数值(即控制信号管脚通电的强度),则蜂鸣器会根据写入数值发出不同的声音。

两管脚

常见的有扬声器、电机(俗称马达)。

这类设备因为较为特殊,一般有专门的接口,或者占用 2 个信号端口,通过信号的变化来工作。

马达往往需要更大的工作电压,如果没有专门的连接口,有可能需要在板上使用跳针切换工作电压。

三管脚

这类传感器数量最多,往往由 GVA 或者 GVD 组成。

使用时,正负极与单片机正负极连接,信号线与板上标注的 A 或 D 进行连接。

四管脚

四管脚传感器分为很多类

特殊接口的,譬如:人体温度传感器

正负极与信号口一般都专门对应的位置供连接。

双信号接口的,譬如:超声波

一般有四个接口:GVTE,其中 GV 正常连接,T 和 E 都接在信号管脚上。

同时接收 AD 的,譬如:烟雾传感器

一般有四个接口:GVAD,其中 GV 正常连接,D 表示有没有烟雾,A 表示烟雾浓度。分别接在对应的信号管脚即可。

五管脚

譬如:摇杆传感器

一般五个接口为:GVXYB,其中 GV 正常连接,X 表示 X 轴(是模拟量接 A)、Y 表示 Y 轴(是模拟量接 A)、B 表示按钮(是数字量接 D)

其他特殊类

其他特殊的传感器一般有特殊接口,譬如:摄像头、屏幕等。

根据说明接入即可。

传感器一般原理

传感器是一种信息转换装置,大部分传感器的工作原理是将非电量(力、速、声、光、热、磁等)通过敏感原件(光敏材料、热敏材料)感受,再通过转换原件转换为电参量(例如电压)。最后通过转换电路把电参量通过放大、过滤等方式转化为线性可读的电量。

info

传感器的应用例子:

山地车在出发前需要检查轮胎气压,需要车手用手按在轮胎上感受气压,需要一定的经验。使用传感器来检测轮胎气压,无需经验,即可获得更加客观的结果

桥梁上需要很多螺丝,且使用一段时间后螺丝会松动,需要加固。传统方式是人为设定一个固定的检查间隔时间,逐一检查螺丝是否拧紧。变色螺栓就是一种传感器,当螺丝压力较小(拧的不紧)时,螺栓颜色较浅,当螺丝压力较大(拧的较紧)时,螺栓颜色较深。极大的提高了检查效率

  • 敏感材料是传感器的灵魂,主要是材料学的范畴。
  • 转换原件和转换电路需要一定的电路设计能力和算法能力。主要是嵌入式开发的范畴。
info

不是所有的传感器的都是这样的组成结构,譬如:中国的称。使用结构来测量质量。

不过随着我们需要测定的量要求越来越精准:例如精确到0.1g,传统的结构称就难以胜任了。

通过找到某种压敏材料,可以在不同压力下呈现不同的电阻,我们可以测定其电阻来反推出质量。

这种方式可以使我们测量精度提高的同时,量程也更大(测量的范围)。

不过有的压敏材料在极端高温和低温下电阻也会发生较大变化,不同的传感器有不同的工作环境

总的发展趋势是找到某个的敏感材料或者多个敏感材料组合然后测定。

发展趋势

阶段特点代表性传感器
结构传感器通过机械结构与基本常识,经久耐用,基本上都是转换式传感器(不需要额外电源)液压称、中国称
物性传感器通过结合光敏材料、热敏材料等敏感元件的物理性质测量,基本上都是有源式(需要先供电才能工作)光敏传感器、热敏传感器
集成芯片传感器在前一代的基础上集成迷你芯片,进行滤波、增强等基本功能RGB数码相机光敏传感器
智能传感器在前一代基础上集成了软件算法,让传感器在亮度、色彩上表现更好RGBW数码相机光敏传感器

传感器的分类

按照工作原理分类

生物传感器:利用生物酶、抗体、组织、细胞、微生物、生物等。譬如云南洱海的海菜花对水质要求极高,如果死亡则说明水质较差,可用于辅助测量水质。

化学传感器:利用各种化学吸附、化学反应的方式获取信息的,例如某些溶液测酸碱性。

物理传感器:数量最多,细分为结构性、物性型,光敏、声敏、热敏等等,不计其数。

按照工作特性分类

能量控制型传感器:需要通过外部供能的传感器,例如电子称需要有电源才能工作。

能量转换型传感器:由被测物体供能,例如:水银式人体温度计,通过将人的体温转换为水银的膨胀读数,无需供能。

同个目的可能会有多种传感器:例如测距离有红外传感器、毫米波、超声波。

同个传感器也可能有不同目的:例如二氧化碳气体传感器也可以用于测量空气质量PM2.5。(因为其工作原理是检测空气中吸收红外的物质的量,二氧化碳和PM都吸收红外)

传感器的特性

静态特性指标说明
灵敏度传感器输出与输入的比值,值越大,越灵敏
线性度传感器输出与输入的线性程度
迟滞性传感器在正反向输入时,输出不一致的程度
重复性传感器在相同输入时,输出不一致的程度
分辨率传感器能够分辨的最小输入变化量
稳定性传感器在长时间工作时,输出不一致的程度
漂移传感器数值整体偏移的程度
动态特性指标说明
响应度传感器对被测数据能否紧密跟随。

传感器的误差

没有人在实验室环境下使用传感器,专业的传感器厂商会在不同温度、湿度、光照等环境下进行测试,并给出误差范围,不同的环境误差有大有小,被称为总误差带。

不确定传感器是否准确时,则需要校准,以温度传感器为例,需要在实验室构建三相点并保持:液态水、固态冰、气态水蒸气。此时温度为 0.01℃,此时读数为 0.01℃,则认为传感器准确。

但是这样的方式费时费力,因为在实验室中校准的传感器作为“标准传感器”,将待检测的传感器与标准传感器进行对比,以此评判待检测传感器的准确性。通常会测量:零点、满量程、中间点。

定期校准是保证传感器准确性的重要手段,也能避免重大事故的发生。

与单片机通信

单片机(Microcontroller Unit,简称 MCU)是指一个微型计算机集成在一个单独的微型芯片中,它包括处理器(CPU)、内存(通常包括 RAM 和 ROM)、以及各种输入/输出(I/O)接口等在内的完整计算设备。

单片机设计用于嵌入式应用,通常在硬件设备中执行特定任务。例如,你的电视遥控器可能就是由一个单片机控制的,它可以接收你的输入,然后发送相应的信号到电视上。其他常见的单片机应用包括玩具、家用电器、医疗设备、汽车等。

有的单片机可以使用完整的 Python,譬如华硕的 thinker edge R、部分树莓派,有的 Arduino 板子、ESP32 等只能使用简化的 MicroPython。

tip

当我们希望通过 windows 计算机的 USB 接口和单片机设备进行串口通信时,需要将 USB 接口转换为标准的串行接口,这个过程需要一个介于 USB 和串口之间的翻译,我下面的驱动就是这个翻译。

信号线的损坏的表现除了信号中断无法传输,也可能导致信号到达时间提前或滞后。

并非所有接口一样的数据线都具有相同功能:有的线仅能慢速充电,有的线可以快速充电,有的线只能充电不能传输数据。确保专线专用。

在搜索引擎中搜 CH341SER 驱动

过程中所有弹窗有下一步点下一步,有确认点确认

在编程软件中识别单片机

常用的编程软件有:Scratch、Mixly、Mixly2、MaixPy 等。

有的支持图形化编程与代码编程,有的需要仅支持代码编程。

下载对应的编程软件后,打开软件。

选择主控这个环节,不同软件的选择方式不同。

tip
  • Vegeta 这样基于 Scratch 的编程软件,需要从左下角选择添加对应的主控型号。

  • Mixly 从右下角,串口旁的下拉菜单选择对应的主控型号。

  • Mixly2 从登录菜单中主控型号后,进入代码编辑页,右上角选择串口旁可以选择更加详细的主控型号。

  • MaixPy 从上方的工具页面中选择开发板型号。

通过连接线连接电脑与单片机。此时可能会有多种情况:

  1. 会提示:有串口连接,并弹出且仅弹出 1 个串口。
  2. 识别计算机上的所有串口,需要自己选择(可以通过反复插拔确认新增的端口号)。
  3. 不弹出任何串口,需要主控通电启动后才识别串口。
  4. 也有的串口时有时无,此时可以考虑:连接线接触不良(更换连接线),或者是主控/USB 电压不稳定——常见于学校机房(主控或电脑独立供电)
  5. 还有的默认的波特率需要调整,否则无法识别传输信号。

连接成功后记得初始化固件,使其恢复到软件对应的固件版本。类似 Android 手机的刷机/恢复出厂设置。

单片机编程

这里的传感器特指狭义的通过半导体检测物理量的传感器,如温度传感器、湿度传感器、光敏传感器等。这些传感器的特点是:输出信号是数字/模拟信号。

数字量传感器的输出信号是数字信号,他的特点是只 返回/发出 两种状态:高电平和低电平。对应在代码中是 1 和 0 。

  • 如声音传感器如果是数字量传感器,当检测到声音时输出高电平,否则输出低电平。

  • 如小灯,输出高电平表示亮,输出低电平表示灭。

模拟量传感器的输出信号是模拟信号,他的特点是输出的电压值是连续变化的。对应在代码中是 0-1023(通常如此,并非绝对) 。

  • 还是以声音传感器为例,如果是模拟量传感器,当检测到声音时输出的电压值会随着声音的大小而变化。

  • 还是以小灯为例,输出最大值表示最亮,输出最小值表示最暗,亮度会随输出的电压值变化。

有的传感器同时支持数字量和模拟量输出,有的不是。

因此,对于不确认的传感器,我们一般先假设传感器是模拟量传感器,如果不是,再当作数据量处理。

单片机的运行内存往往很小,当创建一个非常复杂的代码时,有可能会导致内存问题,对应各种报错都有可能。

模拟量传感器读取

下面以 32 接口为例

import machine
adc32 = machine.ADC(machine.Pin(32))
while True:
print(adc32.read_u16())

模拟量传感器输出

下面以 0 接口为例

import machine
pwm0 = machine.PWM(machine.Pin(0))
pwm0.duty_u16(0)
pwm0.duty_u16(255)

然而,有些动力类传感器需要设置占空比:占空比主要与脉冲宽度调制(Pulse Width Modulation,PWM)相关,它是一种模拟信号的数字化表示方法。在 PWM 中,一个周期内的高电平时间占总周期时间的比例就是占空比。

传感器的输出类型可以有多种,包括模拟电压、模拟电流、数字信号(如 I2C、SPI、UART 等)、频率、PWM 等。只有在使用 PWM 输出的传感器时,才需要设置占空比。例如,一些伺服电机会使用 PWM 信号来控制其位置,这时就需要设置占空比。

对于其他类型的传感器,如模拟电压输出的传感器、数字信号输出的传感器等,就不需要设置占空比。这些传感器的输出通常是连续的或者是特定的数字信号,不涉及到占空比的概念。

from machine import Pin, PWM
import time

# 创建一个PWM对象
pwm = PWM(Pin(2))

# 设置PWM信号的频率为50Hz
# 每秒50个周期,所以每个周期的时间是1秒/50,即20ms。
pwm.freq(50)

# 一般来说,当PWM信号的高电平时间为1ms时,舵机转到0度;
# 当高电平时间为2ms时,舵机转到最大角度。
# 这个范围内的其他高电平时间对应的是0到180度之间的其他角度。

# 转到0度()
pwm.duty(52) # 1ms / 20ms * 1024 = 51.2 取不低于最小值的整:52
time.sleep(1) # 等待一段时间让舵机转到指定位置

# 转到180度
pwm.duty(102) # 2ms / 20ms * 1024 = 102.4 取不高于最大值的整:102
time.sleep(1) # 等待一段时间让舵机转到指定位置

# 关闭PWM
pwm.deinit()

数字量传感器读取

import machine

pin0 = machine.Pin(0, machine.Pin.IN)
while True:
print(pin0.value())

数字量传感器输出

import machine
import time

pin13 = machine.Pin(13, machine.Pin.OUT)
while True:
pin13.value(0)
time.sleep_ms(50)
pin13.value(1)
time.sleep_ms(50)

单片机网络通信

获取天气

心知天气 API 分为免费版、付费版等多个坂本,不同的版本返回的数据数量有所不同。

免费版仅返回三种基本数据,付费版可以返回多种数据。mixly 中默认的 KEY 为高级付费版,可返回全部数据。

数据返回的格式为字典,因此可以通过如下方式进行解包,下面的代码提供了部分数据解包的方法。

需要注意的是,该功能为联网功能,需要在联网环境下使用,确保 wifi 名和密码正确。

import mixiot
import machine
import seniverse_api


mixiot.wlan_connect('wifiname','wifipassword')
print(seniverse_api.weather_now('SGJl0ExVN-4j27msR','北京'))

onenet 物联网传输数据至云端

onenet 物联网是中国移动推出的物联网交互平台,主要面向一般开发者,因此 AIbox 这款设备可以使用 onenet 物联网平台进行数据传输。

相比于 mixio 这样专注于单片机的物联网平台来说,onenet 的文档与接口可能会频繁变动,如有出入以官网教程为准。

onenet 物联网平台网址:https://open.iot.10086.cn/doc/

文档中提供了传输文本与文件 2 种方式

import json
import asyncio
import websockets
from uuid import uuid4

# 音频文件测试路径。
audioFile = "test.mp3"
# 使用自己产品Id和apikey替换下列参数。
productId = "x"
apikey = "x"

#发送文本请求
async def textRequest(ws):
content = {
"aiType":"dm",
"topic": 'nlu.input.text',
"recordId": uuid4().hex,
"refText": "测试" #修改文本请求的输入
}
try:
await ws.send(json.dumps(content))
resp = await ws.recv()
print(resp)
except websockets.exceptions.ConnectionClosed as exp:
print(exp)

#发送音频请求
async def audioRequest(ws):
content = {
"aiType": "dm", #可选dm/asr, dm获取对话结果,asr只获取asr结果
"topic": "recorder.stream.start",
"recordId": uuid4().hex,
"audio": {
"audioType": "mp3", #修改为测试文件的类型
"sampleRate": 16000, #修改为测试文件的sampleRate
"channel": 1, #修改为测试文件的channel
"sampleBytes": 2 #修改为测试文件的sampleBytes
},
"asrParams": {
"realBack": True, #实时返回asr结果
"enableVAD": True, #启动VAD
"enablePunctuation": True, #返回结果是否带拼音
"enableTone": True, #返回结果是否带声调
"enableConfidence": True, #返回结果是否带置信度
"enableNumberConvert": True, #返回结果是否进行数字转换
},
}
try:
#发送文本消息
await ws.send(json.dumps(content))
# 发送音频消息
with open(audioFile, 'rb') as f:
while True:
chunk = f.read(400) #wav buffsize=3200 其他的400
if not chunk:
await ws.send(bytes("", encoding="utf-8"))
break
print(len(chunk))
await ws.send(chunk)
async for message in ws:
print(message)
resp = json.loads(message)
if 'dm' in resp:
break
except websockets.exceptions.ConnectionClosed as exp:
print(exp)
ws.close()

async def dds_demo():
url = f"ws://botai-dsg.and-home.cn:4443/dsg/v1/prod?productId={productId}&apikey={apikey}"
print(url)
async with websockets.connect(url) as websocket:
#await textRequest(websocket) #发送文本请求
await audioRequest(websocket) #发送音频请求
asyncio.get_event_loop().run_until_complete(dds_demo())

后话

最后,通过大量的学习和试错打样,你发现米家雨水传感器,淘宝 46 包邮,搞活动更便宜,这大概是你最后的选择。