9. 上传下载文件
本期将介绍如何通过 SDK 在上位机和控制器之间传输文件,并使用交互式菜单完成作业查询、作业上传、作业下载、日志下载和系统备份。
本 Demo 支持以下功能:
- 查询控制器中的全部作业文件。
- 上传单个
.JBR作业文件。 - 上传包含作业文件的整个本地目录。
- 下载控制器中的全部作业文件。
- 下载指定数量的控制器日志。
- 备份控制器系统数据和配置。
nrc_job_operate.h 没有提供任意配置文件的单独上传或恢复接口,因此系统配置、作业文件等整体数据通过 backup_system 进行备份。
⚠️ 使用注意
本 Demo 不会发送机器人运动指令,但会真实读取、写入或备份控制器文件。运行前必须确认:
- 连接配置: 普通 SDK 使用
robot_port连接,默认文件传输还需要控制器 TCP 5000 端口可用 - 端口区别: 不能将普通 SDK 端口错误修改为文件服务端口 5000
- 覆盖风险: 上传同名作业前确认控制器端文件是否允许被替换;下载前确认是否允许覆盖本地同名文件
- 网络稳定: 文件传输过程中不要断开网络、关闭控制器或强制结束程序
- 目录权限: 下载目录和系统备份保存位置具有足够的写入权限和磁盘空间
- 控制器状态: 执行系统备份前确认机器人空闲,并避免同时进行其他配置修改
上传、下载和备份属于可能改变文件状态的操作。程序不会自动重试这些写操作,避免请求已经成功但响应丢失时再次覆盖文件或重复执行备份。
1、配置普通 SDK 和文件服务参数
程序首先通过普通 SDK 端口连接控制器。当前 nrc_host.dll 在执行文件传输时,还会使用同一控制器 IP 的 TCP 5000 端口建立文件连接。
/**
* @name 用户必须根据运行环境修改
* 以下配置决定普通 SDK 能否连接到实际控制器,运行前必须核对。
* @{
*/
const std::string robot_ip = "192.168.3.243"; ///< 实际控制器的 IP 地址。
const std::string robot_port = "6001"; ///< 实际控制器的普通 SDK 服务端口。
/** @} */
constexpr int sdk_file_server_port = 5000; ///< 当前 nrc_host.dll 使用的控制器文件服务端口。robot_port 仍用于 connect_robot,不能改成 5000。文件服务端口由 DLL 在文件接口内部使用,上位机、防火墙、交换机和 VLAN 都必须允许访问该端口。
下载作业文件时,cover_local_job_files 决定是否覆盖本地已有的同名文件。设置为 true 前,应先确认本地文件保留策略。
constexpr bool cover_local_job_files = true; ///< 下载时是否覆盖本地已有的同名作业文件。
constexpr int default_log_count = 5; ///< 日志下载数量的默认值。
constexpr int job_refresh_timeout_seconds = 15; ///< 上传后等待控制器识别作业文件的总超时时间,单位为秒。
constexpr int job_refresh_interval_ms = 500; ///< 上传后刷新控制器作业列表的间隔,单位为毫秒。
constexpr int query_retry_interval_ms = 500; ///< 只读查询失败后的重试间隔,单位为毫秒。
constexpr int query_max_failures = 3; ///< 只读查询允许的最大连续失败次数。其他常量定义菜单范围、日志数量范围、取消输入和作业文件扩展名,通常不应修改:
constexpr SOCKETFD invalid_socket = -1;
constexpr int first_robot_number = 1;
constexpr int minimum_log_count = 1;
constexpr int maximum_log_count = 100;
constexpr int menu_minimum_value = 0;
constexpr int menu_maximum_value = 6;
constexpr DWORD path_buffer_size = 32768;
const std::string cancel_input = "q";
const std::string job_file_extension = ".JBR";2、定义交互菜单操作
程序使用 MenuOption 枚举表示用户可以选择的操作。使用枚举可以避免在业务分发逻辑中直接使用含义不明确的整数。
/**
* @brief 文件传输 Demo 支持的菜单操作。
*/
enum class MenuOption
{
exit_program = 0,
list_job_files = 1,
upload_job_file = 2,
upload_job_directory = 3,
download_job_files = 4,
download_log_files = 5,
backup_system_data = 6
};菜单值 0 用于退出,1~6 分别对应查询、上传、下载和备份操作。
3、SDK 结果检查
普通 SDK 接口使用 check_sdk_result 检查结果。文件传输接口则使用 check_file_transfer_result,当错误为 DISCONNECT 时额外输出文件服务端口、网络和版本诊断信息。
bool check_sdk_result(Result result, const std::string& operation)
{
if (result == SUCCESS)
return true;
std::cerr << operation << "失败,错误码:"
<< static_cast<int>(result) << std::endl;
return false;
}
bool check_file_transfer_result(
Result result,
const std::string& operation)
{
if (check_sdk_result(result, operation))
return true;
if (result == DISCONNECT)
{
std::cerr << "文件传输连接失败。请确认:" << std::endl;
std::cerr << " 1. 控制器文件服务已经启动;" << std::endl;
std::cerr << " 2. 上位机能够访问 " << robot_ip << ":"
<< sdk_file_server_port << ";" << std::endl;
std::cerr << " 3. 防火墙、交换机或 VLAN 没有拦截 TCP "
<< sdk_file_server_port << ";" << std::endl;
std::cerr << " 4. 控制器软件版本与当前 nrc_host.dll 匹配。"
<< std::endl;
std::cerr << "注意:普通 SDK 端口仍为 " << robot_port
<< ",请勿改成 " << sdk_file_server_port << "。"
<< std::endl;
}
return false;
}4、封装字符串和本地路径处理函数
用户从资源管理器复制路径时,路径两侧可能带有空格或成对双引号。程序通过 trim_copy 和 remove_surrounding_quotes 统一清理输入。
std::string trim_copy(const std::string& value)
{
const std::string whitespace = " \t\r\n";
const std::size_t begin = value.find_first_not_of(whitespace);
if (begin == std::string::npos)
return "";
const std::size_t end = value.find_last_not_of(whitespace);
return value.substr(begin, end - begin + 1);
}
std::string remove_surrounding_quotes(const std::string& value)
{
const std::string trimmed = trim_copy(value);
if (trimmed.size() >= 2
&& trimmed.front() == '"'
&& trimmed.back() == '"')
{
return trimmed.substr(1, trimmed.size() - 2);
}
return trimmed;
}to_upper_ascii 用于不区分 ASCII 大小写地比较取消输入、菜单确认和 .JBR 扩展名。
std::string to_upper_ascii(std::string value)
{
std::transform(
value.begin(), value.end(), value.begin(),
[](unsigned char character)
{
return static_cast<char>(std::toupper(character));
});
return value;
}文件和目录检查分别封装为 is_existing_file 与 is_existing_directory。get_absolute_path 将用户输入转换为绝对路径,避免文件接口依赖程序当前工作目录。
bool is_existing_file(const std::string& path)
{
const DWORD attributes = GetFileAttributesA(path.c_str());
return attributes != INVALID_FILE_ATTRIBUTES
&& (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
}
bool is_existing_directory(const std::string& path)
{
const DWORD attributes = GetFileAttributesA(path.c_str());
return attributes != INVALID_FILE_ATTRIBUTES
&& (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
}
bool get_absolute_path(
const std::string& input_path,
std::string& absolute_path)
{
std::vector<char> buffer(path_buffer_size);
const DWORD length = GetFullPathNameA(
input_path.c_str(),
static_cast<DWORD>(buffer.size()),
buffer.data(),
nullptr);
if (length == 0 || length >= buffer.size())
{
std::cerr << "获取绝对路径失败,Windows 错误码:"
<< GetLastError() << std::endl;
return false;
}
absolute_path.assign(buffer.data(), length);
return true;
}下载目录不存在时,create_directory_tree 会从盘符根目录开始逐级创建。单独封装目录创建逻辑,可以让作业下载和日志下载复用同一套保存路径准备流程。
bool create_directory_tree(
const std::string& input_path,
std::string& absolute_path)
{
if (!get_absolute_path(input_path, absolute_path))
return false;
std::replace(absolute_path.begin(), absolute_path.end(), '/', '\\');
if (is_existing_directory(absolute_path))
return true;
const std::size_t drive_root_length = 3;
for (std::size_t index = drive_root_length;
index <= absolute_path.size();
++index)
{
const bool at_separator =
index < absolute_path.size() && absolute_path[index] == '\\';
const bool at_end = index == absolute_path.size();
if (!at_separator && !at_end)
continue;
const std::string partial_path = absolute_path.substr(0, index);
if (partial_path.empty() || is_existing_directory(partial_path))
continue;
if (CreateDirectoryA(partial_path.c_str(), nullptr) == 0
&& GetLastError() != ERROR_ALREADY_EXISTS)
{
std::cerr << "创建目录失败:" << partial_path
<< ",Windows 错误码:" << GetLastError() << std::endl;
return false;
}
}
return is_existing_directory(absolute_path);
}5、封装作业名称规范化和查询重试
控制器返回的作业名称可能包含路径、扩展名或不同大小写。get_file_name 和 normalize_job_name 会提取文件名、去除 .JBR 后缀并转换为大写。
std::string get_file_name(const std::string& path)
{
const std::size_t separator = path.find_last_of("/\\");
if (separator == std::string::npos)
return path;
return path.substr(separator + 1);
}
std::string normalize_job_name(const std::string& value)
{
std::string name = to_upper_ascii(get_file_name(value));
if (name.size() > job_file_extension.size()
&& name.compare(
name.size() - job_file_extension.size(),
job_file_extension.size(),
job_file_extension) == 0)
{
name.erase(name.size() - job_file_extension.size());
}
return name;
}
bool contains_job(
const std::vector<std::vector<std::string>>& robots_files,
const std::string& expected_job)
{
const std::string normalized_expected = normalize_job_name(expected_job);
for (const auto& files : robots_files)
{
for (const std::string& file : files)
{
if (normalize_job_name(file) == normalized_expected)
return true;
}
}
return false;
}只读查询通过 query_with_retry 进行有限次重试。写操作不自动重试,因为请求可能实际成功但响应丢失,再次调用可能重复覆盖或执行。
template <typename Query>
bool query_with_retry(const std::string& operation, Query query)
{
for (int attempt = 1; attempt <= query_max_failures; ++attempt)
{
const Result result = query();
if (result == SUCCESS)
return true;
std::cerr << operation << "失败,第 " << attempt << "/"
<< query_max_failures << " 次,错误码:"
<< static_cast<int>(result) << std::endl;
if (result == DISCONNECT)
{
std::cerr << "控制器连接已经断开,停止重试" << std::endl;
return false;
}
if (attempt < query_max_failures)
{
std::this_thread::sleep_for(
std::chrono::milliseconds(query_retry_interval_ms));
}
}
return false;
}6、查询作业列表并确认上传结果
list_all_job_files 查询按机器人分组的作业列表,并输出每台机器人对应的文件。单独封装该函数后,菜单查询和目录上传后的列表刷新都可以复用。
bool list_all_job_files(SOCKETFD fd)
{
std::vector<std::vector<std::string>> robots_files;
const bool query_success = query_with_retry(
"job_get_all_jobfile_name",
[&]()
{
robots_files.clear();
return job_get_all_jobfile_name(fd, robots_files);
});
if (!query_success)
return false;
bool has_job_file = false;
for (std::size_t robot_index = 0;
robot_index < robots_files.size();
++robot_index)
{
if (robots_files[robot_index].empty())
continue;
has_job_file = true;
std::cout << "机器人 "
<< robot_index + first_robot_number << ":" << std::endl;
for (const std::string& file : robots_files[robot_index])
std::cout << " " << file << std::endl;
}
if (!has_job_file)
std::cout << "控制器中没有查询到作业文件" << std::endl;
return true;
}单个文件上传完成并调用同步接口后,控制器仍可能需要时间刷新作业列表。wait_for_uploaded_job 在有限时间内重复查询,并通过规范化名称确认目标文件已经出现。
bool wait_for_uploaded_job(SOCKETFD fd, const std::string& job_name)
{
using steady_clock = std::chrono::steady_clock;
const auto deadline = steady_clock::now()
+ std::chrono::seconds(job_refresh_timeout_seconds);
int consecutive_failures = 0;
while (steady_clock::now() < deadline)
{
std::vector<std::vector<std::string>> robots_files;
const Result result = job_get_all_jobfile_name(fd, robots_files);
if (result == SUCCESS)
{
consecutive_failures = 0;
if (contains_job(robots_files, job_name))
{
std::cout << "控制器已经识别作业文件:"
<< get_file_name(job_name) << std::endl;
return true;
}
}
else
{
++consecutive_failures;
std::cerr << "刷新作业列表失败,第 "
<< consecutive_failures << "/"
<< query_max_failures << " 次,错误码:"
<< static_cast<int>(result) << std::endl;
if (result == DISCONNECT
|| consecutive_failures >= query_max_failures)
{
return false;
}
}
std::this_thread::sleep_for(
std::chrono::milliseconds(job_refresh_interval_ms));
}
std::cerr << "等待控制器识别作业文件超时:"
<< get_file_name(job_name) << std::endl;
return false;
}7、封装用户路径和整数输入
read_path 读取一行路径,自动去除成对引号,并允许用户输入 q 取消当前操作。取消业务操作被视为正常返回,不会结束整个菜单程序。
bool read_path(const std::string& prompt, std::string& path)
{
while (true)
{
std::cout << prompt << "(输入 q 取消):";
std::string input;
if (!std::getline(std::cin, input))
{
std::cerr << "读取控制台输入失败" << std::endl;
return false;
}
path = remove_surrounding_quotes(input);
if (to_upper_ascii(path) == to_upper_ascii(cancel_input))
return false;
if (!path.empty())
return true;
std::cerr << "路径不能为空,请重新输入" << std::endl;
}
}上传单个文件时,prompt_upload_file 会确认路径存在、指向普通文件且扩展名为 .JBR。上传目录则要求本地目录已经存在;下载目录不存在时会自动递归创建。
bool prompt_upload_file(std::string& absolute_path)
{
while (true)
{
std::string input_path;
if (!read_path("请输入本地 .JBR 作业文件完整路径", input_path))
return false;
if (!get_absolute_path(input_path, absolute_path))
continue;
if (!is_existing_file(absolute_path))
{
std::cerr << "文件不存在,请重新输入:"
<< absolute_path << std::endl;
continue;
}
const std::string upper_file_name =
to_upper_ascii(get_file_name(absolute_path));
if (upper_file_name.size() <= job_file_extension.size()
|| upper_file_name.compare(
upper_file_name.size() - job_file_extension.size(),
job_file_extension.size(),
job_file_extension) != 0)
{
std::cerr << "job_upload_by_file 仅用于 .JBR 作业文件,请重新输入"
<< std::endl;
continue;
}
return true;
}
}
bool prompt_existing_directory(std::string& absolute_path)
{
while (true)
{
std::string input_path;
if (!read_path("请输入待上传的本地作业目录", input_path))
return false;
if (!get_absolute_path(input_path, absolute_path))
continue;
if (is_existing_directory(absolute_path))
return true;
std::cerr << "目录不存在,请重新输入:"
<< absolute_path << std::endl;
}
}
bool prompt_download_directory(std::string& absolute_path)
{
while (true)
{
std::string input_path;
if (!read_path("请输入上位机保存目录", input_path))
return false;
if (create_directory_tree(input_path, absolute_path))
return true;
std::cerr << "保存目录无效,请重新输入" << std::endl;
}
}prompt_integer 用于读取菜单编号和日志数量,确保整行只有一个整数且位于允许范围内。confirm_operation 则要求用户明确输入 y 或 n,用于系统备份确认。
8、上传单个作业文件
上传单个文件的流程为:读取并校验 .JBR 路径,调用 job_upload_by_file 传输文件,调用 job_sync_job_file 刷新作业数据,最后等待控制器作业列表出现该文件。
bool upload_one_job_file(SOCKETFD fd)
{
std::string file_path;
if (!prompt_upload_file(file_path))
{
std::cout << "已取消上传单个作业文件" << std::endl;
return true;
}
std::cout << "开始上传:" << file_path << std::endl;
if (!check_file_transfer_result(
job_upload_by_file(fd, file_path),
"job_upload_by_file"))
{
return false;
}
if (!check_sdk_result(job_sync_job_file(fd), "job_sync_job_file"))
return false;
return wait_for_uploaded_job(fd, file_path);
}9、上传作业目录
上传目录时,程序先确认目录存在,再调用 job_upload_by_directory。传输完成后执行作业同步,并输出刷新后的控制器作业列表。
bool upload_job_directory(SOCKETFD fd)
{
std::string directory_path;
if (!prompt_existing_directory(directory_path))
{
std::cout << "已取消上传作业目录" << std::endl;
return true;
}
std::cout << "开始上传目录:" << directory_path << std::endl;
if (!check_file_transfer_result(
job_upload_by_directory(fd, directory_path),
"job_upload_by_directory"))
{
return false;
}
if (!check_sdk_result(job_sync_job_file(fd), "job_sync_job_file"))
return false;
std::cout << "目录上传完成,刷新后的控制器作业列表如下:"
<< std::endl;
return list_all_job_files(fd);
}目录上传可能包含多个同名文件,执行前应检查目录内容和控制器端文件保留策略。
10、下载作业文件和控制器日志
download_all_job_files 准备本地保存目录后,调用 job_download_by_directory 下载控制器中的全部作业文件。第三个参数决定是否覆盖本地同名文件。
bool download_all_job_files(SOCKETFD fd)
{
std::string directory_path;
if (!prompt_download_directory(directory_path))
{
std::cout << "已取消下载作业文件" << std::endl;
return true;
}
std::cout << "开始下载全部作业文件到:"
<< directory_path << std::endl;
return check_file_transfer_result(
job_download_by_directory(
fd,
directory_path,
cover_local_job_files),
"job_download_by_directory");
}日志下载先要求用户输入 1~100 的数量,再准备保存目录并调用 log_download_by_quantity。
bool download_controller_logs(SOCKETFD fd)
{
int log_count = default_log_count;
if (!prompt_integer(
"请输入下载日志数量(1~100):",
minimum_log_count,
maximum_log_count,
log_count))
{
return false;
}
std::string directory_path;
if (!prompt_download_directory(directory_path))
{
std::cout << "已取消下载日志文件" << std::endl;
return true;
}
std::cout << "开始下载 " << log_count << " 个日志文件到:"
<< directory_path << std::endl;
return check_file_transfer_result(
log_download_by_quantity(fd, log_count, directory_path),
"log_download_by_quantity");
}11、备份控制器系统数据
backup_controller_system 在执行前明确提示系统备份会保存到当前可执行程序目录,并要求用户输入 y 确认。输入 n 被视为正常取消,不会终止菜单程序。
bool backup_controller_system(SOCKETFD fd)
{
std::cout << "系统备份会保存到当前可执行程序目录,"
<< "请确保机器人处于空闲状态。" << std::endl;
if (!confirm_operation("是否继续执行系统备份"))
{
std::cout << "已取消系统备份" << std::endl;
return true;
}
return check_file_transfer_result(
backup_system(fd),
"backup_system");
}系统备份包含控制器配置、作业文件等数据,但该接口不等于任意单个配置文件的上传或恢复功能。
12、显示菜单并分发文件操作
每次执行文件操作前,confirm_connection 通过只读接口确认普通 SDK 连接仍然有效。连接失效时退出菜单循环,避免继续调用文件接口。
bool confirm_connection(SOCKETFD fd)
{
return query_with_retry(
"get_connection_status",
[&]()
{
return get_connection_status(fd);
});
}prompt_menu 显示功能列表并通过 prompt_integer 读取 0~6 的有效整数。execute_operation 根据枚举调用对应的业务函数。
bool execute_operation(SOCKETFD fd, MenuOption option)
{
switch (option)
{
case MenuOption::list_job_files:
return list_all_job_files(fd);
case MenuOption::upload_job_file:
return upload_one_job_file(fd);
case MenuOption::upload_job_directory:
return upload_job_directory(fd);
case MenuOption::download_job_files:
return download_all_job_files(fd);
case MenuOption::download_log_files:
return download_controller_logs(fd);
case MenuOption::backup_system_data:
return backup_controller_system(fd);
case MenuOption::exit_program:
return true;
default:
std::cerr << "未知菜单选项" << std::endl;
return false;
}
}单次操作失败时,程序记录失败状态并继续显示菜单,用户可以检查错误信息后重新选择功能。连接确认失败或控制台输入流失败时,程序退出循环。
13、主程序连接控制器并运行交互菜单
主函数设置 UTF-8 控制台后连接普通 SDK,并提示文件传输还需要 TCP 5000 端口。菜单循环持续运行到用户选择退出,最后显式调用 disconnect_robot。
/**
* @brief Demo 程序入口。
* @return 全部操作及断开连接成功时返回 EXIT_SUCCESS,否则返回 EXIT_FAILURE。
*/
#include <cpp_interface/nrc_interface.h>
#include <cpp_interface/nrc_job_operate.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <iostream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include "../demo_utils.h" //封装的控制台UTF-8
int main()
{
demo::enable_console_utf8(); //调用控制台UTF-8输出
std::cout << "连接控制器 " << robot_ip << ":"
<< robot_port << " ..." << std::endl;
const SOCKETFD fd = connect_robot(robot_ip, robot_port);
if (fd <= invalid_socket)
{
std::cerr << "connect_robot 失败,请检查 IP、端口和网络连接"
<< std::endl;
return EXIT_FAILURE;
}
std::cout << "控制器连接成功" << std::endl;
std::cout << "提示:文件上传、下载还需要控制器 TCP "
<< sdk_file_server_port << " 端口可用。" << std::endl;
bool program_success = true;
while (true)
{
MenuOption option = MenuOption::exit_program;
if (!prompt_menu(option))
{
program_success = false;
break;
}
if (option == MenuOption::exit_program)
break;
if (!confirm_connection(fd))
{
program_success = false;
break;
}
if (!execute_operation(fd, option))
{
program_success = false;
std::cerr << "本次操作失败,可检查错误信息后重新选择功能"
<< std::endl;
}
}
if (!check_sdk_result(disconnect_robot(fd), "disconnect_robot"))
program_success = false;
return program_success ? EXIT_SUCCESS : EXIT_FAILURE;
}