Skip to content

9. 上传下载文件

本期将介绍如何通过 SDK 在上位机和控制器之间传输文件,并使用交互式菜单完成作业查询、作业上传、作业下载、日志下载和系统备份。

本 Demo 支持以下功能:

  1. 查询控制器中的全部作业文件。
  2. 上传单个 .JBR 作业文件。
  3. 上传包含作业文件的整个本地目录。
  4. 下载控制器中的全部作业文件。
  5. 下载指定数量的控制器日志。
  6. 备份控制器系统数据和配置。

nrc_job_operate.h 没有提供任意配置文件的单独上传或恢复接口,因此系统配置、作业文件等整体数据通过 backup_system 进行备份。

⚠️ 使用注意

本 Demo 不会发送机器人运动指令,但会真实读取、写入或备份控制器文件。运行前必须确认:

  • 连接配置: 普通 SDK 使用 robot_port 连接,默认文件传输还需要控制器 TCP 5000 端口可用
  • 端口区别: 不能将普通 SDK 端口错误修改为文件服务端口 5000
  • 覆盖风险: 上传同名作业前确认控制器端文件是否允许被替换;下载前确认是否允许覆盖本地同名文件
  • 网络稳定: 文件传输过程中不要断开网络、关闭控制器或强制结束程序
  • 目录权限: 下载目录和系统备份保存位置具有足够的写入权限和磁盘空间
  • 控制器状态: 执行系统备份前确认机器人空闲,并避免同时进行其他配置修改

上传、下载和备份属于可能改变文件状态的操作。程序不会自动重试这些写操作,避免请求已经成功但响应丢失时再次覆盖文件或重复执行备份。

1、配置普通 SDK 和文件服务参数

程序首先通过普通 SDK 端口连接控制器。当前 nrc_host.dll 在执行文件传输时,还会使用同一控制器 IP 的 TCP 5000 端口建立文件连接。

cpp
/**
 * @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 前,应先确认本地文件保留策略。

cpp
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;            ///< 只读查询允许的最大连续失败次数。

其他常量定义菜单范围、日志数量范围、取消输入和作业文件扩展名,通常不应修改:

cpp
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 枚举表示用户可以选择的操作。使用枚举可以避免在业务分发逻辑中直接使用含义不明确的整数。

cpp
/**
 * @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 时额外输出文件服务端口、网络和版本诊断信息。

cpp
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_copyremove_surrounding_quotes 统一清理输入。

cpp
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 扩展名。

cpp
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_fileis_existing_directoryget_absolute_path 将用户输入转换为绝对路径,避免文件接口依赖程序当前工作目录。

cpp
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 会从盘符根目录开始逐级创建。单独封装目录创建逻辑,可以让作业下载和日志下载复用同一套保存路径准备流程。

cpp
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_namenormalize_job_name 会提取文件名、去除 .JBR 后缀并转换为大写。

cpp
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 进行有限次重试。写操作不自动重试,因为请求可能实际成功但响应丢失,再次调用可能重复覆盖或执行。

cpp
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 查询按机器人分组的作业列表,并输出每台机器人对应的文件。单独封装该函数后,菜单查询和目录上传后的列表刷新都可以复用。

cpp
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 在有限时间内重复查询,并通过规范化名称确认目标文件已经出现。

cpp
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 取消当前操作。取消业务操作被视为正常返回,不会结束整个菜单程序。

cpp
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。上传目录则要求本地目录已经存在;下载目录不存在时会自动递归创建。

cpp
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 则要求用户明确输入 yn,用于系统备份确认。

8、上传单个作业文件

上传单个文件的流程为:读取并校验 .JBR 路径,调用 job_upload_by_file 传输文件,调用 job_sync_job_file 刷新作业数据,最后等待控制器作业列表出现该文件。

cpp
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。传输完成后执行作业同步,并输出刷新后的控制器作业列表。

cpp
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 下载控制器中的全部作业文件。第三个参数决定是否覆盖本地同名文件。

cpp
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

cpp
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 被视为正常取消,不会终止菜单程序。

cpp
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 连接仍然有效。连接失效时退出菜单循环,避免继续调用文件接口。

cpp
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 根据枚举调用对应的业务函数。

cpp
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

cpp
/**
 * @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;
}