跳到主要内容

202505 海康机器人客制叉车

· 阅读需 13 分钟

叉车可以举升,门架可以前移。控制举升和门架都是通过脚本。

叉车进行两层货架与地面之间的相互搬运。

脚本动作

叉子动作

在车模型中关于叉子得硬件信息如下:

  • lift_pump: 泵
  • lift_up: 叉子抬升
  • lift_down : 叉子下降
  • wireEncoder:拉线编码器

抬升叉子时,需要抬升与泵IO同时设置

下降叉子时,需要抬升与下降IO同时设置

拉线编码器获取当时的叉子高度。

叉子脚本如下:

execute_with_value_timeout 做了叉子动作的超时,如果2s拉线编码器值未发变化。则任务异常。

check_func 函数检擦每次拉线编码器值得时候,同时检测有没有手动,急停得异常情况。

信息

获取拉线编码器的值的时候,由于没有校准,所以需要一个获取值与实际高度的一个转化。

from abc import abstractmethod
from actuator import Actuator, TimeoutResult
from sec_dev import IoApi, BusinessStatus, SecurityAlarmApi
from dev_api import SecDevInterface
import logging

class Forklift(Actuator):
"""叉车执行器类,继承自Actuator抽象基类"""

def __init__(self, logger: logging.Logger = None):
super().__init__(logger)
# 定义IO点位的名称
self.PUMP_DO_NAME = "lift_pump"
self.LIFT_UP_DO_NAME = "lift_up"
self.LIFT_DOWN_DO_NAME = "lift_down"
self.WIRE_ENCODER_NAME = "wireEncoder"

def check_emergency(self,robot: SecDevInterface = None) -> bool:
"""检查紧急情况

Returns:
bool: 如果存在紧急情况返回True,否则返回False
"""
alarm_dict = SecurityAlarmApi.get_all_alarm_info(robot)
alarm_info:dict = alarm_dict.get("info", {})
# 遍历字典,检测碰撞条或急停是否触发
for alarm in alarm_info:
if alarm["m_type"] == 3 or alarm["m_type"] == 4 or alarm["m_type"] == 5 or alarm["m_type"] == 7:
self.log.info(f"急停或者碰撞条触发 {alarm_info}")
return True
return False

def stop(self, robot: SecDevInterface = None):
"""停止叉车所有操作"""
if robot is None:
self.log.error("robot参数为None,无法执行停止操作")
return

try:
self.set_do_value(robot, self.PUMP_DO_NAME, 0)
self.set_do_value(robot, self.LIFT_UP_DO_NAME, 0)
self.set_do_value(robot, self.LIFT_DOWN_DO_NAME, 0)
self.log.info("紧急停止完成")
except Exception as e:
self.log.error(f"紧急停止失败: {e}")

def check_status(self, robot: SecDevInterface):
"""检查叉车状态

Args:
robot: 机器人接口实例

"""
try:
# 获取油泵电机DO状态
pump_status = IoApi.get_di(robot, {"di_name": self.PUMP_DO_NAME})

# 获取上升电磁阀DO状态
lift_up_status = IoApi.get_di(robot, {"di_name": self.LIFT_UP_DO_NAME})

# 获取下降电磁阀DO状态
lift_down_status = IoApi.get_di(robot, {"di_name": self.LIFT_DOWN_DO_NAME})

# 获取货叉高度
fork_height = self.get_line(robot)

# 记录状态到日志
self.log.info(f"叉车状态检查 -> "
f"油泵电机({self.PUMP_DO_NAME}): {pump_status}, "
f"上升阀({self.LIFT_UP_DO_NAME}): {lift_up_status}, "
f"下降阀({self.LIFT_DOWN_DO_NAME}): {lift_down_status}, "
f"货叉高度: {fork_height}")

except Exception as e:
self.log.error(f"检查叉车状态失败: {e}")

def get_line(self, robot: SecDevInterface):
"""获取校正后的拉线值"""
line_code = IoApi.get_line_code(robot, {"name": self.WIRE_ENCODER_NAME})
self.log.debug(f"拉线真实值: {line_code}")
corrected_value = -2 * line_code + 44
return corrected_value

def lift(self, robot: SecDevInterface, target_height):
"""叉子抬升控制函数"""
line_code = self.get_line(robot)
self.log.debug(f"下发举升高度: {target_height}, 当前拉线: {line_code}")

if line_code < 0:
raise Exception("获取拉线值不能为负数")

if target_height == line_code:
self.log.info("目标高度已达到,无需移动")
return

going_up = target_height > line_code

def action_func(**kwargs):
if going_up:
self.log.info(f"开始举升: 从 {line_code}{target_height}")
self.set_do_value(robot, self.LIFT_DOWN_DO_NAME, 0) # 先确保下降阀关闭
self.set_do_value(robot, self.LIFT_UP_DO_NAME, 1)
self.set_do_value(robot, self.PUMP_DO_NAME, 1)
else:
self.log.info(f"开始下放: 从 {line_code}{target_height}")
self.set_do_value(robot, self.LIFT_UP_DO_NAME, 1)
self.set_do_value(robot, self.LIFT_DOWN_DO_NAME, 1)
return True # 表示动作已下发

def check_func(**kwargs):

if self.check_emergency(robot):
raise Exception("急停或者碰撞条触发")

return self.get_line(robot)

# 定义目标检查函数
def target_check_func(current_pos):
self.log.info(f"检查当前值{current_pos} 目标值{target_height}")
if going_up: # 举升
return current_pos >= target_height
else: # 下放
return abs(current_pos - target_height) <= 2

def cleanup_func(**kwargs):
self.log.info("执行清理:关闭泵与电磁阀")
self.stop(robot)

result, last_value, elapsed = self.execute_with_value_timeout(
action_func=action_func,
check_func=check_func,
check_interval=0.05,
value_timeout=2,
target_value=target_check_func,
cleanup_func=cleanup_func,
robot=robot,
target_height=target_height,
going_up=going_up
)

# 检查执行结果,如果超时则抛出异常

if result in [TimeoutResult.TIMEOUT, TimeoutResult.VALUE_TIMEOUT]:
raise Exception(f"叉车升降操作超时: {result.value}, 目标高度={target_height}, 最后位置={last_value}, 耗时={elapsed:.2f}秒")
elif result == TimeoutResult.FAILED:
raise Exception(f"叉车升降操作失败: 目标高度={target_height}, 最后位置={last_value}, 耗时={elapsed:.2f}秒")

self.stop(robot)

门架动作

在车模型中关于门架得硬件信息如下:

  • lift_pump: 泵
  • sway_go: 门架推出
  • sway_back: 门架收回
  • sway_zero: 门架零位
  • sway_final: 门架推出到位

推出时,需要门架推出并开泵。并检测门架推出到位。 收回时,需要门前收回并开泵。并检测门架收回到位。

门架脚本如下:

execute_with_timeout 执行推出,收回门架时,设置了10秒得延时时间, 如果超时则异常。

check_func 每次获取对应到位信号得同时,也检测是否手动或者急停得状态。

from abc import abstractmethod
from actuator import Actuator, TimeoutResult
from sec_dev import IoApi, BusinessStatus, SecurityAlarmApi
from dev_api import SecDevInterface
import logging

class Sway(Actuator):
"""门架执行器类,继承自Actuator抽象基类"""

def __init__(self, logger: logging.Logger = None):
super().__init__(logger)
# 定义IO点位的名称
self.PUMP_DO_NAME = "lift_pump"
self.SWAY_GO_DO_NAME = "sway_go" # 门架伸出
self.SWAY_BACK_DO_NAME = "sway_back" # 门架收缩
self.SWAY_ZERO_DI_NAME = "sway_zero" # 门架零位检测
self.SWAY_FINAL_DI_NAME = "sway_final" # 门架最终位检测

def check_emergency(self, robot: SecDevInterface = None) -> bool:
"""检查紧急情况

Returns:
bool: 如果存在紧急情况返回True,否则返回False
"""
alarm_dict = SecurityAlarmApi.get_all_alarm_info(robot)
alarm_info:dict = alarm_dict.get("info", {})
# 遍历字典,检测碰撞条或急停是否触发
for alarm in alarm_info:
if alarm["m_type"] == 3 or alarm["m_type"] == 4 or alarm["m_type"] == 5 or alarm["m_type"] == 7:
self.log.info(f"急停或者碰撞条触发 {alarm_info}")
return True
return False

def stop(self, robot: SecDevInterface = None):
"""停止门架所有操作"""
if robot is None:
self.log.error("robot参数为None,无法执行停止操作")
return

try:
self.set_do_value(robot, self.PUMP_DO_NAME, 0)
self.set_do_value(robot, self.SWAY_GO_DO_NAME, 0)
self.set_do_value(robot, self.SWAY_BACK_DO_NAME, 0)
self.log.info("门架紧急停止完成")
except Exception as e:
self.log.error(f"门架紧急停止失败: {e}")

def check_status(self, robot: SecDevInterface):
"""检查门架状态

Args:
robot: 机器人接口实例

"""
try:
# 获取油泵电机DO状态
pump_status = IoApi.get_di(robot, {"di_name": self.PUMP_DO_NAME})

# 获取门架伸出电磁阀DO状态
sway_go_status = IoApi.get_di(robot, {"di_name": self.SWAY_GO_DO_NAME})

# 获取门架收缩电磁阀DO状态
sway_back_status = IoApi.get_di(robot, {"di_name": self.SWAY_BACK_DO_NAME})

# 获取门架位置状态
zero_detected = self.get_limit_di(robot, self.SWAY_ZERO_DI_NAME)
final_detected = self.get_limit_di(robot, self.SWAY_FINAL_DI_NAME)

# 记录状态到日志
self.log.info(f"门架状态检查 -> "
f"油泵电机({self.PUMP_DO_NAME}): {pump_status}, "
f"伸出阀({self.SWAY_GO_DO_NAME}): {sway_go_status}, "
f"收缩阀({self.SWAY_BACK_DO_NAME}): {sway_back_status}, "
f"零位检测: {zero_detected}, "
f"最终位检测: {final_detected}")

except Exception as e:
self.log.error(f"检查门架状态失败: {e}")

def get_limit_di(self, robot: SecDevInterface, name):
"""获取限位DI状态"""
di_val = IoApi.get_di(robot, {"di_name": str(name)})
self.log.debug(f"限位IO: {name}, di_val {di_val}")
return di_val

def sway(self, robot: SecDevInterface, target):
"""门架前移后移控制函数

Args:
robot: 机器人接口实例
target: 目标位置,1表示伸出,0表示收缩
"""
self.log.debug(f"门架移动目标: {target}")

if target == 1: # 伸出

def action_func(**kwargs):
self.log.info("开始门架伸出")
self.set_do_value(robot, self.SWAY_BACK_DO_NAME, 0) # 先确保收缩阀关闭
self.set_do_value(robot, self.SWAY_GO_DO_NAME, 1)
self.set_do_value(robot, self.PUMP_DO_NAME, 1)
return True

def check_func(**kwargs):
if self.check_emergency(robot):
raise Exception("急停或者碰撞条触发")
final_detected = self.get_limit_di(robot, self.SWAY_FINAL_DI_NAME)
return final_detected

def target_check_func(current_status):
# 当检测到货物或到达最终位时停止
return current_status == 0

def cleanup_func(**kwargs):
self.log.info("开始门架伸出清理")
self.stop(robot)

elif target == 0: # 收缩

def action_func(**kwargs):
self.log.info("开始门架收缩")
self.set_do_value(robot, self.SWAY_GO_DO_NAME, 0) # 先确保伸出阀关闭
self.set_do_value(robot, self.SWAY_BACK_DO_NAME, 1)
self.set_do_value(robot, self.PUMP_DO_NAME, 1)
return True

def check_func(**kwargs):
if self.check_emergency(robot):
raise Exception("急停或者碰撞条触发")
# 返回零位检测状态
return self.get_limit_di(robot, self.SWAY_ZERO_DI_NAME)

def target_check_func(zero_detected):
return zero_detected == 0

def cleanup_func(**kwargs):
self.log.info("开始门架收缩清理")
self.stop(robot)

else:
raise Exception(f"无效的目标值: {target}")

# 执行超时控制
result, last_value, elapsed = self.execute_with_timeout(
action_func=action_func,
check_func=check_func,
timeout=10,
check_interval=0.05,
value_timeout=0,
target_value=target_check_func,
max_retries=1,
retry_interval=0.5,
cleanup_func=cleanup_func,
robot=robot,
target=target
)

# 检查执行结果,如果超时则抛出异常
if result in [TimeoutResult.TIMEOUT, TimeoutResult.VALUE_TIMEOUT]:
action_desc = "伸出" if target == 1 else "收缩"
raise Exception(f"门架{action_desc}操作超时: {result.value}, 目标={target}, 最后状态={last_value}, 耗时={elapsed:.2f}秒")
elif result == TimeoutResult.FAILED:
action_desc = "伸出" if target == 1 else "收缩"
raise Exception(f"门架{action_desc}操作失败: 目标={target}, 最后状态={last_value}, 耗时={elapsed:.2f}秒")

# 确保停止所有动作
self.stop(robot)

def extend(self, robot: SecDevInterface):
"""门架伸出操作"""
self.sway(robot, 1)

def retract(self, robot: SecDevInterface):
"""门架收缩操作"""
self.sway(robot, 0)

最终执行脚本

最终需要执行取货放货,需要同时执行叉子和门架。我们将前面两个脚本中的函数组合实现搬运动作。

搬运动作脚本如下:

在 steps 中执行 step() 中需要每次延时1s。这样看起来更安全一些。

import datetime
import time
import logging
from forklift import Forklift
from sway import Sway
from sec_dev import TemplateBusiness, BusinessStatus
from dev_api import SecDevInterface
import utils

# 创建日志输出文件
log = logging.getLogger("forklift_pick_place")

log.handlers.clear()
if not log.handlers:
file_handler = logging.FileHandler('./forklift_pick_place.log', encoding="utf8")
log.setLevel(logging.DEBUG)

fmt = '''[%(asctime)s]-%(name)s-%(levelname)s:''' \
'''%(message)s-%(filename)s:%(lineno)s'''
formatter = logging.Formatter(fmt)
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.DEBUG)
log.addHandler(file_handler)

log.info("叉车取放货业务脚本启动......")

class Business(TemplateBusiness):
"""叉车取放货业务类,继承自TemplateBusiness"""

def __init__(self, robot: SecDevInterface, params):
super().__init__()

log.debug(f"参数: {params}")

# 设置默认参数
if params == "":
self.operation_param = {
"action": "pick", # pick: 取货, place: 放货
"target_height": 2000, # 目标高度
"lift_offset": 100 # 抬升高度
}
else:
self.operation_param = params

# 初始化叉车和门架执行器
self.forklift = Forklift(log)
self.sway = Sway(log)

# 清除模块告警
utils.set_module_alarm(robot, 5, 0, "", "")

def execute(self, robot: SecDevInterface, args):
"""执行叉车取放货操作"""

action = self.operation_param.get("action")
target_height = int(self.operation_param.get("target_height"))
lift_offset = int(self.operation_param.get("lift_offset"))

log.info(f"执行操作: {action}, 目标高度: {target_height}, 抬升高度: {lift_offset}")

if action == "pick":
# 取货流程:
# 1. 降叉子到目标高度-抬升高度
# 2. 推门架
# 3. 抬升到目标高度
# 4. 收门架
initial_height = target_height - lift_offset
steps = [
lambda: self.forklift.lift(robot, initial_height), # 降叉子到初始高度
lambda: self.sway.extend(robot), # 推门架(伸出)
lambda: self.forklift.lift(robot, target_height), # 抬升到目标高度
lambda: self.sway.retract(robot) # 收门架(收缩)
]
elif action == "place":
# 放货流程:
# 1. 抬升到目标高度+抬升高度
# 2. 推门架
# 3. 降到目标高度
# 4. 收门架
elevated_height = target_height + lift_offset
steps = [
lambda: self.forklift.lift(robot, elevated_height), # 抬升到抬高位置
lambda: self.sway.extend(robot), # 推门架(伸出)
lambda: self.forklift.lift(robot, target_height), # 降到目标高度
lambda: self.sway.retract(robot) # 收门架(收缩)
]
else:
log.error(f"不支持的操作类型: {action}")
utils.set_module_alarm(robot, 5, 1, f"不支持的操作类型 {action}", "")
return BusinessStatus.FAILED

for i, step in enumerate(steps):
try:
step()
time.sleep(1) # 每步操作后等待1秒
self.status = BusinessStatus.FINISHED
except Exception as e:
log.error(f"步骤 {i} 执行失败: {e}")
utils.set_module_alarm(robot, 6, 1, f"叉车{action}任务第{i}步执行失败", "")
self.status = BusinessStatus.FAILED
return self.status

return BusinessStatus.FINISHED

编写任务编排

先空车移动到起点,执行取货动作。再空车移动到终点,执行放货动作。

可以参考移栽的搬运。

空车移动(锁车)-> 抬升叉子(锁车)-> 空车移动(锁车)-> 降低叉子(锁车)

锁车功能说明:

前一个任务把车锁定了,下个任务就必须指定这辆车,所以后面的任务,它车号的数据来源是来自于前一个任务

hikrobot-customization-forklift-1

充电

叉车使用的地盘是潜伏车,车头充电。 实际设置了站点终点方向180度之后,小车还是叉子朝充电桩。

需要此时需要配置充电桩关联站点。

提示

在添加充电外设的时候,需要鼠标点击拖动滚动条来选择充电桩。如果使用滚动条则会跳过充桩。

charging-pile-relation-station

PDA

添加项目

路径:系统管理->添加

添加服务

添加Rcs服务,服务路径为:https://192.168.124.120/

增加关联接口标识: logout

方法:Post

URL: /unify/basic/api/unify/logout

添加应用

选择应用PDA, ClientView 和 RCS 配合使用,选择PDA(标准版)

添加扩展脚本

this.onLogin = async (info) => {
try {
// 校验参数
if (!info.username || !info.password) {
throw {
message: this.$t('login.error.need')
}
}
const param = {
username: info.username,
password: sha256(info.password), // 需要对密码做处理
clientType: 'pda'
}
// 调用登录接口
const { data } = await this.$post('/unify/basic/api/unify/token', param)
if (data.success) {
// 更新token
mutations.updateToken(data)
// 登录成功之后跳转至首页
this.$router.push('/home')
} else {
throw data
}
} catch(e) {
this.loginTip = e.message || this.$t('login.error.unknown')
}
}

上下文

属性名属性值类型
LOGOUT_METHODRCS.logoutString

配置页面

新建PDA通用版本,页面配置中增加一个一层取放货的页面。

pda-page

该页面脚本编写以下提交任务的脚本。

起点站点控件的id设置为:#startCode

终点站点控件的id 设置为:#endCode

提交的Button的事件中绑定: onSubmit。

// 提交按钮
this.onSubmit = () => {
const params = {
targetRoute: [{
type: 'SITE',
code: this.form['#startCode'],
seq: 0
},
{
type: 'SITE',
code: this.form['#endCode'],
seq: 1
}
],
taskType: 'L01'
}
const priority = this.form['#initPriority']
if (!priority || priority === '') {
params.initPriority = 1
} else {
params.initPriority = parseInt(priority)
}
const headers = {'X-lr-request-id': new Date().getTime()}
this.$post('https://192.168.124.120/rcs/rtas/web/schedule/submit', params, {headers}).then((res) => {
if (res.data.success || res.data.code === '0') {
this.$tip(this.$text('下发成功'), 'success')
this.clear()
} else {
this.$tip(res.data.message)
}
})
}

发布

获取应用id和项目id,拼接一个页面url如下:

https://192.168.124.120/clientview/apps/base-pda/index.html?appId=20250812153631898&projectId=20250812140847670#/login

在浏览器访问该地址如果能访问到说明Url拼接成功。

将前面ip地址部分拿掉 /clientview/apps/base-pda/index.html?appId=20250812153631898&projectId=20250812140847670#/login 配置到PDA登录页面右上角的加载路径中。