Files
Uni-Lab-OS/docs/developer_guide/device_driver.md
Junhan Chang c78ac482d8 Initial commit
2025-04-17 15:19:47 +08:00

330 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 设备 Driver 开发
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
## 有串口字符串指令集文档的设备Python 串口通信(常见 RS485, RS232, USB
开发方式:对照厂家给出的指令集文档,实现相应发送指令字符串的 python 函数。可参考 [注射泵串口驱动样例](https://github.com/TablewareBox/runze-syringe-pump)
## 常见工业通信协议ModbusRTU, TCP
Modbus 与 RS485、RS232 不一样的地方在于会有更多直接寄存器的读写以及涉及字节序转换Big Endian, Little Endian
Uni-Lab 开发团队在仓库中提供了3个样例
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
* 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
****
## 其他工业通信协议CANopen, Ethernet, OPCUA...
【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto
**pywinauto**是一个 Python 库用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作
* **win32**后端适用于大多数Windows应用程序使用native Win32 API。pywinauto_recorder默认使用win32后端
* **uia**后端基于Microsoft UI Automation适用于较新的应用程序特别是基于WPF或UWP的应用程序。在win10上会有更全的目录有的窗口win32会识别不到
### windows平台安装pywinauto和pywinauto_recorder
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
cd到对应目录执行安装
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
![pywinauto_install](image/device_driver/pywinauto_install.png)
windows平台测试 python pywinauto_recorder.py退出使用两次ctrl+alt+r取消选中关闭命令提示符。
### 计算器例子
你可以先打开windows的计算器然后在ilab的环境中运行下面的代码片段可观察到得到结果通过这一案例你需要掌握的pywinauto用法
* 连接到指定进程
* 利用dump_tree查找需要的窗口
* 获取某个位置的信息
* 模拟点击
* 模拟输入
#### 代码学习
```Python
from pywinauto import Application
import time
from pywinauto.findwindows import ElementAmbiguousError
# 启动计算器应用
app = Application(backend='uia').connect(title="计算器")
# 连接到计算器窗口
window = app["计算器Dialog0"]
# 打印窗口控件树结构,帮助定位控件
window.dump_tree(depth=3)
# 详细输出
"""
Dialog - '计算器' (L-419, T773, R-73, B1287)
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
child_window(title="计算器", control_type="Window")
|
| Dialog - '计算器' (L-269, T774, R-81, B806)
| ['计算器Dialog2', 'Dialog2', '计算器2']
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
| |
| | Menu - '系统' (L0, T0, R0, B0)
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
| |
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
| |
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
| |
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
| Dialog - '计算器' (L-411, T774, R-81, B1279)
| ['计算器Dialog3', 'Dialog3', '计算器3']
| child_window(title="计算器", control_type="Window")
| |
| | Static - '计算器' (L-363, T782, R-327, B798)
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
| |
| | Custom - '' (L-411, T806, R-81, B1279)
| | ['Custom', '计算器Custom']
| | child_window(auto_id="NavView", control_type="Custom")
|
| Pane - '' (L-411, T806, R-81, B1279)
| ['Pane', '计算器Pane']
"""
# 通过控件路径可参考下一小节路径可以发现本文档第48-50行是我们需要定位的控件
# 本文档第16-18行为其自身即depth=1我们要定位的第48-50行是depth=3的控件从树来一级一级定位即可
# PyWinAuto为我们提供了非常便捷的取窗口方式在每3行dump的内容中第三行就是从上一级取出当前窗口的方式直接复制即可
# 这里注意到使用title="计算器", control_type="Window"进行匹配会出现两个因此指定found_index=1
sub_window = window.child_window(title="计算器", control_type="Window", found_index=1)
target_window = sub_window.child_window(auto_id="NavView", control_type="Custom")
target_window.dump_tree(depth=3)
"""
Custom - '' (L-411, T806, R-81, B1279)
['标准Custom', 'Custom']
child_window(auto_id="NavView", control_type="Custom")
|
| Button - '打开导航' (L-407, T812, R-367, B848)
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
| |
| | Static - '' (L0, T0, R0, B0)
| | ['Static', 'Static0', 'Static1']
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
| GroupBox - '' (L-411, T814, R-81, B1275)
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
| |
| | Static - '表达式为 ' (L0, T0, R0, B0)
| | ['表达式为 ', 'Static2', '表达式为 Static']
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
| |
| | Static - '显示为 0' (L-411, T875, R-81, B947)
| | ['显示为 0Static', '显示为 0', 'Static3']
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
| |
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
| |
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
| |
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
| |
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
| |
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
| |
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
| |
| | Button - '正负' (L-407, T1228, R-328, B1275)
| | ['Button32', '正负Button', '正负']
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
| Static - '标准' (L-363, T815, R-322, B842)
| ['标准', '标准Static', 'Static4']
| child_window(title="标准", auto_id="Header", control_type="Text")
|
| Button - '始终置顶' (L-312, T814, R-280, B846)
| ['始终置顶Button', '始终置顶', 'Button33']
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
"""
# 观察到GroupBox控件并没有提供默认的child_window而list中的identifier均可作为best_match来索引
# ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1'] 这里选用第0项
group_box = target_window.child_window(best_match="标准GroupBox")
numpad = group_box.child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
numpad.dump_tree(depth=2)
"""
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
| Button - '一' (L-334, T1450, R-255, B1498)
| ['一Button', 'Button2', '一']
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
| Button - '二' (L-253, T1450, R-174, B1498)
| ['Button3', '二', '二Button']
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
| Button - '三' (L-172, T1450, R-93, B1498)
| ['Button4', '三', '三Button']
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
| Button - '四' (L-334, T1400, R-255, B1448)
| ['四', 'Button5', '四Button']
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
| Button - '五' (L-253, T1400, R-174, B1448)
| ['Button6', '五Button', '五']
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
| Button - '六' (L-172, T1400, R-93, B1448)
| ['六Button', 'Button7', '六']
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
| Button - '七' (L-334, T1350, R-255, B1398)
| ['Button8', '七Button', '七']
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
| Button - '八' (L-253, T1350, R-174, B1398)
| ['八', 'Button9', '八Button']
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
| Button - '九' (L-172, T1350, R-93, B1398)
| ['Button10', '九', '九Button']
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
"""
# 获取按钮 '9'
button_9 = numpad.child_window(title="九", auto_id="num9Button", control_type="Button")
# 利用相同的办法,我们也可以找到增加和等于号的控件
std_calc_panel = group_box.child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
equal_operation = std_calc_panel.child_window(title="等于", auto_id="equalButton", control_type="Button")
# 模拟点击按钮 '9'
button_9.click_input()
# 键入https://github.com/pywinauto/pywinauto/blob/atspi/pywinauto/windows/keyboard.py
# 模拟输入 '加号' 和 数字9
window.type_keys("{VK_ADD}9")
# 等于
equal_operation.click_input()
# 获取计算结果文本(显示在计算器窗口的文本框中)
result = group_box.child_window(auto_id="CalculatorResults", control_type="Text").window_text()
print(f"计算结果:{result[4:]}") # 应当得到结果18
```
#### 依据像素判定状态
```Python
# 有时你需要根据窗口的颜色判断是否可用是否正在运行可以使用pyautogui来实现这一功能
# pyautogui需要对应在环境中进行安装
point_x = int(control_view.rectangle().left + control_view.rectangle().width() * 0.15)
point_y = 15 + control_view.rectangle().top
r, g, b = pyautogui.pixel(point_x, point_y)
```
### pywinauto_recorder
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时可以省去仅用pywinauto的一些查找UI步骤。
#### 运行尝试
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
例如我们这里先启动一个windows自带的计算器软件
![calculator_01](image/device_driver/calculator_01.png)
在录制状态下点击数字键盘的“9”随后退出录制得到下图运行的文件。
![calculator_02](image/device_driver/calculator_02.png)
```Python
# encoding: utf-8
from pywinauto_recorder.player import *
with UIPath(u"计算器||Window"):
with UIPath(u"计算器||Window->||Custom->||Group->数字键盘||Group"):
click(u"九||Button")
```
执行该python脚本可以观察到新开启的计算器被点击了数字9
![calculator_03](image/device_driver/calculator_03.png)
### `dump_tree`详解
`dump_tree`方法用于打印控件树结构,帮助我们快速了解应用程序窗口中的控件层级,尤其是在自动化测试或脚本开发中,识别控件非常重要。
```Python
window.dump_tree(depth=[int类型数字], filename=None)
# 打印当前窗口及其子控件的树结构
# 在debug的过程中如果需要查找某一控件可以通过depth指定为4~5利用搜索查看是哪个独立控件
# 指定filename后将保存到对应目录文件中
```
输出会列出窗口的各个控件及其子控件,显示每个控件的属性(如标题、类型等)。
```Python
"""
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
"""
```
这里以上面计算器的例子对dump_tree进行解读
2~4行为当前对象的窗口
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
* 第4行是获取该控件的方法请注意该方法不能保证获取唯一`title`如果是变化的,也需要删除 `title`参数
6~8行为当前对象窗口所包含的子窗口信息信息类型对应2~4行
### 窗口获取注意事项
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在