[copybara] mostrobotpy to allwpilib (#8545)

Project import generated by Copybara.
GitOrigin-RevId: f10284b37498bb6a088891ca41f160793ec7fd90
This commit is contained in:
PJ Reiniger
2026-01-12 22:11:02 -05:00
committed by GitHub
parent 7e1260b003
commit 762d1e8b93
64 changed files with 2665 additions and 66 deletions

View File

@@ -13,6 +13,7 @@ enums:
value_prefix: HAL_kMatchType
HAL_RobotMode:
rename: _RobotMode
value_prefix: HAL_ROBOTMODE
RobotMode:
classes:
HAL_ControlWord:

View File

@@ -6,4 +6,16 @@ functions:
ignore: true # TODO
HAL_HasMain:
HAL_RunMain:
cpp_code: |
[]() {
{
py::gil_scoped_release gil;
HAL_RunMain();
}
// halsim-gui will set the python error indicator if an exception occurs
if (PyErr_Occurred()) {
throw py::error_already_set();
}
}
HAL_ExitMain:

View File

@@ -2,6 +2,8 @@ jinja2==3.1.6
protobuf==5.28.3
grpcio-tools==1.68.0
semiwrap==0.2.1
pytest
pytest>=3.9
numpy
opencv-python~=4.6
robotpy-cli~=2027.0.0a1
pytest-reraise

View File

@@ -326,6 +326,12 @@ pygments==2.19.2 \
pytest==8.4.1 \
--hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \
--hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c
# via
# -r requirements.txt
# pytest-reraise
pytest-reraise==2.1.2 \
--hash=sha256:5ab59bd0e2028be095289e6dfc9e36cc0b56936465278f3223e81bea0f2d1c70 \
--hash=sha256:c22430d33b2cc18905959d7af28978e371113fcc6ef67b5fec95efcd80b88c16
# via -r requirements.txt
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
@@ -382,6 +388,10 @@ pyyaml==6.0.2 \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via semiwrap
robotpy-cli==2027.0.1b1 \
--hash=sha256:5f91f6a2e90f6c55800a7a7205180e2454ca961aaf1ef98ce2b63d76aab67505 \
--hash=sha256:f56d8d7444a4aecd4a9c965ef97d4fcf8e951e7ed7a3497f7f8a2635613a5222
# via -r requirements.txt
ruamel-yaml==0.18.14 \
--hash=sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2 \
--hash=sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7

View File

@@ -332,6 +332,12 @@ pygments==2.19.2 \
pytest==8.4.1 \
--hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \
--hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c
# via
# -r requirements.txt
# pytest-reraise
pytest-reraise==2.1.2 \
--hash=sha256:5ab59bd0e2028be095289e6dfc9e36cc0b56936465278f3223e81bea0f2d1c70 \
--hash=sha256:c22430d33b2cc18905959d7af28978e371113fcc6ef67b5fec95efcd80b88c16
# via -r requirements.txt
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
@@ -388,6 +394,10 @@ pyyaml==6.0.2 \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via semiwrap
robotpy-cli==2027.0.1b1 \
--hash=sha256:5f91f6a2e90f6c55800a7a7205180e2454ca961aaf1ef98ce2b63d76aab67505 \
--hash=sha256:f56d8d7444a4aecd4a9c965ef97d4fcf8e951e7ed7a3497f7f8a2635613a5222
# via -r requirements.txt
ruamel-yaml==0.18.14 \
--hash=sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2 \
--hash=sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7

View File

@@ -173,6 +173,7 @@ def define_pybind_library(name, pkgcfgs = []):
imports = ["src/main/python"],
deps = [
"//romiVendordep:robotpy-native-romi",
"//simulation/halsim_ws_core:robotpy-halsim-ws",
"//wpilibc:robotpy-wpilib",
],
visibility = ["//visibility:public"],

View File

@@ -20,12 +20,16 @@ authors = [
license = "BSD-3-Clause"
dependencies = [
"robotpy-native-romi==0.0.0",
"wpilib==0.0.0"
"wpilib==0.0.0",
"robotpy-halsim-ws==0.0.0",
]
[project.urls]
"Source code" = "https://github.com/robotpy/mostrobotpy"
[project.entry-points."robotpy_cli.2027"]
run-romi = "romi.cli:RunRomi"
[tool.hatch.build.hooks.robotpy]
version_file = "romi/version.py"

View File

@@ -0,0 +1,121 @@
import argparse
import importlib.metadata
import os
import sys
import typing
import wpilib
if sys.version_info < (3, 10):
def entry_points(group):
eps = importlib.metadata.entry_points()
return eps.get(group, [])
else:
entry_points = importlib.metadata.entry_points
def _int_env_default(name: str, fallback: int) -> int:
value = os.environ.get(name)
if value is None:
return fallback
try:
return int(value)
except ValueError:
return fallback
class RunRomi:
"""
Runs the robot using the HAL simulator connected to a ROMI
"""
def __init__(self, parser: argparse.ArgumentParser):
parser.add_argument(
"--nogui",
default=False,
action="store_true",
help="Don't use the WPILib simulation gui",
)
sim_group = parser.add_argument_group("Additional simulation extensions")
self.simexts = {}
for entry_point in entry_points(group="robotpy_sim.2027"):
try:
sim_ext_module = entry_point.load()
except ImportError:
print(f"WARNING: Error detected in {entry_point}")
continue
self.simexts[entry_point.name] = sim_ext_module
try:
if entry_point.name == "ws-client":
cmd_help = argparse.SUPPRESS
else:
cmd_help = importlib.metadata.metadata(entry_point.dist.name)[
"summary"
]
except AttributeError:
cmd_help = "Load specified simulation extension"
sim_group.add_argument(
f"--{entry_point.name}",
default=False,
action="store_true",
help=cmd_help,
)
parser.add_argument(
"--host",
default=os.environ.get("HALSIMWS_HOST", "10.0.0.2"),
help="ROMI websocket host (default: HALSIMWS_HOST or 10.0.0.2)",
)
parser.add_argument(
"--port",
type=int,
default=_int_env_default("HALSIMWS_PORT", 3300),
help="ROMI websocket port (default: HALSIMWS_PORT or 3300)",
)
def run(
self,
options: argparse.Namespace,
project_path: "os.PathLike[str]",
robot_class: typing.Type[wpilib.RobotBase],
):
if "ws-client" not in self.simexts:
print(
"ws-client HALSim extension is missing. Reinstall robotpy-halsim-ws to use run-romi",
file=sys.stderr,
)
return False
os.environ["HALSIMWS_HOST"] = options.host
os.environ["HALSIMWS_PORT"] = str(options.port)
options.ws_client = True
if not options.nogui:
try:
import halsim_gui
except ImportError:
print("robotpy-halsim-gui is not installed!", file=sys.stderr)
return False
else:
halsim_gui.loadExtension()
cwd = os.getcwd()
for name, module in self.simexts.items():
if getattr(options, name.replace("-", "_"), False):
try:
module.loadExtension()
except Exception:
print(f"Error loading {name}!", file=sys.stderr)
raise
os.chdir(cwd)
return robot_class.main(robot_class)

View File

@@ -382,13 +382,24 @@ def generate_pybind_build_file(
base_library = python_dep.replace("robotpy-", "")
return f"//{fixup_root_package_name(base_library)}:{fixup_python_dep_name(python_dep)}"
EXTERNAL_PYPI_DEPS = [
"robotpy-cli",
"pytest-reraise",
"pytest",
]
python_deps = []
has_external_python_deps = False
if "dependencies" in raw_config["project"]:
for d in raw_config["project"]["dependencies"]:
if "robotpy-cli" in d:
continue
pd = target_from_python_dep(d.split("==")[0])
python_deps.append(pd)
for external_dep in EXTERNAL_PYPI_DEPS:
if external_dep in d:
has_external_python_deps = True
python_deps.append(f'requirement("{external_dep}")')
break
else:
pd = target_from_python_dep(d.split("==")[0])
python_deps.append(pd)
env = Environment(loader=BaseLoader)
env.filters["jsonify"] = jsonify
@@ -420,6 +431,7 @@ def generate_pybind_build_file(
raw_project_config=raw_config["project"],
entry_points=entry_points,
version_file=version_file,
has_external_python_deps=has_external_python_deps,
)
+ "\n"
)

View File

@@ -11,6 +11,8 @@ def fixup_root_package_name(name):
return "romiVendordep"
if name == "pyntcore":
return "ntcore"
if name == "halsim-ws":
return "simulation/halsim_ws_core"
return name

View File

@@ -1,5 +1,8 @@
# THIS FILE IS AUTO GENERATED
{% if publish_casters_targets %}
{% if has_external_python_deps %}
load("@allwpilib_pip_deps//:requirements.bzl", "requirement")
{%- endif %}
{%- if publish_casters_targets %}
load("@rules_cc//cc:cc_library.bzl", "cc_library")
{%- endif %}
{%- if version_file %}
@@ -211,7 +214,7 @@ def define_pybind_library(name, pkgcfgs = []):
imports = ["{{stripped_include_prefix}}"],
deps = [
{%- for d in python_deps %}
"{{d}}",
{% if "requirement" in d %}{{d}}{% else %}"{{d}}"{% endif %},
{%- endfor %}
],
visibility = ["//visibility:public"],

View File

@@ -1,10 +1,10 @@
load("@rules_python_pytest//python_pytest:defs.bzl", "py_pytest_test")
load("//shared/bazel/rules/robotpy:compatibility_select.bzl", "robotpy_compatibility_select")
def robotpy_py_test(name, srcs, tags = [], **kwargs):
def robotpy_py_test(name, srcs, tags = [], size = "small", **kwargs):
py_pytest_test(
name = name,
size = "small",
size = size,
srcs = srcs,
target_compatible_with = robotpy_compatibility_select(),
tags = tags + [

View File

@@ -1,7 +1,11 @@
load("@allwpilib_pip_deps//:requirements.bzl", "requirement")
load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test")
load("@rules_python//python:defs.bzl", "py_library")
load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library")
load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project")
load("//shared/bazel/rules:publishing.bzl", "host_architectures")
load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file")
load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test")
cc_library(
name = "headers",
@@ -92,3 +96,31 @@ package_default_cc_project(
maven_artifact_name = "halsim_ds_socket",
maven_group_id = "org.wpilib.halsim",
)
copy_native_file(
name = "halsim_ds_socket",
base_path = "src/main/python/halsim_ds_socket/",
library = "shared/halsim_ds_socket",
)
py_library(
name = "robotpy-halsim-ds-socket",
srcs = glob(["src/main/python/**/*.py"]),
data = [
":halsim_ds_socket.copy_lib",
],
imports = ["src/main/python"],
deps = [
"//hal:robotpy-native-wpihal",
"//wpinet:robotpy-native-wpinet",
],
)
robotpy_py_test(
"python_tests",
srcs = glob(["src/test/python/**/*.py"]),
deps = [
":robotpy-halsim-ds-socket",
requirement("pytest"),
],
)

View File

@@ -0,0 +1,18 @@
robotpy-halsim-ds-socket
==================
Installing this package will allow you to utilize the 2020+ WPILib GUI
DS Socket from a RobotPy program.
Usage
-----
First, install pyfrc. Then run your robot with the 'sim' argument and --ds-socket flag:
# Windows
py -3 -m robotpy sim --ds-socket
# Linux/OSX
python3 -m robotpy sim --ds-socket
WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/

View File

@@ -0,0 +1 @@
from .main import loadExtension

View File

@@ -0,0 +1,23 @@
import logging
import os
from os.path import abspath, dirname, join
logger = logging.getLogger("halsim_ds_socket")
def loadExtension():
try:
import hal
except ImportError as e:
# really, should never happen...
raise ImportError("you must install robotpy-hal!") from e
from .version import version
logger.info("WPILib HAL Simulation DS Socket Extension %s", version)
root = join(abspath(dirname(__file__)), "lib")
ext = join(root, os.listdir(root)[0])
retval = hal.loadOneExtension(ext)
if retval != 0:
logger.warn("loading extension may have failed (error=%d)", retval)

View File

@@ -0,0 +1,40 @@
[build-system]
build-backend = "hatchling.build"
requires = [
"hatchling",
"hatch-robotpy~=0.2.1",
]
[project]
name = "robotpy-halsim-ds-socket"
version = "0.0.0"
description = "WPILib simulator DS Socket Extension"
authors = [
{name = "RobotPy Development Team", email = "robotpy@googlegroups.com"},
]
license = "BSD-3-Clause"
dependencies = [
"robotpy-native-wpihal==0.0.0",
"robotpy-native-wpinet==0.0.0",
]
[project.entry-points."robotpy_sim.2027"]
ds-socket = "halsim_ds_socket"
[tool.hatch.build.hooks.robotpy]
version_file = "halsim_ds_socket/version.py"
[[tool.hatch.build.hooks.robotpy.maven_lib_download]]
artifact_id = "halsim_ds_socket"
group_id = "org.wpilib.halsim"
repo_url = ""
version = "0.0.0"
use_headers = false
extract_to = "halsim_ds_socket"
libs = ["halsim_ds_socket"]
[tool.hatch.build.targets.wheel]
packages = ["halsim_ds_socket"]

View File

@@ -0,0 +1,18 @@
import ctypes
import pathlib
def test_halsim_ds_socket():
# dependencies
import native.wpihal._init_robotpy_native_wpihal
import native.wpinet._init_robotpy_native_wpinet
import halsim_ds_socket as base
loaded = 0
for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir():
if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"):
ctypes.CDLL(str(fname))
loaded += 1
assert loaded

View File

@@ -1,7 +1,10 @@
load("@allwpilib_pip_deps//:requirements.bzl", "requirement")
load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test")
load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library")
load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project")
load("//shared/bazel/rules:publishing.bzl", "host_architectures")
load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test")
load("//simulation/halsim_gui:robotpy_pybind_build_info.bzl", "define_pybind_library", "halsim_gui_ext_extension")
wpilib_cc_library(
name = "halsim_gui",
@@ -148,3 +151,36 @@ package_default_cc_project(
maven_artifact_name = "halsim_gui",
maven_group_id = "org.wpilib.halsim",
)
PKG_CONFIG_DEPS = [
"//datalog:native/datalog/robotpy-native-datalog.pc",
"//datalog:robotpy-wpilog.generated_pkgcfg_files",
"//hal:native/wpihal/robotpy-native-wpihal.pc",
"//hal:robotpy-hal.generated_pkgcfg_files",
"//ntcore:native/ntcore/robotpy-native-ntcore.pc",
"//ntcore:pyntcore.generated_pkgcfg_files",
"//wpimath:native/wpimath/robotpy-native-wpimath.pc",
"//wpimath:robotpy-wpimath.generated_pkgcfg_files",
"//wpinet:native/wpinet/robotpy-native-wpinet.pc",
"//wpinet:robotpy-wpinet.generated_pkgcfg_files",
"//wpiutil:native/wpiutil/robotpy-native-wpiutil.pc",
"//wpiutil:robotpy-wpiutil.generated_pkgcfg_files",
]
halsim_gui_ext_extension(
srcs = glob(["src/main/python/halsim_gui/_ext/**/*.cpp"]),
)
define_pybind_library(
name = "robotpy-halsim-gui",
pkgcfgs = PKG_CONFIG_DEPS,
)
robotpy_py_test(
"python_tests",
srcs = glob(["src/test/python/**/*.py"]),
deps = [
":robotpy-halsim-gui",
requirement("pytest"),
],
)

View File

@@ -0,0 +1,152 @@
# THIS FILE IS AUTO GENERATED
load("//shared/bazel/rules/gen:gen-version-file.bzl", "generate_version_file")
load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file", "create_pybind_library", "robotpy_library")
load("//shared/bazel/rules/robotpy:semiwrap_helpers.bzl", "gen_libinit", "gen_modinit_hpp", "gen_pkgconf", "resolve_casters", "run_header_gen")
def halsim_gui_ext_extension(srcs = [], header_to_dat_deps = [], extra_hdrs = [], includes = [], extra_pyi_deps = []):
HALSIM_GUI_EXT_HEADER_GEN = [
]
resolve_casters(
name = "halsim_gui_ext.resolve_casters",
caster_deps = ["//wpimath:src/main/python/wpimath/wpimath-casters.pybind11.json", "//wpiutil:src/main/python/wpiutil/wpiutil-casters.pybind11.json"],
casters_pkl_file = "halsim_gui_ext.casters.pkl",
dep_file = "halsim_gui_ext.casters.d",
)
gen_libinit(
name = "halsim_gui_ext.gen_lib_init",
output_file = "src/main/python/halsim_gui/_ext/_init__halsim_gui_ext.py",
modules = ["hal._init__wpiHal", "wpimath._init__wpimath", "ntcore._init__ntcore"],
)
gen_pkgconf(
name = "halsim_gui_ext.gen_pkgconf",
libinit_py = "halsim_gui._ext._init__halsim_gui_ext",
module_pkg_name = "halsim_gui._ext._halsim_gui_ext",
output_file = "halsim_gui_ext.pc",
pkg_name = "halsim_gui_ext",
install_path = "src/main/python/halsim_gui/_ext",
project_file = "src/main/python/pyproject.toml",
package_root = "src/main/python/halsim_gui/_ext/__init__.py",
)
gen_modinit_hpp(
name = "halsim_gui_ext.gen_modinit_hpp",
input_dats = [x.class_name for x in HALSIM_GUI_EXT_HEADER_GEN],
libname = "_halsim_gui_ext",
output_file = "semiwrap_init.halsim_gui._ext._halsim_gui_ext.hpp",
)
run_header_gen(
name = "halsim_gui_ext",
casters_pickle = "halsim_gui_ext.casters.pkl",
header_gen_config = HALSIM_GUI_EXT_HEADER_GEN,
trampoline_subpath = "src/main/python/halsim_gui/_ext",
deps = header_to_dat_deps,
local_native_libraries = [
],
)
create_pybind_library(
name = "halsim_gui_ext",
install_path = "src/main/python/halsim_gui/_ext/",
extension_name = "_halsim_gui_ext",
generated_srcs = [],
semiwrap_header = [":halsim_gui_ext.gen_modinit_hpp"],
deps = [
":halsim_gui_ext.tmpl_hdrs",
":halsim_gui_ext.trampoline_hdrs",
"//hal:wpiHal",
"//hal:wpihal_pybind_library",
"//ntcore:ntcore",
"//ntcore:ntcore_pybind_library",
"//simulation/halsim_gui:halsim_gui",
"//wpimath:wpimath",
"//wpimath:wpimath_pybind_library",
],
dynamic_deps = [
"//hal:shared/wpiHal",
"//simulation/halsim_gui:shared/halsim_gui",
"//ntcore:shared/ntcore",
"//wpimath:shared/wpimath",
],
extra_hdrs = extra_hdrs,
extra_srcs = srcs,
includes = includes,
)
native.filegroup(
name = "halsim_gui_ext.generated_files",
srcs = [
"halsim_gui_ext.gen_modinit_hpp.gen",
"halsim_gui_ext.header_gen_files",
"halsim_gui_ext.gen_pkgconf",
"halsim_gui_ext.gen_lib_init",
],
tags = ["manual", "robotpy"],
)
def define_pybind_library(name, pkgcfgs = []):
# Helper used to generate all files with one target.
native.filegroup(
name = "{}.generated_files".format(name),
srcs = [
"halsim_gui_ext.generated_files",
],
tags = ["manual", "robotpy"],
visibility = ["//visibility:public"],
)
# Files that will be included in the wheel as data deps
native.filegroup(
name = "{}.generated_pkgcfg_files".format(name),
srcs = [
"src/main/python/halsim_gui/_ext/halsim_gui_ext.pc",
],
tags = ["manual", "robotpy"],
visibility = ["//visibility:public"],
)
# Contains all of the non-python files that need to be included in the wheel
native.filegroup(
name = "{}.extra_files".format(name),
srcs = native.glob(["src/main/python/halsim_gui/**"], exclude = ["src/main/python/halsim_gui/**/*.py"], allow_empty = True),
tags = ["manual", "robotpy"],
)
generate_version_file(
name = "{}.generate_version".format(name),
output_file = "src/main/python/halsim_gui/version.py",
template = "//shared/bazel/rules/robotpy:version_template.in",
)
copy_native_file(
name = "halsim_gui",
library = ":shared/halsim_gui",
base_path = "src/main/python/halsim_gui/",
)
robotpy_library(
name = name,
srcs = native.glob(["src/main/python/halsim_gui/**/*.py"]) + [
"src/main/python/halsim_gui/_ext/_init__halsim_gui_ext.py",
"{}.generate_version".format(name),
],
data = [
"{}.generated_pkgcfg_files".format(name),
"{}.extra_files".format(name),
":src/main/python/halsim_gui/_ext/_halsim_gui_ext",
":halsim_gui_ext.trampoline_hdr_files",
":halsim_gui.copy_lib",
],
imports = ["src/main/python"],
deps = [
"//hal:robotpy-hal",
"//ntcore:pyntcore",
"//wpimath:robotpy-wpimath",
"//wpiutil:robotpy-wpiutil",
],
visibility = ["//visibility:public"],
)

View File

@@ -0,0 +1,18 @@
robotpy-halsim-gui
==================
Installing this package will allow you to utilize the 2020+ WPILib GUI
simulation from a RobotPy program.
Usage
-----
First, install pyfrc. Then run your robot with the 'sim' argument:
# Windows
py -3 -m robotpy sim
# Linux/OSX
python3 -m robotpy sim
WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/simulation-gui.html

View File

@@ -0,0 +1 @@
from .main import loadExtension

View File

@@ -0,0 +1 @@
from . import _init__halsim_gui_ext

View File

@@ -0,0 +1,44 @@
#include <semiwrap_init.halsim_gui._ext._halsim_gui_ext.hpp>
#include <functional>
#include <wpi/halsim/gui/HALSimGuiExt.hpp>
#include <wpi/hal/Extensions.h>
std::function<void()> g_gui_exit;
SEMIWRAP_PYBIND11_MODULE(m) {
initWrapper(m);
m.def("_kill_on_signal", []() {
HAL_RegisterExtensionListener(
nullptr, [](void *, const char *name, void *data) {
std::string_view name_view{name};
if (name_view == HALSIMGUI_EXT_GUIEXIT) {
g_gui_exit = (halsimgui::GuiExitFn)data;
} else if (name_view == HALSIMGUI_EXT_ADDGUILATEEXECUTE) {
auto AddGuiLateExecute = (halsimgui::AddGuiLateExecuteFn)data;
AddGuiLateExecute([] {
py::gil_scoped_acquire gil;
if (PyErr_CheckSignals() == -1) {
// If a python signal has been triggered, the GUI needs to exit. It's
// not safe to throw an exception here on all platforms so we just
// assume that the only eventual caller of this function is HAL_RunMain,
// and our wrapper around that function will check if a python error is
// set and throw from there.
//
// Reference: https://github.com/wpilibsuite/allwpilib/issues/8528
if (g_gui_exit) {
g_gui_exit();
} else {
throw py::error_already_set();
}
}
});
}
});
});
}

View File

@@ -0,0 +1,27 @@
import logging
import os
from os.path import abspath, dirname, join
logger = logging.getLogger("halsim_gui")
def loadExtension():
try:
import hal
except ImportError as e:
# really, should never happen...
raise ImportError("you must install robotpy-hal!") from e
from .version import version
logger.info("WPILib HAL Simulation %s", version)
root = join(abspath(dirname(__file__)), "lib")
ext = join(root, os.listdir(root)[0])
retval = hal.loadOneExtension(ext)
if retval != 0:
logger.warn("loading extension may have failed (error=%d)", retval)
from ._ext._halsim_gui_ext import _kill_on_signal
_kill_on_signal()

View File

@@ -0,0 +1,57 @@
[build-system]
build-backend = "hatchling.build"
requires = [
"semiwrap~=0.2.1",
"hatch-meson~=0.1.0",
"hatch-robotpy~=0.2.1",
"hatchling",
"robotpy-wpiutil==0.0.0",
"robotpy-wpimath==0.0.0",
"robotpy-hal==0.0.0",
"pyntcore==0.0.0",
]
[project]
name = "robotpy-halsim-gui"
version = "0.0.0"
description = "WPILib simulation GUI"
authors = [
{name = "RobotPy Development Team", email = "robotpy@googlegroups.com"},
]
license = "BSD-3-Clause"
dependencies = [
"robotpy-wpiutil==0.0.0",
"robotpy-wpimath==0.0.0",
"robotpy-hal==0.0.0",
"pyntcore==0.0.0",
]
[project.urls]
"Source code" = "https://github.com/robotpy/mostrobotpy"
[tool.hatch.build.hooks.robotpy]
version_file = "halsim_gui/version.py"
[[tool.hatch.build.hooks.robotpy.maven_lib_download]]
artifact_id = "halsim_gui"
group_id = "org.wpilib.halsim"
repo_url = ""
version = "0.0.0"
use_headers = true
libs = ["halsim_gui"]
extract_to = "halsim_gui"
[tool.hatch.build.hooks.semiwrap]
[tool.hatch.build.hooks.meson]
[tool.hatch.build.targets.wheel]
packages = ["halsim_gui"]
[tool.semiwrap]
[tool.semiwrap.extension_modules."halsim_gui._ext._halsim_gui_ext"]
name = "halsim_gui_ext"
depends = ["wpihal", "wpimath", "ntcore"]

View File

@@ -0,0 +1,20 @@
import ctypes
import pathlib
def test_halsim_gui():
# dependencies
import wpinet
import hal
import wpimath
import ntcore
import halsim_gui as base
loaded = 0
for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir():
if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"):
ctypes.CDLL(str(fname))
loaded += 1
assert loaded

View File

@@ -1,6 +1,10 @@
load("@allwpilib_pip_deps//:requirements.bzl", "requirement")
load("@rules_python//python:defs.bzl", "py_library")
load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library")
load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project")
load("//shared/bazel/rules:publishing.bzl", "host_architectures")
load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file")
load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test")
wpilib_cc_library(
name = "halsim_ws_core",
@@ -54,3 +58,39 @@ package_default_cc_project(
maven_artifact_name = "halsim_ws_core",
maven_group_id = "org.wpilib.halsim",
)
copy_native_file(
name = "halsim_ws_client",
base_path = "src/main/python/halsim_ws/client/",
library = "//simulation/halsim_ws_client:shared/halsim_ws_client",
)
copy_native_file(
name = "halsim_ws_server",
base_path = "src/main/python/halsim_ws/server/",
library = "//simulation/halsim_ws_server:shared/halsim_ws_server",
)
py_library(
name = "robotpy-halsim-ws",
srcs = glob(["src/main/python/**/*.py"]),
data = [
":halsim_ws_client.copy_lib",
":halsim_ws_server.copy_lib",
],
imports = ["src/main/python"],
visibility = ["//visibility:public"],
deps = [
"//hal:robotpy-native-wpihal",
"//wpinet:robotpy-native-wpinet",
],
)
robotpy_py_test(
"python_tests",
srcs = glob(["src/test/python/**/*.py"]),
deps = [
":robotpy-halsim-ws",
requirement("pytest"),
],
)

View File

@@ -0,0 +1,19 @@
robotpy-halsim-ws
==================
Installing this package will allow you to utilize the 2020+ WPILib websim from a RobotPy program.
Usage
-----
First, install pyfrc. Then run your robot with the 'sim' argument and --ws-server or --ws-client flag:
# Windows
py -3 -m robotpy sim --ws-server
py -3 -m robotpy sim --ws-client
# Linux/OSX
python3 -m robotpy sim --ws-server
python3 -m robotpy sim --ws-client
WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/

View File

@@ -0,0 +1 @@
from .main import loadExtension

View File

@@ -0,0 +1,23 @@
import logging
import os
from os.path import abspath, dirname, join
logger = logging.getLogger("halsim_ws.client")
def loadExtension():
try:
import hal
except ImportError as e:
# really, should never happen...
raise ImportError("you must install robotpy-hal!") from e
from ..version import version
logger.info("WPILib HAL Simulation websim client %s", version)
root = join(abspath(dirname(__file__)), "lib")
ext = join(root, os.listdir(root)[0])
retval = hal.loadOneExtension(ext)
if retval != 0:
logger.warn("loading extension may have failed (error=%d)", retval)

View File

@@ -0,0 +1 @@
from .main import loadExtension

View File

@@ -0,0 +1,23 @@
import logging
import os
from os.path import abspath, dirname, join
logger = logging.getLogger("halsim_ws.server")
def loadExtension():
try:
import hal
except ImportError as e:
# really, should never happen...
raise ImportError("you must install robotpy-hal!") from e
from ..version import version
logger.info("WPILib HAL Simulation websim server %s", version)
root = join(abspath(dirname(__file__)), "lib")
ext = join(root, os.listdir(root)[0])
retval = hal.loadOneExtension(ext)
if retval != 0:
logger.warn("loading extension may have failed (error=%d)", retval)

View File

@@ -0,0 +1,55 @@
[build-system]
build-backend = "hatchling.build"
requires = [
"hatchling",
"hatch-robotpy~=0.2.1",
]
[project]
name = "robotpy-halsim-ws"
version = "0.0.0"
description = "WPILib simulator websim Extensions"
authors = [
{name = "RobotPy Development Team", email = "robotpy@googlegroups.com"},
]
license = "BSD-3-Clause"
dependencies = [
"robotpy-native-wpihal==0.0.0",
"robotpy-native-wpinet==0.0.0",
]
[project.entry-points."robotpy_sim.2027"]
ws-server = "halsim_ws.server"
ws-client = "halsim_ws.client"
[project.urls]
"Source code" = "https://github.com/robotpy/mostrobotpy"
[tool.hatch.build.hooks.robotpy]
version_file = "halsim_ws/version.py"
[[tool.hatch.build.hooks.robotpy.maven_lib_download]]
artifact_id = "halsim_ws_server"
group_id = "org.wpilib.halsim"
repo_url = ""
version = "0.0.0"
use_headers = false
extract_to = "halsim_ws/server"
libs = ["halsim_ws_server"]
[[tool.hatch.build.hooks.robotpy.maven_lib_download]]
artifact_id = "halsim_ws_client"
group_id = "org.wpilib.halsim"
repo_url = ""
version = "0.0.0"
use_headers = false
extract_to = "halsim_ws/client"
libs = ["halsim_ws_client"]
[tool.hatch.build.targets.wheel]
packages = ["halsim_ws"]

View File

@@ -0,0 +1,18 @@
import ctypes
import pathlib
def test_halsim_ws_client():
# dependencies
import native.wpihal._init_robotpy_native_wpihal
import native.wpinet._init_robotpy_native_wpinet
import halsim_ws.client as base
loaded = 0
for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir():
if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"):
ctypes.CDLL(str(fname))
loaded += 1
assert loaded

View File

@@ -0,0 +1,18 @@
import ctypes
import pathlib
def test_halsim_ws_server():
# dependencies
import native.wpihal._init_robotpy_native_wpihal
import native.wpinet._init_robotpy_native_wpinet
import halsim_ws.server as base
loaded = 0
for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir():
if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"):
ctypes.CDLL(str(fname))
loaded += 1
assert loaded

View File

@@ -270,7 +270,11 @@ define_pybind_library(
robotpy_py_test(
"python_tests",
srcs = glob(["src/test/python/**/*.py"]),
srcs = glob(
["src/test/python/**/*.py"],
# TODO(pjreiniger) This excluded test needs the ENTRY_POINT hooks (i.e. have a dependency on a wheel)
exclude = ["src/test/python/test_pytest_plugins.py"],
),
tags = ["exclusive"],
deps = [
":robotpy-wpilib",

View File

@@ -1,5 +1,6 @@
# THIS FILE IS AUTO GENERATED
load("@allwpilib_pip_deps//:requirements.bzl", "requirement")
load("//shared/bazel/rules/gen:gen-version-file.bzl", "generate_version_file")
load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "create_pybind_library", "robotpy_library")
load("//shared/bazel/rules/robotpy:semiwrap_helpers.bzl", "gen_libinit", "gen_modinit_hpp", "gen_pkgconf", "resolve_casters", "run_header_gen")
@@ -1636,6 +1637,9 @@ def define_pybind_library(name, pkgcfgs = []):
"//wpilibc:robotpy-native-wpilib",
"//wpimath:robotpy-wpimath",
"//wpiutil:robotpy-wpiutil",
requirement("pytest"),
requirement("pytest-reraise"),
requirement("robotpy-cli"),
],
visibility = ["//visibility:public"],
)

View File

@@ -26,14 +26,21 @@ dependencies = [
"robotpy-wpimath==0.0.0",
"robotpy-hal==0.0.0",
"pyntcore==0.0.0",
"robotpy-cli~=2027.0.0a1"
"robotpy-cli~=2027.0.0a1",
# For running robot tests
"pytest>=3.9",
"pytest-reraise",
]
[project.urls]
"Source code" = "https://github.com/robotpy/mostrobotpy"
[project.entry-points."robotpy_cli.2027"]
run = "wpilib._impl.start:Main"
add-tests = "wpilib._impl.cli_add_tests:AddTests"
run = "wpilib._impl.cli_run:Main"
sim = "wpilib._impl.cli_sim:RobotSim"
test = "wpilib._impl.cli_test:RobotTest"
[tool.hatch.build.hooks.robotpy]

View File

@@ -18,9 +18,7 @@ classes:
wpi::units::second_t:
LoopFunc:
SimulationInit:
internal: true
SimulationPeriodic:
internal: true
DisabledExit:
AutonomousExit:
TeleopExit:
@@ -38,7 +36,8 @@ classes:
This class provides the following functions which are called by the main
loop, StartCompetition(), at the appropriate times:
RobotInit() -- provide for initialization at robot power-on
DriverStationConnected() -- provide for initialization the first time the DS
is connected
Init() functions -- each of the following functions is called once when the
appropriate mode is entered:

View File

@@ -228,6 +228,4 @@ try:
except ImportError:
__version__ = "master"
from ._impl.main import run
__all__ += ["CameraServer", "run"]
__all__ += ["CameraServer"]

View File

@@ -0,0 +1,51 @@
import pathlib
import sys
builtin_tests = """'''
This test module imports tests that come with wpilib, and can be used
to test basic functionality of just about any robot.
'''
from wpilib.testing.robot_tests import *
"""
class AddTests:
"""
Adds default robot tests to your robot project directory
"""
def __init__(self, parser=None):
pass
def run(self, main_file: pathlib.Path, project_path: pathlib.Path):
if not main_file.exists():
print(
f"ERROR: is this a robot project? {main_file} does not exist",
file=sys.stderr,
)
return 1
try_dirs = [project_path / "tests", project_path / ".." / "tests"]
test_directory = try_dirs[0]
for d in try_dirs:
if d.exists():
test_directory = d
break
else:
test_directory.mkdir(parents=True)
print(f"Tests directory is {test_directory}")
print()
builtin_tests_file = test_directory / "robot_test.py"
if builtin_tests_file.exists():
print("- robot_test.py already exists")
else:
with open(builtin_tests_file, "w") as fp:
fp.write(builtin_tests)
print("- builtin tests created at", builtin_tests_file)
print()
print("Robot tests can be ran via 'python3 -m robotpy test'")

View File

@@ -0,0 +1,10 @@
class Main:
"""
Executes the robot code using the currently installed HAL (this is probably not what you want unless this is controlling the physical robot)
"""
def __init__(self, parser):
pass
def run(self, options, robot_class, **static_options):
return robot_class.main(robot_class)

View File

@@ -0,0 +1,78 @@
import os
import argparse
import importlib.metadata
import logging
import sys
import typing
import wpilib
logger = logging.getLogger("robot.sim")
class RobotSim:
"""
Runs the robot in simulation mode
"""
def __init__(self, parser: argparse.ArgumentParser):
parser.add_argument(
"--nogui",
default=False,
action="store_true",
help="Don't use the WPIlib simulation gui",
)
self.simexts = {}
for entry_point in importlib.metadata.entry_points(group="robotpy_sim.2027"):
try:
sim_ext_module = entry_point.load()
except ImportError:
print(f"WARNING: Error detected in {entry_point}", file=sys.stderr)
continue
self.simexts[entry_point.name] = sim_ext_module
try:
cmd_help = importlib.metadata.metadata(entry_point.dist.name)["summary"]
except AttributeError:
cmd_help = "Load specified simulation extension"
parser.add_argument(
f"--{entry_point.name}",
default=False,
action="store_true",
help=cmd_help,
)
def run(
self,
options: argparse.Namespace,
nogui: bool,
robot_class: typing.Type[wpilib.RobotBase],
):
if not nogui:
try:
import halsim_gui
except ImportError:
print("robotpy-halsim-gui is not installed!", file=sys.stderr)
exit(1)
else:
halsim_gui.loadExtension()
# Some extensions (gui) changes the current directory
cwd = os.getcwd()
for name, module in self.simexts.items():
if getattr(options, name.replace("-", "_"), False):
try:
module.loadExtension()
except:
print(f"Error loading {name}!", file=sys.stderr)
raise
os.chdir(cwd)
# run the robot
return robot_class.main(robot_class)

View File

@@ -0,0 +1,244 @@
import logging
import os
from os.path import abspath
import inspect
import pathlib
import sys
import tomllib
import typing
import wpilib
import pytest
from ..testing import pytest_plugin
# TODO: setting the plugins so that the end user can invoke pytest directly
# could be a useful thing. Will have to consider that later.
logger = logging.getLogger("test")
class _TryAgain(Exception):
pass
#
# main test class
#
class RobotTest:
"""
Executes unit tests on the robot code using a special pytest plugin
"""
def __init__(self, parser=None):
if parser:
parser.add_argument(
"--builtin",
default=False,
action="store_true",
help="Use builtin tests if no tests are specified",
)
isolation_group = parser.add_mutually_exclusive_group()
isolation_group.add_argument(
"--isolated",
default=None,
dest="isolated",
action="store_true",
help="Run each test in a separate robot process (default). Set `tool.robotpy.testing.isolated` in your pyproject.toml to control the default",
)
isolation_group.add_argument(
"--no-isolation",
dest="isolated",
action="store_false",
help="Disable isolated test mode and run tests in-process",
)
parser.add_argument(
"--coverage-mode",
default=False,
action="store_true",
help="This flag is passed when trying to determine coverage",
)
parser.add_argument(
"pytest_args",
nargs="*",
help="To pass args to pytest, specify --<space>, then the args",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=-1,
help="Maximum isolated robot processes (default: max CPUs - 1)",
)
def run(
self,
main_file: pathlib.Path,
project_path: pathlib.Path,
robot_class: typing.Type[wpilib.RobotBase],
builtin: bool,
isolated: typing.Optional[bool],
coverage_mode: bool,
verbose: bool,
pytest_args: typing.List[str],
jobs: int,
):
if isolated is None:
pyproject_path = project_path / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as fp:
d = tomllib.load(fp)
try:
v = d["tool"]["robotpy"]["testing"]["isolated"]
except KeyError:
pass
else:
if not isinstance(v, bool):
raise ValueError(
f"tool.robotpy.testing.isolated must be a boolean value (got {v})"
)
isolated = v
if isolated is None:
isolated = True
try:
return self._run_test(
main_file,
project_path,
robot_class,
builtin,
isolated,
coverage_mode,
verbose,
pytest_args,
jobs,
)
except _TryAgain:
return self._run_test(
main_file,
project_path,
robot_class,
builtin,
isolated,
coverage_mode,
verbose,
pytest_args,
jobs,
)
def _run_test(
self,
main_file: pathlib.Path,
project_path: pathlib.Path,
robot_class: typing.Type[wpilib.RobotBase],
builtin: bool,
isolated: bool,
coverage_mode: bool,
verbose: bool,
pytest_args: typing.List[str],
jobs: int,
):
# find test directory, change current directory so pytest can find the tests
# -> assume that tests reside in tests or ../tests
curdir = pathlib.Path.cwd().absolute()
self.try_dirs = [
((project_path / "tests").absolute(), False),
((project_path / ".." / "tests").absolute(), True),
]
for d, chdir in self.try_dirs:
if d.exists():
builtin = False
if chdir:
os.chdir(d)
break
else:
if not builtin:
print("ERROR: Cannot run robot tests, as test directory was not found!")
retv = self._no_tests(main_file, project_path)
return 1
from ..testing import robot_tests
pytest_args.insert(0, abspath(inspect.getfile(robot_tests)))
try:
if isolated:
from ..testing import pytest_isolated_tests_plugin
retv = pytest.main(
pytest_args,
plugins=[
pytest_isolated_tests_plugin.IsolatedTestsPlugin(
robot_class, main_file, builtin, verbose, jobs
)
],
)
else:
retv = pytest.main(
pytest_args,
plugins=[
pytest_plugin.RobotTestingPlugin(robot_class, main_file, False)
],
)
finally:
os.chdir(curdir)
# requires pytest 2.8.x
if retv == 5:
print()
print("ERROR: a tests directory was found, but no tests were defined")
retv = self._no_tests(main_file, project_path, retv)
return retv
def _no_tests(
self, main_file: pathlib.Path, project_path: pathlib.Path, r: int = 1
):
print()
print("Looked for tests at:")
for d, _ in self.try_dirs:
print("-", d)
print()
print(
"If you don't want to write your own tests, wpilib comes with generic tests"
)
print("that can test basic functionality of most robots. You can run them by")
print("specifying the --builtin option.")
print()
if not sys.stdin.isatty():
print(
"Alternatively, to create a tests directory with the builtin tests, you can run:"
)
print()
print(" python3 -m robotpy add-tests")
print()
else:
if yesno("Create a tests directory with builtin tests now?"):
from .cli_add_tests import AddTests
add_tests = AddTests()
add_tests.run(main_file, project_path)
raise _TryAgain()
return r
def yesno(prompt):
"""Returns True if user answers 'y'"""
prompt += " [y/n]"
a = ""
while a not in ["y", "n"]:
a = input(prompt).lower()
return a == "y"

View File

@@ -1,18 +0,0 @@
import inspect
import sys
def run(robot_class, **kwargs):
"""
``wpilib.run`` is no longer used. You should run your robot code via one of
the following methods instead:
* Windows: ``py -m robotpy [arguments]``
* Linux/macOS: ``python -m robotpy [arguments]``
In a virtualenv the ``robotpy`` command can be used directly.
"""
msg = inspect.cleandoc(inspect.getdoc(run) or "`wpilib.run` is no longer used")
print(msg, file=sys.stderr)
sys.exit(1)

View File

@@ -77,18 +77,6 @@ def _log_versions(robotpy_version: typing.Optional[str]):
logger.debug("%s version %s", k, v)
class Main:
"""
Executes the robot code using the currently installed HAL (this is probably not what you want unless you're on the roboRIO)
"""
def __init__(self, parser):
pass
def run(self, options, robot_class, **static_options):
return robot_class.main(robot_class)
class RobotStarter:
def __init__(self):
self.logger = logging.getLogger("robotpy")

View File

@@ -6,6 +6,7 @@ __all__ = ["OpModeRobot"]
from ._wpilib import OpModeRobotBase, OpMode
class OpModeRobot(OpModeRobotBase):
"""
OpModeRobot implements the opmode-based robot program framework.
@@ -23,14 +24,16 @@ class OpModeRobot(OpModeRobotBase):
def __init__(self):
super().__init__()
def addOpMode(self,
opmodeCls: type,
mode: RobotMode,
name: str,
group: Optional[str] = None,
description: Optional[str] = None,
textColor: Optional[Color] = None,
backgroundColor: Optional[Color] = None) -> None:
def addOpMode(
self,
opmodeCls: type,
mode: RobotMode,
name: str,
group: Optional[str] = None,
description: Optional[str] = None,
textColor: Optional[Color] = None,
backgroundColor: Optional[Color] = None,
) -> None:
"""
Adds an operating mode option. It's necessary to call PublishOpModes() to
make the added modes visible to the driver station.
@@ -48,6 +51,7 @@ class OpModeRobot(OpModeRobotBase):
:param textColor: text color
:param backgroundColor: background color
"""
def makeOpModeInstance() -> OpMode:
# Try to instantiate with robot argument first
try:
@@ -55,7 +59,18 @@ class OpModeRobot(OpModeRobotBase):
except TypeError:
# Fallback to no-argument constructor
return opmodeCls() # type: ignore
if textColor is None or backgroundColor is None:
self.addOpModeFactory(makeOpModeInstance, mode, name, group or "", description or "")
self.addOpModeFactory(
makeOpModeInstance, mode, name, group or "", description or ""
)
else:
self.addOpModeFactory(makeOpModeInstance, mode, name, group or "", description or "", textColor, backgroundColor)
self.addOpModeFactory(
makeOpModeInstance,
mode,
name,
group or "",
description or "",
textColor,
backgroundColor,
)

View File

@@ -0,0 +1,163 @@
import contextlib
import time
import threading
import typing as T
import pytest
from .. import RobotBase
from ..simulation import (
DriverStationSim,
stepTiming,
stepTimingAsync,
getProgramStarted,
)
from hal._wpiHal import _RobotMode as RobotMode
class RobotTestController:
"""
Use this object to control the robot's state during tests
"""
def __init__(self, reraise, robot: RobotBase):
self._reraise = reraise
self._thread: T.Optional[threading.Thread] = None
self._robot = robot
self._cond = threading.Condition()
self._robot_started = False
self._robot_finished = False
def _robot_thread(self, robot: RobotBase):
with self._cond:
self._robot_started = True
self._cond.notify_all()
with self._reraise(catch=True):
assert robot is not None # shouldn't happen...
try:
robot.startCompetition()
assert self._robot_finished
finally:
# always call endCompetition or python hangs
robot.endCompetition()
del robot
@contextlib.contextmanager
def run_robot(self):
"""
Use this in a "with" statement to start your robot code at the
beginning of the with block, and end your robot code at the end
of the with block.
"""
# remove robot reference so it gets cleaned up when gc.collect() is called
robot = self._robot
assert robot is not None
self._robot = None
self._thread = th = threading.Thread(
target=self._robot_thread, args=(robot,), daemon=True
)
th.start()
with self._cond:
# make sure the thread didn't die
assert self._cond.wait_for(lambda: self._robot_started, timeout=1)
# This is the same thing that waitForProgramStart does
for _ in range(1000):
if getProgramStarted():
break
time.sleep(0.001)
else:
assert False, "robot never started"
try:
# in this block you should tell the sim to do sim things
yield
finally:
self._robot_finished = True
robot.endCompetition()
if isinstance(self._reraise.exception, RuntimeError):
if str(self._reraise.exception).startswith(
"HAL: A handle parameter was passed incorrectly"
):
msg = (
"Do not reuse HAL objects in tests! This error may occur if you"
" stored a motor/sensor as a global or as a class variable"
" outside of a method."
)
if hasattr(Exception, "add_note"):
self._reraise.exception.add_note(f"*** {msg}")
else:
e = self._reraise.exception
self._reraise.reset()
raise RuntimeError(msg) from e
# Increment time by 1 second to ensure that any notifiers fire
stepTimingAsync(1.0)
# the robot thread should exit quickly
th.join(timeout=1)
if th.is_alive():
pytest.fail("robot did not exit within 2 seconds")
self._robot = None
self._thread = None
@property
def robot_is_alive(self) -> bool:
"""
True if the robot code is alive
"""
th = self._thread
if not th:
return False
return th.is_alive()
def step_timing(
self,
*,
seconds: float,
autonomous: bool,
enabled: bool,
assert_alive: bool = True,
) -> float:
"""
This utility will increment simulated time, while pretending that
there's a driver station connected and delivering new packets
every 0.2 seconds.
:param seconds: Number of seconds to run (will step in increments of 0.2)
:param autonomous: Tell the robot that it is in autonomous mode
:param enabled: Tell the robot that it is enabled
:returns: Number of seconds time was incremented
"""
assert self.robot_is_alive, "did you call control.run_robot()?"
assert seconds > 0
DriverStationSim.setDsAttached(True)
DriverStationSim.setRobotMode(
RobotMode.AUTONOMOUS if autonomous else RobotMode.TELEOPERATED
)
DriverStationSim.setEnabled(enabled)
tm = 0.0
while tm < seconds + 0.01:
DriverStationSim.notifyNewData()
stepTiming(0.2)
if assert_alive:
assert self.robot_is_alive
tm += 0.2
return tm

View File

@@ -0,0 +1,461 @@
import dataclasses
import logging
import multiprocessing
import multiprocessing.connection
import os
import pathlib
import signal
import sys
import time
import typing as T
import pytest
import robotpy.main
import wpilib
from .pytest_plugin import RobotTestingPlugin
def _enable_faulthandler():
#
# In the event of a segfault, faulthandler will dump the currently
# active stack so you can figure out what went wrong.
#
# Additionally, on non-Windows platforms we register a SIGUSR2
# handler -- if you send the robot process a SIGUSR2, then
# faulthandler will dump all of your current stacks. This can
# be really useful for figuring out things like deadlocks.
#
import logging
logger = logging.getLogger("faulthandler")
try:
# These should work on all platforms
import faulthandler
faulthandler.enable()
except Exception as e:
logger.warning("Could not enable faulthandler: %s", e)
return
try:
faulthandler.register(signal.SIGUSR2)
logger.info("registered SIGUSR2 for PID %s", os.getpid())
except Exception:
return
class WorkerPlugin:
"""
This pytest plugin runs in the isolated process that runs a test that uses the
robot fixture.
Heavily borrowed from pytest-xdist WorkerInteractor
"""
def __init__(self, channel: multiprocessing.connection.Connection):
self.channel = channel
def sendevent(self, name: str, **kwargs: object):
self.channel.send((name, kwargs))
@pytest.hookimpl(wrapper=True)
def pytest_sessionstart(self, session: pytest.Session):
self.config = session.config
return (yield)
@pytest.hookimpl
def pytest_internalerror(self, excrepr: object):
formatted_error = str(excrepr)
for line in formatted_error.split("\n"):
print("IERROR>", line, file=sys.stderr)
self.sendevent("internal_error", formatted_error=formatted_error)
@pytest.hookimpl
def pytest_runtest_logstart(
self,
nodeid: str,
location: tuple[str, int | None, str],
):
self.sendevent("logstart", nodeid=nodeid, location=location)
@pytest.hookimpl
def pytest_runtest_logfinish(
self,
nodeid: str,
location: tuple[str, int | None, str],
):
self.sendevent("logfinish", nodeid=nodeid, location=location)
@pytest.hookimpl
def pytest_runtest_logreport(self, report: pytest.TestReport):
data = self.config.hook.pytest_report_to_serializable(
config=self.config, report=report
)
self.sendevent("testreport", data=data)
def _run_test(
item_nodeid, config_args, robot_class, robot_file, verbose, pipe, root_path
):
"""This function runs in a subprocess"""
logging.root.addHandler(logging.NullHandler())
logging.root.setLevel(logging.DEBUG if verbose else logging.INFO)
_enable_faulthandler()
# This is used by getDeployDirectory, so make sure it gets fixed
robotpy.main.robot_py_path = robot_file
os.chdir(root_path)
# keep the plugins around because it has a reference to the robot
# and we don't want it to die and deadlock
plugin = RobotTestingPlugin(robot_class, robot_file, True)
worker_plugin = WorkerPlugin(pipe)
ec = pytest.main(
[item_nodeid, "--no-header", "-p", "no:terminalreporter", *config_args],
plugins=[plugin, worker_plugin],
)
# ensure output is printed out
sys.stdout.flush()
# Don't let the process die, let the parent kill us to avoid
# python interpreter badness
worker_plugin.sendevent("finished", exit_code=ec)
pipe.close()
# ensure that the gc doesn't collect the plugin..
while plugin:
time.sleep(100)
@dataclasses.dataclass
class IsolatedTestJob:
item: pytest.Function
conn: multiprocessing.connection.Connection
process: multiprocessing.Process
start_time: float
exit_code: int | None = None
finished: bool = False
# set when the worker indicates it has finished
worker_completed: bool = False
def set_exit_code(self, ec: int):
if self.exit_code is None:
self.exit_code = ec
class IsolatedTestsPlugin:
"""
This pytest plugin runs any test that uses the 'robot' fixture in an
isolated subprocess
"""
def __init__(
self,
robot_class: T.Type[wpilib.RobotBase],
robot_file: pathlib.Path,
builtin_tests: bool,
verbose: bool,
parallelism: int,
):
self._robot_class = robot_class
self._robot_file = robot_file
self._builtin_tests = builtin_tests
self._verbose = verbose
if parallelism < 1:
try:
parallelism = multiprocessing.cpu_count() - 1
except NotImplementedError:
parallelism = 1
self._parallelism = max(1, parallelism)
self._shouldstop = False
@pytest.hookimpl(wrapper=True)
def pytest_sessionstart(self, session: pytest.Session):
self._config = session.config
self._maxfail: int = self._config.getvalue("maxfail")
self._countfailures = 0
self._shouldstop = False
multiprocessing.set_start_method("spawn")
return (yield)
@pytest.hookimpl
def pytest_runtestloop(self, session: pytest.Session) -> bool:
if (
session.testsfailed
and not session.config.option.continue_on_collection_errors
):
raise session.Interrupted(
f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection"
)
if session.config.option.collectonly:
return True
running: list[IsolatedTestJob] = []
deferred: list[pytest.Function] = []
try:
# Start any tests that use the robot fixture first. Tests that don't
# use the robot fixture will be ran later
for item in session.items:
assert isinstance(item, pytest.Function)
if "robot" not in item.fixturenames:
deferred.append(item)
continue
while len(running) >= self._parallelism:
self._wait_for_jobs(running, session)
running.append(self._start_isolated_test(item))
self._maybe_raise(session)
# Run the in-process tests now while the robot tests are finishing
for idx, item in enumerate(deferred):
nextitem = deferred[idx + 1] if idx + 1 < len(deferred) else None
session.config.hook.pytest_runtest_protocol(
item=item, nextitem=nextitem
)
self._maybe_raise(session)
while running:
self._wait_for_jobs(running, session)
finally:
for job in running:
self._cleanup_job(job)
return True
def _start_isolated_test(self, item: pytest.Function) -> IsolatedTestJob:
config_args = self._config.invocation_params.args
if self._builtin_tests:
nodeid = f"{config_args[0]}::{item.name}"
config_args = config_args[1:]
else:
nodeid = item.nodeid
pconn, cconn = multiprocessing.Pipe()
process = multiprocessing.Process(
target=_run_test,
args=(
nodeid,
config_args,
self._robot_class,
self._robot_file,
self._verbose,
cconn,
self._config.rootpath,
),
)
process.start()
cconn.close()
return IsolatedTestJob(
item=item,
conn=pconn,
process=process,
start_time=time.time(),
)
def _wait_for_jobs(self, running: list[IsolatedTestJob], session: pytest.Session):
if not running:
return
ready = multiprocessing.connection.wait([job.conn for job in running])
for conn in ready:
job = next(job for job in running if job.conn == conn)
self._process_job_messages(job, session)
if job.finished:
running.remove(job)
self._finalize_job(job, session)
def _process_job_messages(self, job: IsolatedTestJob, session: pytest.Session):
while not job.finished:
try:
if not job.conn.poll():
break
callname, kwargs = job.conn.recv()
except (IOError, EOFError) as e:
job.finished = True
break
method = "worker_" + callname
call = getattr(self, method)
call(job, **kwargs)
self._maybe_raise(session)
if not job.process.is_alive():
job.finished = True
def _finalize_job(self, job: IsolatedTestJob, session: pytest.Session):
self._cleanup_job(job)
if job.worker_completed:
return
stop = time.time()
duration = stop - job.start_time
ec = job.exit_code
longrepr = None
if ec is None:
longrepr = "subprocess failed for unknown reason"
else:
if ec < 0:
try:
signal_name = signal.strsignal(-ec)
longrepr = f"subprocess exited due to signal {-ec}: {signal_name}"
except ValueError:
pass
if longrepr is None:
longrepr = f"subprocess exited with exit code {ec}"
report = pytest.TestReport(
nodeid=job.item.nodeid,
location=job.item.location,
keywords=job.item.keywords,
outcome="failed",
longrepr=longrepr,
when="call",
duration=duration,
start=job.start_time,
stop=stop,
)
self._config.hook.pytest_runtest_logstart(
nodeid=job.item.nodeid, location=job.item.location
)
self._config.hook.pytest_runtest_logreport(report=report)
self._config.hook.pytest_runtest_logfinish(
nodeid=job.item.nodeid, location=job.item.location
)
self._maybe_raise(session)
def _cleanup_job(self, job: IsolatedTestJob):
try:
job.conn.close()
except Exception:
pass
if job.process.is_alive():
job.process.kill()
try:
job.process.join(timeout=1)
except TimeoutError:
pass
ec = job.process.exitcode
if ec is not None:
job.set_exit_code(ec)
job.process.close()
def _maybe_raise(self, session: pytest.Session):
if self._shouldstop:
raise session.Interrupted(self._shouldstop)
if session.shouldfail:
raise session.Failed(session.shouldfail)
if session.shouldstop:
raise session.Interrupted(session.shouldstop)
#
# Worker dispatch functions (copied from pytest-xdist)
#
def worker_logstart(
self,
job: IsolatedTestJob,
nodeid: str,
location: tuple[str, int | None, str],
):
"""Emitted when a node calls the pytest_runtest_logstart hook."""
if self._config.option.verbose > 0:
return
self._config.hook.pytest_runtest_logstart(nodeid=nodeid, location=location)
def worker_logfinish(
self,
job: IsolatedTestJob,
nodeid: str,
location: tuple[str, int | None, str],
):
"""Emitted when a node calls the pytest_runtest_logfinish hook."""
if self._config.option.verbose > 0:
return
self._config.hook.pytest_runtest_logfinish(nodeid=nodeid, location=location)
def worker_testreport(self, job: IsolatedTestJob, data: object):
"""Emitted when a node calls the pytest_runtest_logreport hook."""
report = self._config.hook.pytest_report_from_serializable(
config=self._config, data=data
)
self._config.hook.pytest_runtest_logreport(report=report)
self._handlefailures(report)
def worker_internal_error(self, job: IsolatedTestJob, formatted_error: str):
"""Emitted when a node calls the pytest_internalerror hook."""
for line in formatted_error.split("\n"):
print("IERROR>", line, file=sys.stderr)
job.finished = True
if not self._shouldstop:
self._shouldstop = "internal error in worker"
def worker_finished(self, job: IsolatedTestJob, exit_code: object | None = None):
"""Emitted when a node finishes running."""
if exit_code is not None:
job.exit_code = int(exit_code)
job.worker_completed = True
job.finished = True
def _handlefailures(self, rep: pytest.TestReport):
if rep.failed:
self._countfailures += 1
if (
self._maxfail
and self._countfailures >= self._maxfail
and not self._shouldstop
):
self._shouldstop = f"stopping after {self._countfailures} failures"
#
# These fixtures match the ones in RobotTestingPlugin but these have no effect
#
@pytest.fixture(scope="function")
def robot(self):
pass
@pytest.fixture(scope="function")
def control(self, reraise, robot):
pass
@pytest.fixture()
def robot_file(self) -> pathlib.Path:
"""The absolute filename your robot code is started from"""
return self._robot_file
@pytest.fixture()
def robot_path(self) -> pathlib.Path:
"""The absolute directory that your robot code is located at"""
return self._robot_file.parent

View File

@@ -0,0 +1,145 @@
import gc
import pathlib
import typing as T
import weakref
import pytest
import hal
import hal.simulation
from hal._wpiHal import _RobotMode as RobotMode
import ntcore
import wpilib
from wpilib.simulation import DriverStationSim, pauseTiming, restartTiming
import wpilib.simulation
# TODO: get rid of special-casing.. maybe should register a HAL shutdown hook or something
try:
import commands2
except ImportError:
commands2 = None
from .controller import RobotTestController
class RobotTestingPlugin:
"""
Pytest plugin. Each documented member function name can be an argument
to your test functions, and the data that these functions return will
be passed to your test function.
"""
def __init__(
self,
robot_class: T.Type[wpilib.RobotBase],
robot_file: pathlib.Path,
isolated: bool,
):
self.isolated = isolated
self._robot_file = robot_file
self._robot_class = robot_class
#
# Fixtures
#
# Each one of these can be arguments to your test, and the result of the
# corresponding function will be passed to your test as that argument.
#
@pytest.fixture(scope="function")
def robot(self):
"""
Your robot instance
.. note:: RobotPy/WPILib testing infrastructure is really sensitive
to ensuring that things get cleaned up properly. Make sure
that you don't store references to your robot or other
WPILib objects in a global or static context.
"""
#
# This function needs to do the same things that RobotBase.main does
# plus some extra things needed for testing
#
# Previously this was separate from robot fixture, but we need to
# ensure that the robot cleanup happens deterministically relative to
# when handle cleanup/etc happens, otherwise unnecessary HAL errors will
# bubble up to the user
#
nt_inst = ntcore.NetworkTableInstance.getDefault()
nt_inst.startLocal()
pauseTiming()
restartTiming()
wpilib.DriverStation.silenceJoystickConnectionWarning(True)
DriverStationSim.setRobotMode(RobotMode.AUTONOMOUS)
DriverStationSim.setEnabled(False)
DriverStationSim.notifyNewData()
# Create the user's robot instance
robot = self._robot_class()
# Tests only get a proxy to ensure cleanup is more reliable
yield weakref.proxy(robot)
# If running in separate processes, no need to do cleanup
if self.isolated:
# .. and funny enough, in isolated mode we *don't* want the
# robot to be cleaned up, as that can deadlock
self._saved_robot = robot
return
# HACK: avoid motor safety deadlock
wpilib.simulation._simulation._resetMotorSafety()
del robot
if commands2 is not None:
commands2.CommandScheduler.resetInstance()
# Double-check all objects are destroyed so that HAL handles are released
gc.collect()
# shutdown networktables before other kinds of global cleanup
# -> some reset functions will re-register listeners, so it's important
# to do this before so that the listeners are active on the current
# NetworkTables instance
nt_inst.stopLocal()
nt_inst._reset()
# Cleanup WPILib globals
# -> preferences, SmartDashboard, MotorSafety
wpilib.simulation._simulation._resetWpilibSimulationData()
wpilib._wpilib._clearSmartDashboardData()
# Cancel all periodic callbacks
hal.simulation.cancelAllSimPeriodicCallbacks()
# Reset the HAL handles
hal.simulation.resetGlobalHandles()
# Reset the HAL data
hal.simulation.resetAllSimData()
# Don't call HAL shutdown! This is only used to cleanup HAL extensions,
# and functions will only be called the first time (unless re-registered)
# hal.shutdown()
@pytest.fixture(scope="function")
def control(self, reraise, robot: wpilib.RobotBase) -> RobotTestController:
"""
A pytest fixture that provides control over your robot
"""
return RobotTestController(reraise, robot)
@pytest.fixture()
def robot_file(self) -> pathlib.Path:
"""The absolute filename your robot code is started from"""
return self._robot_file
@pytest.fixture()
def robot_path(self) -> pathlib.Path:
"""The absolute directory that your robot code is located at"""
return self._robot_file.parent

View File

@@ -0,0 +1,74 @@
"""
The primary purpose of these tests is to run through your code
and make sure that it doesn't crash. If you actually want to test
your code, you need to write your own custom tests to tease out
the edge cases.
To use these, add the following to a python file in your tests directory::
from wpilib.testing.robot_tests import *
"""
import pytest
from .controller import RobotTestController
def test_autonomous(control: RobotTestController):
"""Runs autonomous mode by itself"""
with control.run_robot():
# Run disabled for a short period
control.step_timing(seconds=0.5, autonomous=True, enabled=False)
# Run enabled for 15 seconds
control.step_timing(seconds=15, autonomous=True, enabled=True)
# Disabled for another short period
control.step_timing(seconds=0.5, autonomous=True, enabled=False)
@pytest.mark.filterwarnings("ignore")
def test_disabled(control: RobotTestController, robot):
"""Runs disabled mode by itself"""
with control.run_robot():
# Run disabled + autonomous for a short period
control.step_timing(seconds=5, autonomous=True, enabled=False)
# Run disabled + !autonomous for a short period
control.step_timing(seconds=5, autonomous=False, enabled=False)
@pytest.mark.filterwarnings("ignore")
def test_operator_control(control: RobotTestController):
"""Runs operator control mode by itself"""
with control.run_robot():
# Run disabled for a short period
control.step_timing(seconds=0.5, autonomous=False, enabled=False)
# Run enabled for 15 seconds
control.step_timing(seconds=15, autonomous=False, enabled=True)
# Disabled for another short period
control.step_timing(seconds=0.5, autonomous=False, enabled=False)
@pytest.mark.filterwarnings("ignore")
def test_practice(control: RobotTestController):
"""Runs through the entire span of a practice match"""
with control.run_robot():
# Run disabled for a short period
control.step_timing(seconds=0.5, autonomous=True, enabled=False)
# Run autonomous + enabled for 15 seconds
control.step_timing(seconds=15, autonomous=True, enabled=True)
# Disabled for another short period
control.step_timing(seconds=0.5, autonomous=False, enabled=False)
# Run teleop + enabled for 2 minutes
control.step_timing(seconds=120, autonomous=False, enabled=True)

View File

@@ -5,6 +5,8 @@ import ntcore
import wpilib
from wpilib.simulation._simulation import _resetWpilibSimulationData
pytest_plugins = "pytester"
@pytest.fixture
def cfg_logging(caplog):

View File

@@ -3,7 +3,7 @@ import threading
from wpilib import simulation as wsim
from wpimath.units import seconds
from wpilib.opmoderobot import OpModeRobot
from wpilib import OpMode
from wpilib import OpMode, DriverStation
from hal._wpiHal import RobotMode
from wpiutil import Color
@@ -55,6 +55,7 @@ def sim_timing_setup():
wsim.setProgramStarted(False)
yield
wsim.resumeTiming()
DriverStation.clearOpModes()
def test_add_op_mode():

View File

@@ -0,0 +1,389 @@
import pathlib
import sys
import pytest
def _make_robot_module(pytester):
pytester.makepyfile(
robot_module="""
import wpilib
class DummyRobot(wpilib.TimedRobot):
def __init__(self):
super().__init__()
self.did_init = True
class AutonomousPeriodicFailed(wpilib.TimedRobot):
def autonomousPeriodic(self):
assert False
class TeleopPeriodicFailed(wpilib.TimedRobot):
def teleopPeriodic(self):
assert False
class TeleopInitFailed(wpilib.TimedRobot):
def teleopInit(self):
assert False
class IterativeStateRobot(wpilib.TimedRobot):
def disabledInit(self):
self.did_disabled_init = True
def disabledPeriodic(self):
self.did_disabled_periodic = True
def autonomousInit(self):
self.did_auto_init = True
def autonomousPeriodic(self):
self.did_auto_periodic = True
def teleopInit(self):
self.did_teleop_init = True
def teleopPeriodic(self):
self.did_teleop_periodic = True
"""
)
def _configure_robot_testing_plugin(pytester, robot_class="DummyRobot"):
pytester.makeconftest(
f"""
import pathlib
from wpilib.testing.pytest_plugin import RobotTestingPlugin
from robot_module import {robot_class}
def pytest_configure(config):
robot_file = pathlib.Path(__file__).resolve()
config.pluginmanager.register(RobotTestingPlugin({robot_class}, robot_file, False))
"""
)
def _configure_isolated_plugin(pytester, parallelism=1, robot_class="DummyRobot"):
pytester.makeconftest(
f"""
import pathlib
from wpilib.testing.pytest_isolated_tests_plugin import IsolatedTestsPlugin
from robot_module import {robot_class}
def pytest_configure(config):
if "--no-header" in config.invocation_params.args:
return
robot_file = pathlib.Path(__file__).resolve()
config.pluginmanager.register(
IsolatedTestsPlugin({robot_class}, robot_file, False, False, {parallelism})
)
"""
)
def test_robot_testing_plugin_success(pytester):
_make_robot_module(pytester)
_configure_robot_testing_plugin(pytester)
pytester.makepyfile(
test_success="""
def test_robot_fixture(robot):
assert robot.did_init
"""
)
result = pytester.runpytest("-vv")
result.assert_outcomes(passed=1)
def test_robot_testing_plugin_failure_shows_output(pytester):
_make_robot_module(pytester)
_configure_robot_testing_plugin(pytester)
pytester.makepyfile(
test_failure="""
def test_robot_failure(robot):
print("checked failure output")
assert False
"""
)
result = pytester.runpytest("-vv")
result.assert_outcomes(failed=1)
result.stdout.fnmatch_lines(
[
"*test_failure.py::test_robot_failure FAILED*",
"*checked failure output*",
]
)
def test_isolated_plugin_process_and_output(pytester):
_make_robot_module(pytester)
_configure_isolated_plugin(pytester)
pytester.makepyfile(
test_isolated="""
import os
def test_non_robot_pid():
with open("non_robot_pid.txt", "w") as fp:
fp.write(str(os.getpid()))
def test_robot_pid_one(robot):
with open("robot_pid_one.txt", "w") as fp:
fp.write(str(os.getpid()))
def test_robot_pid_two(robot):
with open("robot_pid_two.txt", "w") as fp:
fp.write(str(os.getpid()))
def test_robot_failure_output(robot):
print("isolated failure output")
assert False
"""
)
result = pytester.runpytest_subprocess("-vv")
result.assert_outcomes(passed=3, failed=1)
result.stdout.fnmatch_lines(
[
"*test_isolated.py::test_robot_failure_output FAILED*",
"*isolated failure output*",
]
)
root = pathlib.Path(pytester.path)
main_pid = int(root.joinpath("non_robot_pid.txt").read_text())
robot_pid_one = int(root.joinpath("robot_pid_one.txt").read_text())
robot_pid_two = int(root.joinpath("robot_pid_two.txt").read_text())
assert robot_pid_one != main_pid
assert robot_pid_two != main_pid
assert robot_pid_one != robot_pid_two
def test_isolated_plugin_no_duplicate_verbose_output(pytester):
_make_robot_module(pytester)
_configure_isolated_plugin(pytester)
pytester.makepyfile(
test_isolated="""
def test_non_robot():
assert True
def test_robot_one(robot):
assert robot is not None
def test_robot_two(robot):
assert robot is not None
"""
)
result = pytester.runpytest_subprocess("-v")
result.assert_outcomes(passed=3)
assert (
sum(1 for line in result.outlines if "test_isolated.py::test_robot_one" in line)
== 1
)
assert (
sum(1 for line in result.outlines if "test_isolated.py::test_robot_two" in line)
== 1
)
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="Process signal exits do not work on Windows",
)
def test_isolated_plugin_reports_signal_exit(pytester):
_make_robot_module(pytester)
_configure_isolated_plugin(pytester)
pytester.makepyfile(
test_isolated="""
import os
import signal
def test_robot_signal_exit(robot):
os.kill(os.getpid(), signal.SIGTERM)
"""
)
result = pytester.runpytest_subprocess("-vv")
result.assert_outcomes(failed=1)
result.stdout.fnmatch_lines(
[
"*test_isolated.py::test_robot_signal_exit FAILED*",
"*Terminated*",
]
)
def test_isolated_plugin_shows_file_in_non_verbose_output(pytester):
_make_robot_module(pytester)
_configure_isolated_plugin(pytester)
pytester.makepyfile(
test_isolated="""
def test_non_robot():
assert True
def test_robot_one(robot):
assert robot is not None
def test_robot_two(robot):
assert robot is not None
"""
)
result = pytester.runpytest_subprocess()
result.assert_outcomes(passed=3)
assert (
sum(1 for line in result.outlines if line.startswith("test_isolated.py")) == 1
)
def test_isolated_plugin_maxfail_stops_early(pytester):
_make_robot_module(pytester)
_configure_isolated_plugin(pytester)
pytester.makepyfile(
test_isolated="""
def test_robot_first(robot):
assert False
def test_robot_second(robot):
assert False
"""
)
result = pytester.runpytest_subprocess("-v", "-x")
result.assert_outcomes(failed=1)
assert not any("test_robot_second" in line for line in result.outlines)
@pytest.mark.parametrize("isolated", [False, True])
def test_builtin_tests_module(pytester, isolated):
_make_robot_module(pytester)
if isolated:
_configure_isolated_plugin(pytester, robot_class="DummyRobot")
else:
_configure_robot_testing_plugin(pytester, robot_class="DummyRobot")
pytester.makepyfile(pyfrc_test="from wpilib.testing.robot_tests import *\n")
result = pytester.runpytest_subprocess("-q")
result.assert_outcomes(passed=4)
def _run_robot_suite(pytester, isolated, robot_class, test_source, *args):
_make_robot_module(pytester)
if isolated:
_configure_isolated_plugin(pytester, robot_class=robot_class)
else:
_configure_robot_testing_plugin(pytester, robot_class=robot_class)
pytester.makepyfile(test_robot=test_source)
return pytester.runpytest_subprocess(*args)
_AUTO_FAILURES = [
"AutonomousPeriodicFailed",
]
_TELEOP_FAILURES = [
"TeleopPeriodicFailed",
"TeleopInitFailed",
]
@pytest.mark.parametrize("isolated", [False, True])
@pytest.mark.parametrize("robot_class", _AUTO_FAILURES)
def test_autonomous_failure_detection(pytester, isolated, robot_class):
result = _run_robot_suite(
pytester,
isolated,
robot_class,
"""
def test_autonomous_failure(robot, control):
with control.run_robot():
control.step_timing(seconds=0.4, autonomous=True, enabled=True)
""",
"-vv",
)
result.assert_outcomes(failed=1)
@pytest.mark.parametrize("isolated", [False, True])
@pytest.mark.parametrize("robot_class", _TELEOP_FAILURES)
def test_teleop_failure_detection(pytester, isolated, robot_class):
result = _run_robot_suite(
pytester,
isolated,
robot_class,
"""
def test_teleop_failure(robot, control):
with control.run_robot():
control.step_timing(seconds=0.4, autonomous=False, enabled=True)
""",
"-vv",
)
result.assert_outcomes(failed=1)
@pytest.mark.parametrize("isolated", [False, True])
@pytest.mark.parametrize("robot_class", ["IterativeStateRobot"])
def test_robot_state_transitions(pytester, isolated, robot_class):
expected = {
"IterativeStateRobot": [
"did_disabled_init",
"did_disabled_periodic",
"did_auto_init",
"did_auto_periodic",
"did_teleop_init",
"did_teleop_periodic",
],
}[robot_class]
result = _run_robot_suite(
pytester,
isolated,
robot_class,
f"""
def test_state_transitions(robot, control):
with control.run_robot():
control.step_timing(seconds=0.4, autonomous=False, enabled=False)
control.step_timing(seconds=0.4, autonomous=True, enabled=True)
control.step_timing(seconds=0.4, autonomous=False, enabled=True)
expected = {{name: True for name in {expected!r}}}
attrs = {{name: getattr(robot, name, False) for name in {expected!r}}}
assert expected == attrs
""",
"-vv",
)
result.assert_outcomes(passed=1)

View File

@@ -73,11 +73,7 @@ def test_init_rotation_matrix():
assert expected2 == rot2
# Matrix that isn't orthogonal
R3 = np.array([
[1.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 0.0]
])
R3 = np.array([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
with pytest.raises(ValueError):
Rotation3d(R3)

View File

@@ -21,7 +21,7 @@ public:
return false;
}
value = WPI_String(str, static_cast<size_t>(size));
value = WPI_String{str, static_cast<size_t>(size)};
return true;
}

View File

@@ -23,6 +23,9 @@ dependencies = [
"wpilib==0.0.0"
]
[project.entry-points."robotpy_cli.2027"]
run-xrp = "xrp.cli:RunXrp"
[project.entry-points."robotpy_sim.2027"]
xrp = "xrp.extension"

View File

@@ -0,0 +1,121 @@
import argparse
import importlib.metadata
import os
import sys
import typing
import wpilib
if sys.version_info < (3, 10):
def entry_points(group):
eps = importlib.metadata.entry_points()
return eps.get(group, [])
else:
entry_points = importlib.metadata.entry_points
def _int_env_default(name: str, fallback: int) -> int:
value = os.environ.get(name)
if value is None:
return fallback
try:
return int(value)
except ValueError:
return fallback
class RunXrp:
"""
Runs the robot using the HAL simulator connected to an XRP platform
"""
def __init__(self, parser: argparse.ArgumentParser):
parser.add_argument(
"--nogui",
default=False,
action="store_true",
help="Don't use the WPILib simulation gui",
)
sim_group = parser.add_argument_group("Additional simulation extensions")
self.simexts = {}
for entry_point in entry_points(group="robotpy_sim.2027"):
try:
sim_ext_module = entry_point.load()
except ImportError:
print(f"WARNING: Error detected in {entry_point}")
continue
self.simexts[entry_point.name] = sim_ext_module
try:
if entry_point.name == "xrp":
cmd_help = argparse.SUPPRESS
else:
cmd_help = importlib.metadata.metadata(entry_point.dist.name)[
"summary"
]
except AttributeError:
cmd_help = "Load specified simulation extension"
sim_group.add_argument(
f"--{entry_point.name}",
default=False,
action="store_true",
help=cmd_help,
)
parser.add_argument(
"--host",
default=os.environ.get("HALSIMXRP_HOST", "192.168.42.1"),
help="XRP host (default: HALSIMXRP_HOST or 192.168.42.1)",
)
parser.add_argument(
"--port",
type=int,
default=_int_env_default("HALSIMXRP_PORT", 3540),
help="XRP port (default: HALSIMXRP_PORT or 3540)",
)
def run(
self,
options: argparse.Namespace,
project_path: "os.PathLike[str]",
robot_class: typing.Type[wpilib.RobotBase],
):
if "xrp" not in self.simexts:
print(
"robotpy-xrp HALSim extension is missing. Reinstall robotpy-xrp to use run-xrp.",
file=sys.stderr,
)
return False
os.environ["HALSIMXRP_HOST"] = options.host
os.environ["HALSIMXRP_PORT"] = str(options.port)
options.xrp = True
if not options.nogui:
try:
import halsim_gui
except ImportError:
print("robotpy-halsim-gui is not installed!", file=sys.stderr)
return False
else:
halsim_gui.loadExtension()
cwd = os.getcwd()
for name, module in self.simexts.items():
if getattr(options, name.replace("-", "_"), False):
try:
module.loadExtension()
except Exception:
print(f"Error loading {name}!", file=sys.stderr)
raise
os.chdir(cwd)
return robot_class.main(robot_class)