test
This commit is contained in:
@@ -10,7 +10,9 @@ venv
|
|||||||
.venv
|
.venv
|
||||||
.claude
|
.claude
|
||||||
.codex
|
.codex
|
||||||
|
.agents
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
*.egg-info
|
*.egg-info
|
||||||
**.FCBak
|
**.FCBak
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Docker можно использовать как альтернативную
|
|||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- Ubuntu 24.04 LTS;
|
- Ubuntu 24.044 LTS;
|
||||||
- доступ в интернет;
|
- доступ в интернет;
|
||||||
- права `sudo`;
|
- права `sudo`;
|
||||||
- физический KUKA LBR iiwa 7 R800 либо компьютер для работы только с симулятором.
|
- физический KUKA LBR iiwa 7 R800 либо компьютер для работы только с симулятором.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ web:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "ysXOyL_p3-f2YH2WqQ808KgQUA32qACziKdfqbhsJsPI0GGkoRbGn9eU22FV8mdS"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ _BLOCKS: List[_Block] = [
|
|||||||
options=["0.0.0.0", "127.0.0.1"]),
|
options=["0.0.0.0", "127.0.0.1"]),
|
||||||
_Field("port", "Порт HTTP:", "8007",
|
_Field("port", "Порт HTTP:", "8007",
|
||||||
note="Порт FastAPI-сервера (по умолчанию 8007)"),
|
note="Порт FastAPI-сервера (по умолчанию 8007)"),
|
||||||
|
_Field("token", "Токен Bearer для REST API и MCP:", "",
|
||||||
|
note="Длинный секрет. Хранится в cobot-setting.yaml и нужен при внешнем host; пустое значение отключает авторизацию"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_Block(
|
_Block(
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ web:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "replace-with-a-long-secret-token"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
```
|
```
|
||||||
@@ -131,10 +132,18 @@ web:
|
|||||||
| `enabled` | Enable (`true`) or disable (`false`) the web server |
|
| `enabled` | Enable (`true`) or disable (`false`) the web server |
|
||||||
| `host` | Listening address: `0.0.0.0` for all interfaces or `127.0.0.1` for local access only |
|
| `host` | Listening address: `0.0.0.0` for all interfaces or `127.0.0.1` for local access only |
|
||||||
| `port` | HTTP API port; default: `8007` |
|
| `port` | HTTP API port; default: `8007` |
|
||||||
|
| `token` | Bearer token for the REST API and MCP; required for an external `host` |
|
||||||
| `endpoints` | Path to the REST endpoint description |
|
| `endpoints` | Path to the REST endpoint description |
|
||||||
| `joint_limits` | Path to joint limits used for command validation |
|
| `joint_limits` | Path to joint limits used for command validation |
|
||||||
|
|
||||||
After startup, the REST API is available at `http://<host>:8007`, and MCP is available at `/mcp`.
|
When `token` is set, every request to a protected REST or MCP route must include
|
||||||
|
the `Authorization: Bearer <token>` header. The `/docs`, `/redoc`, and
|
||||||
|
`/openapi.json` resources are public only to load Swagger UI. An empty `token`
|
||||||
|
is allowed only for local access (`127.0.0.1` or `localhost`); the server
|
||||||
|
refuses to start with an external `host`. Protect the settings file and do not
|
||||||
|
publish the token.
|
||||||
|
|
||||||
|
After startup, the REST API is available at `http://<host>:8007`, and MCP is available at `/mcp/mcp`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ web:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "замените-на-длинный-секретный-токен"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
```
|
```
|
||||||
@@ -133,10 +134,18 @@ web:
|
|||||||
| `enabled` | Включить (`true`) или отключить (`false`) веб-сервер |
|
| `enabled` | Включить (`true`) или отключить (`false`) веб-сервер |
|
||||||
| `host` | Адрес прослушивания: `0.0.0.0` — все интерфейсы, `127.0.0.1` — только локально |
|
| `host` | Адрес прослушивания: `0.0.0.0` — все интерфейсы, `127.0.0.1` — только локально |
|
||||||
| `port` | Порт HTTP API (по умолчанию `8007`) |
|
| `port` | Порт HTTP API (по умолчанию `8007`) |
|
||||||
|
| `token` | Bearer-токен для REST API и MCP; обязателен для внешнего `host` |
|
||||||
| `endpoints` | Путь к описанию REST-эндпоинтов |
|
| `endpoints` | Путь к описанию REST-эндпоинтов |
|
||||||
| `joint_limits` | Путь к файлу ограничений суставов для валидации команд |
|
| `joint_limits` | Путь к файлу ограничений суставов для валидации команд |
|
||||||
|
|
||||||
После запуска REST API доступен по адресу `http://<host>:8007`, MCP — по пути `/mcp`.
|
Если `token` заполнен, каждый запрос к защищённому REST- или MCP-маршруту
|
||||||
|
должен содержать заголовок `Authorization: Bearer <token>`. Страницы
|
||||||
|
`/docs`, `/redoc` и `/openapi.json` доступны без токена только для загрузки
|
||||||
|
Swagger UI. Пустой `token` допустим только для локального доступа
|
||||||
|
(`127.0.0.1` или `localhost`); при внешнем `host` сервер не запустится.
|
||||||
|
Храните файл настроек с ограниченными правами доступа и не публикуйте токен.
|
||||||
|
|
||||||
|
После запуска REST API доступен по адресу `http://<host>:8007`, MCP — по пути `/mcp/mcp`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ web:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "replace-with-a-long-secret-token"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
~~~
|
~~~
|
||||||
@@ -28,7 +29,65 @@ After starting the stack with **cobot run**, the server is available at **http:/
|
|||||||
|
|
||||||
There is no separate health-check endpoint. If Swagger UI opens, the HTTP server is running. Readiness of ROS components is checked when a specific endpoint is called.
|
There is no separate health-check endpoint. If Swagger UI opens, the HTTP server is running. Readiness of ROS components is checked when a specific endpoint is called.
|
||||||
|
|
||||||
By default, the server listens on all network interfaces and does not use authentication. Do not expose port 8007 to an untrusted network. For local access, set **host: 127.0.0.1**. For remote access, restrict the network with firewall rules or a VPN.
|
When `host` is not `localhost`, `127.0.0.1`, or another loopback address, the
|
||||||
|
`token` field is required; otherwise the server exits during startup. When
|
||||||
|
`token` is set, Bearer authentication applies to every REST route and to the
|
||||||
|
MCP route `/mcp/mcp`, including with a local `host`.
|
||||||
|
|
||||||
|
The `/docs`, `/redoc`, and `/openapi.json` resources are available without a
|
||||||
|
header so the browser can load Swagger UI. This does not expose control
|
||||||
|
commands. Open `/docs`, click **Authorize**, paste the `web.token` value without
|
||||||
|
the word `Bearer`, and confirm. Swagger adds the header to API requests.
|
||||||
|
|
||||||
|
The token is stored in **cobot-setting.yaml**. Do not add it to documentation,
|
||||||
|
scripts, or public repositories. If it is exposed, replace it and restart the
|
||||||
|
stack. For remote access, also restrict port 8007 with a firewall or VPN.
|
||||||
|
|
||||||
|
### REST and MCP authentication
|
||||||
|
|
||||||
|
Every request to a protected REST route or MCP must include the following header
|
||||||
|
when `web.token` is set:
|
||||||
|
|
||||||
|
~~~ http
|
||||||
|
Authorization: Bearer <web.token value>
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Client setup examples:
|
||||||
|
|
||||||
|
=== "curl"
|
||||||
|
|
||||||
|
~~~ bash
|
||||||
|
HOST=http://localhost:8007
|
||||||
|
API_TOKEN='copy web.token from cobot-setting.yaml'
|
||||||
|
AUTH_HEADER="Authorization: Bearer ${API_TOKEN}"
|
||||||
|
curl -sS -H "${AUTH_HEADER}" $HOST/robot/joint_states
|
||||||
|
~~~
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
|
||||||
|
~~~ python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
HOST = "http://localhost:8007"
|
||||||
|
API_TOKEN = "copy web.token from cobot-setting.yaml"
|
||||||
|
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||||
|
response = httpx.get(f"{HOST}/robot/joint_states", headers=HEADERS, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
~~~
|
||||||
|
|
||||||
|
=== "MATLAB"
|
||||||
|
|
||||||
|
~~~ matlab
|
||||||
|
HOST = 'http://localhost:8007';
|
||||||
|
API_TOKEN = 'copy web.token from cobot-setting.yaml';
|
||||||
|
readOpts = weboptions('Timeout', 10, ...
|
||||||
|
'HeaderFields', {'Authorization', ['Bearer ' API_TOKEN]});
|
||||||
|
jointState = webread([HOST '/robot/joint_states'], readOpts);
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Pass the same header when connecting an MCP client to
|
||||||
|
`http://<host>:8007/mcp/mcp`. If the client supports custom HTTP headers, set
|
||||||
|
`Authorization: Bearer <web.token value>` in its connection settings.
|
||||||
|
|
||||||
## Preparing the examples
|
## Preparing the examples
|
||||||
|
|
||||||
@@ -648,4 +707,4 @@ When troubleshooting, proceed from simple checks to more complex ones:
|
|||||||
3. Make sure that the complete stack is running: `controller_manager`, MoveIt, and `iiwa_motion_server`.
|
3. Make sure that the complete stack is running: `controller_manager`, MoveIt, and `iiwa_motion_server`.
|
||||||
4. After starting a sequence, inspect **/sequences/logs**. After publishing a trajectory, inspect **/trajectory/logs**.
|
4. After starting a sequence, inspect **/sequences/logs**. After publishing a trajectory, inspect **/trajectory/logs**.
|
||||||
|
|
||||||
The MCP server runs in the same process but provides a separate interface at **http://server-address:8007/mcp/mcp**. For ordinary HTTP integrations, use the endpoints documented on this page.
|
The MCP server runs in the same process but provides a separate interface at **http://server-address:8007/mcp/mcp**. It uses the same Bearer token; pass the `Authorization` header when connecting an MCP client. For ordinary HTTP integrations, use the endpoints documented on this page.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ web:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "замените-на-длинный-секретный-токен"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
~~~
|
~~~
|
||||||
@@ -28,7 +29,65 @@ web:
|
|||||||
|
|
||||||
Отдельного health-check в сервере нет. Если открывается Swagger UI, HTTP-сервер запущен. Готовность ROS-компонентов проверяется при обращении к конкретному маршруту.
|
Отдельного health-check в сервере нет. Если открывается Swagger UI, HTTP-сервер запущен. Готовность ROS-компонентов проверяется при обращении к конкретному маршруту.
|
||||||
|
|
||||||
По умолчанию сервер слушает все сетевые интерфейсы и не использует аутентификацию. Не публикуйте порт 8007 в недоверенную сеть. Для локальной работы укажите **host: 127.0.0.1**, а для удалённого доступа ограничьте сеть правилами firewall или VPN.
|
Если `host` отличается от `localhost`, `127.0.0.1` или другого loopback-адреса,
|
||||||
|
поле `token` обязательно: без него сервер завершит запуск с ошибкой. Если
|
||||||
|
`token` заполнен, Bearer-аутентификация применяется ко всем REST-маршрутам и
|
||||||
|
к MCP-маршруту `/mcp/mcp`, в том числе при локальном `host`.
|
||||||
|
|
||||||
|
Страницы `/docs`, `/redoc` и схема `/openapi.json` доступны без заголовка,
|
||||||
|
чтобы браузер мог загрузить Swagger UI. Это не открывает команды управления.
|
||||||
|
Откройте `/docs`, нажмите **Authorize**, вставьте значение `web.token` без
|
||||||
|
слова `Bearer` и подтвердите. Swagger сам добавит нужный заголовок к запросам.
|
||||||
|
|
||||||
|
Токен хранится в **cobot-setting.yaml**. Не добавляйте его в документацию,
|
||||||
|
скрипты или публичные репозитории; после утечки замените значение и перезапустите
|
||||||
|
стек. Для удалённого доступа дополнительно ограничьте порт 8007 firewall или VPN.
|
||||||
|
|
||||||
|
### Аутентификация REST и MCP
|
||||||
|
|
||||||
|
Каждый запрос к защищённому REST-маршруту или MCP при заполненном `web.token`
|
||||||
|
должен содержать:
|
||||||
|
|
||||||
|
~~~ http
|
||||||
|
Authorization: Bearer <значение-web.token>
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Примеры подготовки клиентов:
|
||||||
|
|
||||||
|
=== "curl"
|
||||||
|
|
||||||
|
~~~ bash
|
||||||
|
HOST=http://localhost:8007
|
||||||
|
API_TOKEN='скопируйте значение web.token из cobot-setting.yaml'
|
||||||
|
AUTH_HEADER="Authorization: Bearer ${API_TOKEN}"
|
||||||
|
curl -sS -H "${AUTH_HEADER}" $HOST/robot/joint_states
|
||||||
|
~~~
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
|
||||||
|
~~~ python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
HOST = "http://localhost:8007"
|
||||||
|
API_TOKEN = "скопируйте значение web.token из cobot-setting.yaml"
|
||||||
|
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||||
|
response = httpx.get(f"{HOST}/robot/joint_states", headers=HEADERS, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
~~~
|
||||||
|
|
||||||
|
=== "MATLAB"
|
||||||
|
|
||||||
|
~~~ matlab
|
||||||
|
HOST = 'http://localhost:8007';
|
||||||
|
API_TOKEN = 'скопируйте значение web.token из cobot-setting.yaml';
|
||||||
|
readOpts = weboptions('Timeout', 10, ...
|
||||||
|
'HeaderFields', {'Authorization', ['Bearer ' API_TOKEN]});
|
||||||
|
jointState = webread([HOST '/robot/joint_states'], readOpts);
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Тот же заголовок передаётся MCP-клиенту при подключении к
|
||||||
|
`http://<host>:8007/mcp/mcp`. Если клиент поддерживает пользовательские HTTP
|
||||||
|
заголовки, укажите `Authorization: Bearer <значение-web.token>` в его настройках.
|
||||||
|
|
||||||
## Подготовка к примерам
|
## Подготовка к примерам
|
||||||
|
|
||||||
@@ -96,7 +155,7 @@ GET **/robot/joint_states** возвращает последнее сообще
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.get(f"{HOST}/robot/joint_states", timeout=T_READ)
|
response = httpx.get(f"{HOST}/robot/joint_states", headers=HEADERS, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
state = response.json()
|
state = response.json()
|
||||||
print(dict(zip(state["name"], state["position"])))
|
print(dict(zip(state["name"], state["position"])))
|
||||||
@@ -139,7 +198,7 @@ GET **/robot/pose** вычисляет прямую кинематику чер
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.get(f"{HOST}/robot/pose", timeout=T_READ)
|
response = httpx.get(f"{HOST}/robot/pose", headers=HEADERS, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
pose = response.json()
|
pose = response.json()
|
||||||
print(pose["position"])
|
print(pose["position"])
|
||||||
@@ -172,7 +231,7 @@ GET **/robot/positions** читает положения group_state из SRDF.
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.get(f"{HOST}/robot/positions", timeout=T_READ)
|
response = httpx.get(f"{HOST}/robot/positions", headers=HEADERS, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
for position in response.json():
|
for position in response.json():
|
||||||
print(position["name"], "—", position["description"])
|
print(position["name"], "—", position["description"])
|
||||||
@@ -223,6 +282,7 @@ POST **/robot/move/named** перемещает манипулятор в пол
|
|||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
f"{HOST}/robot/move/named",
|
f"{HOST}/robot/move/named",
|
||||||
|
headers=HEADERS,
|
||||||
json={"name": "home", "speed": 0.1, "accel_scale": 0.0},
|
json={"name": "home", "speed": 0.1, "accel_scale": 0.0},
|
||||||
timeout=T_MOVE,
|
timeout=T_MOVE,
|
||||||
)
|
)
|
||||||
@@ -274,7 +334,7 @@ POST **/robot/move/pose** принимает положение TCP в метр
|
|||||||
"a": 0.0, "b": 3.14159, "c": 0.0,
|
"a": 0.0, "b": 3.14159, "c": 0.0,
|
||||||
"speed": 0.1, "planner": "ptp", "frame_id": "",
|
"speed": 0.1, "planner": "ptp", "frame_id": "",
|
||||||
}
|
}
|
||||||
response = httpx.post(f"{HOST}/robot/move/pose", json=target, timeout=T_MOVE)
|
response = httpx.post(f"{HOST}/robot/move/pose", headers=HEADERS, json=target, timeout=T_MOVE)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
print(response.json())
|
print(response.json())
|
||||||
~~~
|
~~~
|
||||||
@@ -319,6 +379,7 @@ POST **/robot/move/joints** принимает ровно семь углов в
|
|||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
f"{HOST}/robot/move/joints",
|
f"{HOST}/robot/move/joints",
|
||||||
|
headers=HEADERS,
|
||||||
json={
|
json={
|
||||||
"joints": [0.0, 0.5, 0.0, -1.57, 0.0, 1.57, 0.0],
|
"joints": [0.0, 0.5, 0.0, -1.57, 0.0, 1.57, 0.0],
|
||||||
"speed": 0.1,
|
"speed": 0.1,
|
||||||
@@ -382,7 +443,7 @@ POST **/trajectory/send** принимает одну или несколько
|
|||||||
],
|
],
|
||||||
"validate_limits": True,
|
"validate_limits": True,
|
||||||
}
|
}
|
||||||
response = httpx.post(f"{HOST}/trajectory/send", json=trajectory, timeout=T_READ)
|
response = httpx.post(f"{HOST}/trajectory/send", headers=HEADERS, json=trajectory, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
print(response.json())
|
print(response.json())
|
||||||
~~~
|
~~~
|
||||||
@@ -430,6 +491,7 @@ joint1,joint2,joint3,joint4,joint5,joint6,joint7,t
|
|||||||
with open("trajectory.csv", "rb") as csv_file:
|
with open("trajectory.csv", "rb") as csv_file:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
f"{HOST}/trajectory/send_csv",
|
f"{HOST}/trajectory/send_csv",
|
||||||
|
headers=HEADERS,
|
||||||
params={"separator": ",", "validate_limits": True},
|
params={"separator": ",", "validate_limits": True},
|
||||||
files={"file": ("trajectory.csv", csv_file, "text/csv")},
|
files={"file": ("trajectory.csv", csv_file, "text/csv")},
|
||||||
timeout=T_READ,
|
timeout=T_READ,
|
||||||
@@ -468,7 +530,7 @@ GET **/trajectory/logs?n=50** возвращает до 300 последних
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.get(f"{HOST}/trajectory/logs", params={"n": 20}, timeout=T_READ)
|
response = httpx.get(f"{HOST}/trajectory/logs", headers=HEADERS, params={"n": 20}, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
for line in response.json()["lines"]:
|
for line in response.json()["lines"]:
|
||||||
print(line)
|
print(line)
|
||||||
@@ -538,6 +600,7 @@ POST **/sequences/start** запускает отдельный процесс m
|
|||||||
with open("motion_sequence_config.json", "rb") as config:
|
with open("motion_sequence_config.json", "rb") as config:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
f"{HOST}/sequences/start",
|
f"{HOST}/sequences/start",
|
||||||
|
headers=HEADERS,
|
||||||
files={"config": ("motion_sequence_config.json", config, "application/json")},
|
files={"config": ("motion_sequence_config.json", config, "application/json")},
|
||||||
data={"n_iterations": "3", "delay_between_iterations": "5.0"},
|
data={"n_iterations": "3", "delay_between_iterations": "5.0"},
|
||||||
timeout=T_READ,
|
timeout=T_READ,
|
||||||
@@ -577,11 +640,11 @@ POST **/sequences/start** запускает отдельный процесс m
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
status = httpx.get(f"{HOST}/sequences/status", timeout=T_READ)
|
status = httpx.get(f"{HOST}/sequences/status", headers=HEADERS, timeout=T_READ)
|
||||||
status.raise_for_status()
|
status.raise_for_status()
|
||||||
print(status.json())
|
print(status.json())
|
||||||
|
|
||||||
logs = httpx.get(f"{HOST}/sequences/logs", params={"n": 50}, timeout=T_READ)
|
logs = httpx.get(f"{HOST}/sequences/logs", headers=HEADERS, params={"n": 50}, timeout=T_READ)
|
||||||
logs.raise_for_status()
|
logs.raise_for_status()
|
||||||
for line in logs.json()["lines"]:
|
for line in logs.json()["lines"]:
|
||||||
print(line)
|
print(line)
|
||||||
@@ -617,7 +680,7 @@ POST **/stop** останавливает запущенный runner, публ
|
|||||||
=== "Python"
|
=== "Python"
|
||||||
|
|
||||||
~~~ python
|
~~~ python
|
||||||
response = httpx.post(f"{HOST}/stop", timeout=T_READ)
|
response = httpx.post(f"{HOST}/stop", headers=HEADERS, timeout=T_READ)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
print(response.json())
|
print(response.json())
|
||||||
~~~
|
~~~
|
||||||
@@ -648,4 +711,4 @@ POST **/stop** останавливает запущенный runner, публ
|
|||||||
3. Убедитесь, что стек запущен полностью: controller_manager, MoveIt и iiwa_motion_server.
|
3. Убедитесь, что стек запущен полностью: controller_manager, MoveIt и iiwa_motion_server.
|
||||||
4. После запуска последовательности посмотрите **/sequences/logs**; после публикации траектории — **/trajectory/logs**.
|
4. После запуска последовательности посмотрите **/sequences/logs**; после публикации траектории — **/trajectory/logs**.
|
||||||
|
|
||||||
MCP-сервер работает в том же процессе, но это отдельный интерфейс: его адрес — **http://адрес-сервера:8007/mcp/mcp**. Для обычных HTTP-интеграций используйте маршруты из этой страницы.
|
MCP-сервер работает в том же процессе, но это отдельный интерфейс: его адрес — **http://адрес-сервера:8007/mcp/mcp**. Он использует тот же Bearer-токен; передайте заголовок `Authorization` при подключении MCP-клиента. Для обычных HTTP-интеграций используйте маршруты из этой страницы.
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import sys
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, OpaqueFunction
|
from launch.actions import (
|
||||||
|
DeclareLaunchArgument,
|
||||||
|
IncludeLaunchDescription,
|
||||||
|
OpaqueFunction,
|
||||||
|
)
|
||||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||||
from launch_ros.actions import Node
|
from launch_ros.actions import Node
|
||||||
from launch_ros.substitutions import FindPackageShare
|
from launch_ros.substitutions import FindPackageShare
|
||||||
|
|
||||||
from iiwa_utils import converter, setting_loader
|
|
||||||
|
|
||||||
from supported.moveit_nodes import make_moveit_nodes
|
from supported.moveit_nodes import make_moveit_nodes
|
||||||
|
from supported.optional_nodes import make_foxglove_node, make_web_server_node
|
||||||
from supported.rviz_nodes import make_rviz_nodes
|
from supported.rviz_nodes import make_rviz_nodes
|
||||||
from supported.simulation_nodes import make_simulation_nodes
|
from supported.simulation_nodes import make_simulation_nodes
|
||||||
from supported.optional_nodes import make_foxglove_node, make_web_server_node
|
|
||||||
|
from iiwa_utils import converter, setting_loader
|
||||||
|
|
||||||
|
|
||||||
def _runtime_setup(context, *args, **kwargs):
|
def _runtime_setup(context, *args, **kwargs):
|
||||||
@@ -52,6 +55,7 @@ def _runtime_setup(context, *args, **kwargs):
|
|||||||
"fri_port": str(settings.robot.port),
|
"fri_port": str(settings.robot.port),
|
||||||
"simulate": "false",
|
"simulate": "false",
|
||||||
"joint_position_tau": str(settings.robot.joint_position_tau),
|
"joint_position_tau": str(settings.robot.joint_position_tau),
|
||||||
|
"joint_velocity_tau": str(settings.robot.joint_velocity_tau),
|
||||||
}
|
}
|
||||||
|
|
||||||
robot_description = converter.load_robot_description(
|
robot_description = converter.load_robot_description(
|
||||||
@@ -118,6 +122,7 @@ def _runtime_setup(context, *args, **kwargs):
|
|||||||
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
||||||
"fri_cycle_ms": str(settings.robot.fri_cycle_ms),
|
"fri_cycle_ms": str(settings.robot.fri_cycle_ms),
|
||||||
"joint_position_tau": str(settings.robot.joint_position_tau),
|
"joint_position_tau": str(settings.robot.joint_position_tau),
|
||||||
|
"joint_velocity_tau": str(settings.robot.joint_velocity_tau),
|
||||||
"controller": settings.robot.active_controller,
|
"controller": settings.robot.active_controller,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
from launch.actions import DeclareLaunchArgument, OpaqueFunction, RegisterEventHandler
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
from launch.event_handlers import OnProcessExit
|
from launch.event_handlers import OnProcessExit
|
||||||
from launch.actions import RegisterEventHandler
|
from launch.substitutions import LaunchConfiguration
|
||||||
from launch_ros.actions import Node
|
from launch_ros.actions import Node
|
||||||
from webots_ros2_driver.urdf_spawner import URDFSpawner
|
from webots_ros2_driver.urdf_spawner import URDFSpawner
|
||||||
|
|
||||||
|
|
||||||
from iiwa_utils import converter
|
from iiwa_utils import converter
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +20,7 @@ def _setup_controllers(context, *args, **kwargs):
|
|||||||
controller = LaunchConfiguration("controller").perform(context) # "jtc" | "forward"
|
controller = LaunchConfiguration("controller").perform(context) # "jtc" | "forward"
|
||||||
fri_cycle_ms = int(LaunchConfiguration("fri_cycle_ms").perform(context))
|
fri_cycle_ms = int(LaunchConfiguration("fri_cycle_ms").perform(context))
|
||||||
joint_position_tau = LaunchConfiguration("joint_position_tau").perform(context)
|
joint_position_tau = LaunchConfiguration("joint_position_tau").perform(context)
|
||||||
|
joint_velocity_tau = LaunchConfiguration("joint_velocity_tau").perform(context)
|
||||||
update_rate = 1000 // fri_cycle_ms
|
update_rate = 1000 // fri_cycle_ms
|
||||||
|
|
||||||
xacro_args = {"initial_positions_file": initial_positions_file}
|
xacro_args = {"initial_positions_file": initial_positions_file}
|
||||||
@@ -30,6 +29,7 @@ def _setup_controllers(context, *args, **kwargs):
|
|||||||
xacro_args["simulate"] = "true"
|
xacro_args["simulate"] = "true"
|
||||||
else:
|
else:
|
||||||
xacro_args["joint_position_tau"] = joint_position_tau
|
xacro_args["joint_position_tau"] = joint_position_tau
|
||||||
|
xacro_args["joint_velocity_tau"] = joint_velocity_tau
|
||||||
|
|
||||||
robot_description = converter.load_robot_description(
|
robot_description = converter.load_robot_description(
|
||||||
model_path=description,
|
model_path=description,
|
||||||
@@ -139,6 +139,7 @@ def generate_launch_description():
|
|||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
DeclareLaunchArgument("fri_cycle_ms", default_value="5"),
|
DeclareLaunchArgument("fri_cycle_ms", default_value="5"),
|
||||||
DeclareLaunchArgument("joint_position_tau", default_value="0.04"),
|
DeclareLaunchArgument("joint_position_tau", default_value="0.04"),
|
||||||
|
DeclareLaunchArgument("joint_velocity_tau", default_value="0.01"),
|
||||||
DeclareLaunchArgument("controller", default_value="jtc"),
|
DeclareLaunchArgument("controller", default_value="jtc"),
|
||||||
OpaqueFunction(function=_setup_controllers),
|
OpaqueFunction(function=_setup_controllers),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ def make_foxglove_node(settings, use_sim_time: bool) -> Node:
|
|||||||
|
|
||||||
|
|
||||||
def make_web_server_node(settings, use_sim_time: bool) -> Node:
|
def make_web_server_node(settings, use_sim_time: bool) -> Node:
|
||||||
|
token = getattr(settings.web, "token", None)
|
||||||
|
additional_env = {"IIWA_WEB_TOKEN": str(token)} if token else None
|
||||||
|
|
||||||
return Node(
|
return Node(
|
||||||
package="iiwa_web",
|
package="iiwa_web",
|
||||||
executable="iiwa_web_server",
|
executable="iiwa_web_server",
|
||||||
output="screen",
|
output="screen",
|
||||||
name="iiwa_web_server",
|
name="iiwa_web_server",
|
||||||
|
additional_env=additional_env,
|
||||||
parameters=[{
|
parameters=[{
|
||||||
"host": settings.web.host,
|
"host": settings.web.host,
|
||||||
"port": settings.web.port,
|
"port": settings.web.port,
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ planning:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: "0.0.0.0"
|
host: "127.0.0.1"
|
||||||
port: 8007
|
port: 8007
|
||||||
|
token: "ysXOyL_p3-f2YH2WqQ808KgQUA32qACziKdfqbhsJsPI0GGkoRbGn9eU22FV8mdS"
|
||||||
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
endpoints: pkg://iiwa_config/config/api_endpoints.yaml
|
||||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ find_package(rclcpp_lifecycle REQUIRED)
|
|||||||
find_package(realtime_tools REQUIRED)
|
find_package(realtime_tools REQUIRED)
|
||||||
find_package(std_msgs REQUIRED)
|
find_package(std_msgs REQUIRED)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
find_package(ament_cmake_gtest REQUIRED)
|
||||||
|
find_package(ament_cmake_pytest REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
# FRI SDK
|
# FRI SDK
|
||||||
set(FRI_SDK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/external/libFRI)
|
set(FRI_SDK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/external/libFRI)
|
||||||
|
|
||||||
@@ -75,6 +80,28 @@ pluginlib_export_plugin_description_file(
|
|||||||
iiwa_hardware_interface_plugin.xml
|
iiwa_hardware_interface_plugin.xml
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
ament_add_gtest(
|
||||||
|
test_guards
|
||||||
|
test/test_guards.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_guards PRIVATE
|
||||||
|
include
|
||||||
|
${FRI_SDK_DIR}/include
|
||||||
|
${FRI_SDK_DIR}/src/nanopb-0.2.8
|
||||||
|
${FRI_SDK_DIR}/src/protobuf
|
||||||
|
${FRI_SDK_DIR}/src/protobuf_gen
|
||||||
|
${FRI_SDK_DIR}/src/base
|
||||||
|
${FRI_SDK_DIR}/src/client_lbr
|
||||||
|
${FRI_SDK_DIR}/src/connection
|
||||||
|
)
|
||||||
|
|
||||||
|
ament_add_pytest_test(
|
||||||
|
test_safety_contract
|
||||||
|
test/test_safety_contract.py
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Установка — только библиотека и заголовки, без config/launch/urdf
|
# Установка — только библиотека и заголовки, без config/launch/urdf
|
||||||
install(TARGETS ${PROJECT_NAME}
|
install(TARGETS ${PROJECT_NAME}
|
||||||
EXPORT export_${PROJECT_NAME}
|
EXPORT export_${PROJECT_NAME}
|
||||||
@@ -87,6 +114,10 @@ install(DIRECTORY include/
|
|||||||
DESTINATION include
|
DESTINATION include
|
||||||
)
|
)
|
||||||
|
|
||||||
|
install(DIRECTORY external/libFRI/include/
|
||||||
|
DESTINATION include
|
||||||
|
)
|
||||||
|
|
||||||
ament_export_include_directories(include)
|
ament_export_include_directories(include)
|
||||||
ament_export_libraries(${PROJECT_NAME})
|
ament_export_libraries(${PROJECT_NAME})
|
||||||
ament_export_targets(export_${PROJECT_NAME})
|
ament_export_targets(export_${PROJECT_NAME})
|
||||||
|
|||||||
@@ -2,26 +2,35 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <mutex>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
#include "friClientApplication.h"
|
#include "friClientApplication.h"
|
||||||
#include "friLBRClient.h"
|
#include "friLBRClient.h"
|
||||||
#include "friUdpConnection.h"
|
#include "friUdpConnection.h"
|
||||||
|
|
||||||
|
#include "iiwa_controller/filters.hpp"
|
||||||
|
|
||||||
namespace iiwa_controller
|
namespace iiwa_controller
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
// Снимок состояния робота захватывается атомарно за один lock в FRI-потоке
|
// Снимок состояния робота публикуется через короткий seqlock: FRI-поток не
|
||||||
// и так же за один lock читается из read() в потоке управления.
|
// блокируется на mutex, а ros2_control читает только согласованный снимок.
|
||||||
struct IIWAStateSnapshot
|
struct IIWAStateSnapshot
|
||||||
{
|
{
|
||||||
std::array<double, 7> measured_pos{}; // в Commanding = filtered_pos_ (open-loop)
|
std::array<double, 7> measured_pos{}; // фактическая позиция из FRI
|
||||||
std::array<double, 7> measured_tau{}; // измеренные моменты [Нм]
|
std::array<double, 7> measured_tau{}; // измеренные моменты [Нм]
|
||||||
std::array<double, 7> external_tau{}; // внешние моменты без компенсации модели [Нм]
|
std::array<double, 7> external_tau{}; // внешние моменты без компенсации модели [Нм]
|
||||||
std::array<double, 7> ipo_pos{}; // позиция интерполятора [рад], только в Commanding
|
std::array<double, 7> ipo_pos{}; // позиция интерполятора [рад], только в Commanding
|
||||||
double sample_time{0.005}; // период цикла FRI [с]
|
double sample_time{0.005}; // период цикла FRI [с]
|
||||||
KUKA::FRI::EConnectionQuality quality{KUKA::FRI::POOR};
|
KUKA::FRI::EConnectionQuality quality{KUKA::FRI::POOR};
|
||||||
|
KUKA::FRI::ESafetyState safety_state{KUKA::FRI::SAFETY_STOP_LEVEL_2};
|
||||||
|
KUKA::FRI::EOperationMode operation_mode{KUKA::FRI::TEST_MODE_1};
|
||||||
|
KUKA::FRI::EDriveState drive_state{KUKA::FRI::OFF};
|
||||||
|
KUKA::FRI::EClientCommandMode client_command_mode{KUKA::FRI::NO_COMMAND_MODE};
|
||||||
|
KUKA::FRI::EControlMode control_mode{KUKA::FRI::NO_CONTROL};
|
||||||
|
double tracking_performance{0.0};
|
||||||
bool ipo_valid{false}; // в Monitor-режиме IPO недоступна
|
bool ipo_valid{false}; // в Monitor-режиме IPO недоступна
|
||||||
unsigned int time_stamp_sec{0}; // Unix-время пакета [с]
|
unsigned int time_stamp_sec{0}; // Unix-время пакета [с]
|
||||||
unsigned int time_stamp_nano_sec{0}; // наносекундная часть [нс]
|
unsigned int time_stamp_nano_sec{0}; // наносекундная часть [нс]
|
||||||
@@ -45,22 +54,39 @@ public:
|
|||||||
void onStateChange(
|
void onStateChange(
|
||||||
KUKA::FRI::ESessionState oldState, KUKA::FRI::ESessionState newState) override;
|
KUKA::FRI::ESessionState oldState, KUKA::FRI::ESessionState newState) override;
|
||||||
|
|
||||||
// Потокобезопасное API для ros2_control, вызывается из read() и write()
|
// API для ros2_control. Обмен данными не использует mutex в RT-пути.
|
||||||
void setTargetJointPositions(const std::array<double, N_JOINTS> & q);
|
void setTargetJointPositions(const std::array<double, N_JOINTS> & q);
|
||||||
IIWAStateSnapshot getStateSnapshot() const;
|
IIWAStateSnapshot getStateSnapshot() const;
|
||||||
bool isCommandingActive() const;
|
bool isCommandingActive() const;
|
||||||
KUKA::FRI::ESessionState getSessionState() const;
|
KUKA::FRI::ESessionState getSessionState() const;
|
||||||
|
uint64_t getSnapshotGeneration() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
double joint_position_tau_;
|
double joint_position_tau_;
|
||||||
std::atomic<KUKA::FRI::ESessionState> session_state_{KUKA::FRI::IDLE};
|
std::atomic<KUKA::FRI::ESessionState> session_state_{KUKA::FRI::IDLE};
|
||||||
|
|
||||||
mutable std::mutex data_mutex_;
|
// Цель публикуется поэлементно атомарно, чтобы read()/write() не
|
||||||
std::array<double, N_JOINTS> target_pos_{};
|
// блокировали FRI callback и не создавали data race.
|
||||||
|
std::array<std::atomic<double>, N_JOINTS> target_pos_atomic_{};
|
||||||
// Сглаженная позиция, которую реально отправляем роботу.
|
// Сглаженная позиция, которую реально отправляем роботу.
|
||||||
// Инициализируется IPO-позицией в waitForCommand(), чтобы не было скачка при старте.
|
// Инициализируется IPO-позицией в waitForCommand(), чтобы не было скачка при старте.
|
||||||
std::array<double, N_JOINTS> filtered_pos_{};
|
std::array<double, N_JOINTS> filtered_pos_{};
|
||||||
IIWAStateSnapshot snapshot_{};
|
std::array<std::atomic<double>, N_JOINTS> measured_pos_{};
|
||||||
|
std::array<std::atomic<double>, N_JOINTS> measured_tau_{};
|
||||||
|
std::array<std::atomic<double>, N_JOINTS> external_tau_{};
|
||||||
|
std::array<std::atomic<double>, N_JOINTS> ipo_pos_{};
|
||||||
|
std::atomic<double> sample_time_{0.005};
|
||||||
|
std::atomic<KUKA::FRI::EConnectionQuality> quality_{KUKA::FRI::POOR};
|
||||||
|
std::atomic<KUKA::FRI::ESafetyState> safety_state_{KUKA::FRI::SAFETY_STOP_LEVEL_2};
|
||||||
|
std::atomic<KUKA::FRI::EOperationMode> operation_mode_{KUKA::FRI::TEST_MODE_1};
|
||||||
|
std::atomic<KUKA::FRI::EDriveState> drive_state_{KUKA::FRI::OFF};
|
||||||
|
std::atomic<KUKA::FRI::EClientCommandMode> client_command_mode_{KUKA::FRI::NO_COMMAND_MODE};
|
||||||
|
std::atomic<KUKA::FRI::EControlMode> control_mode_{KUKA::FRI::NO_CONTROL};
|
||||||
|
std::atomic<double> tracking_performance_{0.0};
|
||||||
|
std::atomic<bool> ipo_valid_{false};
|
||||||
|
std::atomic<unsigned int> time_stamp_sec_{0};
|
||||||
|
std::atomic<unsigned int> time_stamp_nano_sec_{0};
|
||||||
|
std::atomic<uint64_t> snapshot_generation_{0};
|
||||||
|
|
||||||
// Обновить snapshot_ без поля ipo_pos (в Monitor-режиме getIpoJointPosition() недоступна)
|
// Обновить snapshot_ без поля ipo_pos (в Monitor-режиме getIpoJointPosition() недоступна)
|
||||||
void captureMonitoringData();
|
void captureMonitoringData();
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
#include "rclcpp/macros.hpp"
|
#include "rclcpp/macros.hpp"
|
||||||
#include "rclcpp_lifecycle/state.hpp"
|
#include "rclcpp_lifecycle/state.hpp"
|
||||||
|
|
||||||
|
#include "iiwa_controller/CommandGuard.hpp"
|
||||||
#include "iiwa_controller/FRIClient.h"
|
#include "iiwa_controller/FRIClient.h"
|
||||||
|
#include "iiwa_controller/StateGuard.hpp"
|
||||||
|
|
||||||
namespace iiwa_controller
|
namespace iiwa_controller
|
||||||
{
|
{
|
||||||
@@ -63,6 +65,11 @@ private:
|
|||||||
// EMA-фильтр скорости: сглаживает одиночные выбросы конечных разностей.
|
// EMA-фильтр скорости: сглаживает одиночные выбросы конечных разностей.
|
||||||
// joint_velocity_tau = 0 отключает фильтр (raw finite difference).
|
// joint_velocity_tau = 0 отключает фильтр (raw finite difference).
|
||||||
double joint_velocity_tau_{0.01};
|
double joint_velocity_tau_{0.01};
|
||||||
|
std::array<double, N_JOINTS> command_min_{};
|
||||||
|
std::array<double, N_JOINTS> command_max_{};
|
||||||
|
std::array<double, N_JOINTS> command_max_velocity_{};
|
||||||
|
CommandGuard command_guard_;
|
||||||
|
StateGuard state_guard_;
|
||||||
|
|
||||||
// Объекты FRI SDK
|
// Объекты FRI SDK
|
||||||
std::unique_ptr<FRIClient> fri_client_;
|
std::unique_ptr<FRIClient> fri_client_;
|
||||||
@@ -73,6 +80,7 @@ private:
|
|||||||
// read() лишь читает готовый снимок — без блокировки RT-потока.
|
// read() лишь читает готовый снимок — без блокировки RT-потока.
|
||||||
std::thread fri_thread_;
|
std::thread fri_thread_;
|
||||||
std::atomic<bool> fri_running_{false};
|
std::atomic<bool> fri_running_{false};
|
||||||
|
std::atomic<bool> communication_fault_{false};
|
||||||
void friThreadFunc();
|
void friThreadFunc();
|
||||||
|
|
||||||
// Хэндлы интерфейсов состояния, заполняются в on_activate
|
// Хэндлы интерфейсов состояния, заполняются в on_activate
|
||||||
@@ -90,7 +98,11 @@ private:
|
|||||||
unsigned int last_ts_sec_{0};
|
unsigned int last_ts_sec_{0};
|
||||||
unsigned int last_ts_nsec_{0};
|
unsigned int last_ts_nsec_{0};
|
||||||
bool velocity_initialized_{false};
|
bool velocity_initialized_{false};
|
||||||
|
uint64_t last_snapshot_generation_{0};
|
||||||
|
unsigned int stale_snapshot_cycles_{0};
|
||||||
void compute_velocity_(const IIWAStateSnapshot & snap);
|
void compute_velocity_(const IIWAStateSnapshot & snap);
|
||||||
|
std::array<double, N_JOINTS> last_command_pos_{};
|
||||||
|
bool command_initialized_{false};
|
||||||
|
|
||||||
// Отслеживание сессии FRI для обнаружения потери управления
|
// Отслеживание сессии FRI для обнаружения потери управления
|
||||||
KUKA::FRI::ESessionState previous_session_state_{KUKA::FRI::IDLE};
|
KUKA::FRI::ESessionState previous_session_state_{KUKA::FRI::IDLE};
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
<test_depend>ament_lint_auto</test_depend>
|
<test_depend>ament_lint_auto</test_depend>
|
||||||
<test_depend>ament_lint_common</test_depend>
|
<test_depend>ament_lint_common</test_depend>
|
||||||
|
<test_depend>ament_cmake_gtest</test_depend>
|
||||||
|
<test_depend>ament_cmake_pytest</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_cmake</build_type>
|
<build_type>ament_cmake</build_type>
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ static const char * friStateName(KUKA::FRI::ESessionState s)
|
|||||||
FRIClient::FRIClient(double joint_position_tau)
|
FRIClient::FRIClient(double joint_position_tau)
|
||||||
: joint_position_tau_(joint_position_tau)
|
: joint_position_tau_(joint_position_tau)
|
||||||
{
|
{
|
||||||
target_pos_.fill(0.0);
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
|
target_pos_atomic_[i].store(0.0, std::memory_order_relaxed);
|
||||||
|
measured_pos_[i].store(0.0, std::memory_order_relaxed);
|
||||||
|
measured_tau_[i].store(0.0, std::memory_order_relaxed);
|
||||||
|
external_tau_[i].store(0.0, std::memory_order_relaxed);
|
||||||
|
ipo_pos_[i].store(0.0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
filtered_pos_.fill(0.0);
|
filtered_pos_.fill(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,20 +37,26 @@ FRIClient::FRIClient(double joint_position_tau)
|
|||||||
// В Monitor-режиме getIpoJointPosition() бросает FRIException, поэтому здесь не зовём.
|
// В Monitor-режиме getIpoJointPosition() бросает FRIException, поэтому здесь не зовём.
|
||||||
void FRIClient::captureMonitoringData()
|
void FRIClient::captureMonitoringData()
|
||||||
{
|
{
|
||||||
std::memcpy(
|
const auto & state = robotState();
|
||||||
snapshot_.measured_pos.data(),
|
const auto * measured_pos = state.getMeasuredJointPosition();
|
||||||
robotState().getMeasuredJointPosition(), N_JOINTS * sizeof(double));
|
const auto * measured_tau = state.getMeasuredTorque();
|
||||||
std::memcpy(
|
const auto * external_tau = state.getExternalTorque();
|
||||||
snapshot_.measured_tau.data(),
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
robotState().getMeasuredTorque(), N_JOINTS * sizeof(double));
|
measured_pos_[i].store(measured_pos[i], std::memory_order_relaxed);
|
||||||
std::memcpy(
|
measured_tau_[i].store(measured_tau[i], std::memory_order_relaxed);
|
||||||
snapshot_.external_tau.data(),
|
external_tau_[i].store(external_tau[i], std::memory_order_relaxed);
|
||||||
robotState().getExternalTorque(), N_JOINTS * sizeof(double));
|
}
|
||||||
snapshot_.sample_time = robotState().getSampleTime();
|
sample_time_.store(state.getSampleTime(), std::memory_order_relaxed);
|
||||||
snapshot_.quality = robotState().getConnectionQuality();
|
quality_.store(state.getConnectionQuality(), std::memory_order_relaxed);
|
||||||
snapshot_.ipo_valid = false;
|
safety_state_.store(state.getSafetyState(), std::memory_order_relaxed);
|
||||||
snapshot_.time_stamp_sec = robotState().getTimestampSec();
|
operation_mode_.store(state.getOperationMode(), std::memory_order_relaxed);
|
||||||
snapshot_.time_stamp_nano_sec = robotState().getTimestampNanoSec();
|
drive_state_.store(state.getDriveState(), std::memory_order_relaxed);
|
||||||
|
client_command_mode_.store(state.getClientCommandMode(), std::memory_order_relaxed);
|
||||||
|
control_mode_.store(state.getControlMode(), std::memory_order_relaxed);
|
||||||
|
tracking_performance_.store(state.getTrackingPerformance(), std::memory_order_relaxed);
|
||||||
|
ipo_valid_.store(false, std::memory_order_relaxed);
|
||||||
|
time_stamp_sec_.store(state.getTimestampSec(), std::memory_order_relaxed);
|
||||||
|
time_stamp_nano_sec_.store(state.getTimestampNanoSec(), std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызывается из Commanding-состояний (COMMANDING_WAIT и COMMANDING_ACTIVE).
|
// Вызывается из Commanding-состояний (COMMANDING_WAIT и COMMANDING_ACTIVE).
|
||||||
@@ -52,20 +64,18 @@ void FRIClient::captureMonitoringData()
|
|||||||
void FRIClient::captureCommandingData()
|
void FRIClient::captureCommandingData()
|
||||||
{
|
{
|
||||||
captureMonitoringData();
|
captureMonitoringData();
|
||||||
std::memcpy(
|
const auto * ipo_pos = robotState().getIpoJointPosition();
|
||||||
snapshot_.ipo_pos.data(),
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
robotState().getIpoJointPosition(), N_JOINTS * sizeof(double));
|
ipo_pos_[i].store(ipo_pos[i], std::memory_order_relaxed);
|
||||||
snapshot_.ipo_valid = true;
|
}
|
||||||
// Open-loop: JTC видит filtered_pos_ как «измеренную» позицию — как в lbr_fri_ros2_stack.
|
ipo_valid_.store(true, std::memory_order_relaxed);
|
||||||
// Благодаря этому JTC не видит расхождения и не генерирует коррекций.
|
|
||||||
snapshot_.measured_pos = filtered_pos_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызывается в MONITORING_WAIT и MONITORING_READY
|
// Вызывается в MONITORING_WAIT и MONITORING_READY
|
||||||
void FRIClient::monitor()
|
void FRIClient::monitor()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(data_mutex_);
|
|
||||||
captureMonitoringData();
|
captureMonitoringData();
|
||||||
|
snapshot_generation_.fetch_add(1, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызывается в COMMANDING_WAIT.
|
// Вызывается в COMMANDING_WAIT.
|
||||||
@@ -76,35 +86,39 @@ void FRIClient::monitor()
|
|||||||
// статическое отклонение не даст выполниться этому условию.
|
// статическое отклонение не даст выполниться этому условию.
|
||||||
void FRIClient::waitForCommand()
|
void FRIClient::waitForCommand()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(data_mutex_);
|
|
||||||
captureCommandingData();
|
captureCommandingData();
|
||||||
|
|
||||||
// Инициализируем цель и фильтр IPO-позицией.
|
// Инициализируем цель и фильтр IPO-позицией.
|
||||||
// Фильтр стартует с IPO — это гарантирует нулевой скачок при переходе в COMMANDING_ACTIVE.
|
// Фильтр стартует с IPO — это гарантирует нулевой скачок при переходе в COMMANDING_ACTIVE.
|
||||||
std::memcpy(target_pos_.data(), snapshot_.ipo_pos.data(), N_JOINTS * sizeof(double));
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
std::memcpy(filtered_pos_.data(), snapshot_.ipo_pos.data(), N_JOINTS * sizeof(double));
|
const double ipo = ipo_pos_[i].load(std::memory_order_relaxed);
|
||||||
|
target_pos_atomic_[i].store(ipo, std::memory_order_relaxed);
|
||||||
|
filtered_pos_[i] = ipo;
|
||||||
|
}
|
||||||
|
|
||||||
robotCommand().setJointPosition(filtered_pos_.data());
|
robotCommand().setJointPosition(filtered_pos_.data());
|
||||||
|
snapshot_generation_.fetch_add(1, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызывается в COMMANDING_ACTIVE, основной цикл управления
|
// Вызывается в COMMANDING_ACTIVE, основной цикл управления
|
||||||
void FRIClient::command()
|
void FRIClient::command()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(data_mutex_);
|
std::array<double, N_JOINTS> target{};
|
||||||
|
|
||||||
// EMA-фильтр применяется ДО захвата снимка — тогда snapshot_.measured_pos = filtered_pos_
|
|
||||||
// будет содержать то, что реально отправлено роботу в этом цикле (не прошлом).
|
|
||||||
// Это соответствует lbr_fri_ros2_stack: снимок захватывается post-EMA.
|
|
||||||
const double dt = robotState().getSampleTime();
|
|
||||||
const double alpha = (joint_position_tau_ > 0.0) ? dt / (joint_position_tau_ + dt) : 1.0;
|
|
||||||
for (size_t i = 0; i < N_JOINTS; ++i) {
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
filtered_pos_[i] = alpha * target_pos_[i] + (1.0 - alpha) * filtered_pos_[i];
|
target[i] = target_pos_atomic_[i].load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const double dt = robotState().getSampleTime();
|
||||||
|
const double alpha = exponentialFilterAlpha(joint_position_tau_, dt);
|
||||||
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
|
filtered_pos_[i] = alpha * target[i] + (1.0 - alpha) * filtered_pos_[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
robotCommand().setJointPosition(filtered_pos_.data());
|
robotCommand().setJointPosition(filtered_pos_.data());
|
||||||
|
|
||||||
// Захватываем снимок ПОСЛЕ EMA: measured_pos = filtered_pos_ = что робот только что получил.
|
// Захватываем фактическое состояние FRI после отправки команды.
|
||||||
captureCommandingData();
|
captureCommandingData();
|
||||||
|
snapshot_generation_.fetch_add(1, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FRIClient::onStateChange(
|
void FRIClient::onStateChange(
|
||||||
@@ -132,14 +146,32 @@ void FRIClient::setTargetJointPositions(const std::array<double, N_JOINTS> & q)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::lock_guard<std::mutex> lock(data_mutex_);
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
target_pos_ = q;
|
target_pos_atomic_[i].store(q[i], std::memory_order_relaxed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IIWAStateSnapshot FRIClient::getStateSnapshot() const
|
IIWAStateSnapshot FRIClient::getStateSnapshot() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(data_mutex_);
|
IIWAStateSnapshot result{};
|
||||||
return snapshot_;
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
|
result.measured_pos[i] = measured_pos_[i].load(std::memory_order_relaxed);
|
||||||
|
result.measured_tau[i] = measured_tau_[i].load(std::memory_order_relaxed);
|
||||||
|
result.external_tau[i] = external_tau_[i].load(std::memory_order_relaxed);
|
||||||
|
result.ipo_pos[i] = ipo_pos_[i].load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
result.sample_time = sample_time_.load(std::memory_order_relaxed);
|
||||||
|
result.quality = quality_.load(std::memory_order_relaxed);
|
||||||
|
result.safety_state = safety_state_.load(std::memory_order_relaxed);
|
||||||
|
result.operation_mode = operation_mode_.load(std::memory_order_relaxed);
|
||||||
|
result.drive_state = drive_state_.load(std::memory_order_relaxed);
|
||||||
|
result.client_command_mode = client_command_mode_.load(std::memory_order_relaxed);
|
||||||
|
result.control_mode = control_mode_.load(std::memory_order_relaxed);
|
||||||
|
result.tracking_performance = tracking_performance_.load(std::memory_order_relaxed);
|
||||||
|
result.ipo_valid = ipo_valid_.load(std::memory_order_relaxed);
|
||||||
|
result.time_stamp_sec = time_stamp_sec_.load(std::memory_order_relaxed);
|
||||||
|
result.time_stamp_nano_sec = time_stamp_nano_sec_.load(std::memory_order_relaxed);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FRIClient::isCommandingActive() const
|
bool FRIClient::isCommandingActive() const
|
||||||
@@ -152,4 +184,9 @@ KUKA::FRI::ESessionState FRIClient::getSessionState() const
|
|||||||
return session_state_.load(std::memory_order_relaxed);
|
return session_state_.load(std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint64_t FRIClient::getSnapshotGeneration() const
|
||||||
|
{
|
||||||
|
return snapshot_generation_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace iiwa_controller
|
} // namespace iiwa_controller
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#include "iiwa_controller/IIWAHardwareInterface.hpp"
|
#include "iiwa_controller/IIWAHardwareInterface.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
|
#include <stdexcept>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
#include "hardware_interface/hardware_info.hpp"
|
#include "hardware_interface/hardware_info.hpp"
|
||||||
@@ -10,6 +13,7 @@
|
|||||||
#include "hardware_interface/types/hardware_interface_type_values.hpp"
|
#include "hardware_interface/types/hardware_interface_type_values.hpp"
|
||||||
#include "pluginlib/class_list_macros.hpp"
|
#include "pluginlib/class_list_macros.hpp"
|
||||||
#include "rclcpp/rclcpp.hpp"
|
#include "rclcpp/rclcpp.hpp"
|
||||||
|
#include "friException.h"
|
||||||
|
|
||||||
PLUGINLIB_EXPORT_CLASS(
|
PLUGINLIB_EXPORT_CLASS(
|
||||||
iiwa_controller::IIWAHardwareInterface,
|
iiwa_controller::IIWAHardwareInterface,
|
||||||
@@ -43,11 +47,24 @@ CallbackReturn IIWAHardwareInterface::on_init(
|
|||||||
|
|
||||||
const auto & info = params.hardware_info;
|
const auto & info = params.hardware_info;
|
||||||
|
|
||||||
robot_ip_ = getParam(info, "robot_ip", "192.170.10.2");
|
try {
|
||||||
fri_port_ = std::stoi(getParam(info, "fri_port", "30200"));
|
robot_ip_ = getParam(info, "robot_ip", "192.170.10.2");
|
||||||
simulate_ = (getParam(info, "simulate", "false") == "true");
|
fri_port_ = std::stoi(getParam(info, "fri_port", "30200"));
|
||||||
joint_position_tau_ = std::stod(getParam(info, "joint_position_tau", "0.04"));
|
const auto simulate = getParam(info, "simulate", "false");
|
||||||
joint_velocity_tau_ = std::stod(getParam(info, "joint_velocity_tau", "0.01"));
|
simulate_ = simulate == "true" || simulate == "1" || simulate == "yes";
|
||||||
|
joint_position_tau_ = std::stod(getParam(info, "joint_position_tau", "0.04"));
|
||||||
|
joint_velocity_tau_ = std::stod(getParam(info, "joint_velocity_tau", "0.01"));
|
||||||
|
} catch (const std::exception & ex) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG), "Некорректные параметры FRI: %s", ex.what());
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (robot_ip_.empty() || fri_port_ < 1 || fri_port_ > 65535 ||
|
||||||
|
!std::isfinite(joint_position_tau_) || joint_position_tau_ < 0.0 ||
|
||||||
|
!std::isfinite(joint_velocity_tau_) || joint_velocity_tau_ < 0.0) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG), "Параметры FRI вне допустимого диапазона");
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG),
|
RCLCPP_INFO(rclcpp::get_logger(LOG),
|
||||||
"on_init: ip=%s port=%d simulate=%s pos_tau=%.3f vel_tau=%.3f",
|
"on_init: ip=%s port=%d simulate=%s pos_tau=%.3f vel_tau=%.3f",
|
||||||
@@ -62,6 +79,84 @@ CallbackReturn IIWAHardwareInterface::on_init(
|
|||||||
return CallbackReturn::ERROR;
|
return CallbackReturn::ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
command_min_.fill(-std::numeric_limits<double>::infinity());
|
||||||
|
command_max_.fill(std::numeric_limits<double>::infinity());
|
||||||
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
|
const std::string expected_name = "joint" + std::to_string(i + 1);
|
||||||
|
if (info.joints[i].name != expected_name) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
||||||
|
"Сустав %zu имеет имя '%s', ожидается '%s'", i,
|
||||||
|
info.joints[i].name.c_str(), expected_name.c_str());
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool position_interface_found = false;
|
||||||
|
for (const auto & interface : info.joints[i].command_interfaces) {
|
||||||
|
if (interface.name != hardware_interface::HW_IF_POSITION) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
position_interface_found = true;
|
||||||
|
try {
|
||||||
|
auto min_value = interface.min;
|
||||||
|
auto max_value = interface.max;
|
||||||
|
if (min_value.empty()) {
|
||||||
|
const auto it = interface.parameters.find("min");
|
||||||
|
if (it != interface.parameters.end()) {
|
||||||
|
min_value = it->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (max_value.empty()) {
|
||||||
|
const auto it = interface.parameters.find("max");
|
||||||
|
if (it != interface.parameters.end()) {
|
||||||
|
max_value = it->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!min_value.empty()) {
|
||||||
|
command_min_[i] = std::stod(min_value);
|
||||||
|
}
|
||||||
|
if (!max_value.empty()) {
|
||||||
|
command_max_[i] = std::stod(max_value);
|
||||||
|
}
|
||||||
|
} catch (const std::exception & ex) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
||||||
|
"Некорректные ограничения позиции для %s: %s",
|
||||||
|
expected_name.c_str(), ex.what());
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
const auto limits_it = info.limits.find(expected_name);
|
||||||
|
if (limits_it != info.limits.end() && limits_it->second.has_position_limits) {
|
||||||
|
if (!std::isfinite(command_min_[i])) {
|
||||||
|
command_min_[i] = limits_it->second.min_position;
|
||||||
|
}
|
||||||
|
if (!std::isfinite(command_max_[i])) {
|
||||||
|
command_max_[i] = limits_it->second.max_position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!position_interface_found || !std::isfinite(command_min_[i]) ||
|
||||||
|
!std::isfinite(command_max_[i]) || command_min_[i] > command_max_[i]) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
||||||
|
"Для %s отсутствует корректный position command interface", expected_name.c_str());
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto limits_it = info.limits.find(expected_name);
|
||||||
|
if (limits_it == info.limits.end() || !limits_it->second.has_velocity_limits ||
|
||||||
|
!std::isfinite(limits_it->second.max_velocity) ||
|
||||||
|
limits_it->second.max_velocity <= 0.0) {
|
||||||
|
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
||||||
|
"Для %s отсутствует корректное ограничение скорости", expected_name.c_str());
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
command_max_velocity_[i] = limits_it->second.max_velocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandGuardLimits guard_limits;
|
||||||
|
guard_limits.min_position = command_min_;
|
||||||
|
guard_limits.max_position = command_max_;
|
||||||
|
guard_limits.max_velocity = command_max_velocity_;
|
||||||
|
command_guard_ = CommandGuard(guard_limits);
|
||||||
|
|
||||||
prev_pos_.fill(0.0);
|
prev_pos_.fill(0.0);
|
||||||
velocity_.fill(0.0);
|
velocity_.fill(0.0);
|
||||||
velocity_raw_.fill(0.0);
|
velocity_raw_.fill(0.0);
|
||||||
@@ -105,6 +200,9 @@ CallbackReturn IIWAHardwareInterface::on_configure(const rclcpp_lifecycle::State
|
|||||||
if (!app_->connect(fri_port_, robot_ip_.c_str())) {
|
if (!app_->connect(fri_port_, robot_ip_.c_str())) {
|
||||||
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
RCLCPP_FATAL(rclcpp::get_logger(LOG),
|
||||||
"Не удалось открыть UDP-сокет на порту %d (робот: %s)", fri_port_, robot_ip_.c_str());
|
"Не удалось открыть UDP-сокет на порту %d (робот: %s)", fri_port_, robot_ip_.c_str());
|
||||||
|
app_.reset();
|
||||||
|
connection_.reset();
|
||||||
|
fri_client_.reset();
|
||||||
return CallbackReturn::ERROR;
|
return CallbackReturn::ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +243,20 @@ CallbackReturn IIWAHardwareInterface::on_activate(const rclcpp_lifecycle::State
|
|||||||
return CallbackReturn::SUCCESS;
|
return CallbackReturn::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!fri_client_ || !connection_ || !app_) {
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "FRI не сконфигурирован перед активацией");
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
communication_fault_.store(false, std::memory_order_release);
|
||||||
fri_running_.store(true, std::memory_order_relaxed);
|
fri_running_.store(true, std::memory_order_relaxed);
|
||||||
fri_thread_ = std::thread(&IIWAHardwareInterface::friThreadFunc, this);
|
fri_thread_ = std::thread(&IIWAHardwareInterface::friThreadFunc, this);
|
||||||
|
|
||||||
constexpr int kTimeoutMs = 15000;
|
constexpr int kTimeoutMs = 15000;
|
||||||
constexpr int kPollMs = 200;
|
constexpr int kPollMs = 200;
|
||||||
for (int elapsed = 0;
|
for (int elapsed = 0;
|
||||||
fri_client_->getSessionState() == KUKA::FRI::IDLE && elapsed < kTimeoutMs;
|
fri_client_->getSessionState() < KUKA::FRI::MONITORING_READY &&
|
||||||
|
!communication_fault_.load(std::memory_order_acquire) && elapsed < kTimeoutMs;
|
||||||
elapsed += kPollMs)
|
elapsed += kPollMs)
|
||||||
{
|
{
|
||||||
RCLCPP_INFO_THROTTLE(rclcpp::get_logger(LOG), throttle_clock_, 2000,
|
RCLCPP_INFO_THROTTLE(rclcpp::get_logger(LOG), throttle_clock_, 2000,
|
||||||
@@ -159,10 +264,17 @@ CallbackReturn IIWAHardwareInterface::on_activate(const rclcpp_lifecycle::State
|
|||||||
std::this_thread::sleep_for(std::chrono::milliseconds(kPollMs));
|
std::this_thread::sleep_for(std::chrono::milliseconds(kPollMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fri_client_->getSessionState() == KUKA::FRI::IDLE) {
|
if (fri_client_->getSessionState() < KUKA::FRI::MONITORING_READY ||
|
||||||
|
communication_fault_.load(std::memory_order_acquire)) {
|
||||||
RCLCPP_ERROR(rclcpp::get_logger(LOG),
|
RCLCPP_ERROR(rclcpp::get_logger(LOG),
|
||||||
"FRI не подключился за %d с. Проверьте ServerFriRos2 на %s",
|
"FRI не подключился за %d с. Проверьте ServerFriRos2 на %s",
|
||||||
kTimeoutMs / 1000, robot_ip_.c_str());
|
kTimeoutMs / 1000, robot_ip_.c_str());
|
||||||
|
fri_running_.store(false, std::memory_order_release);
|
||||||
|
if (fri_thread_.joinable()) {
|
||||||
|
fri_thread_.join();
|
||||||
|
}
|
||||||
|
app_->disconnect();
|
||||||
|
return CallbackReturn::ERROR;
|
||||||
} else {
|
} else {
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI сессия установлена!");
|
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI сессия установлена!");
|
||||||
const auto snap = fri_client_->getStateSnapshot();
|
const auto snap = fri_client_->getStateSnapshot();
|
||||||
@@ -171,6 +283,10 @@ CallbackReturn IIWAHardwareInterface::on_activate(const rclcpp_lifecycle::State
|
|||||||
last_ts_nsec_ = snap.time_stamp_nano_sec;
|
last_ts_nsec_ = snap.time_stamp_nano_sec;
|
||||||
velocity_.fill(0.0);
|
velocity_.fill(0.0);
|
||||||
velocity_initialized_ = true;
|
velocity_initialized_ = true;
|
||||||
|
last_snapshot_generation_ = fri_client_->getSnapshotGeneration();
|
||||||
|
stale_snapshot_cycles_ = 0;
|
||||||
|
last_command_pos_ = snap.ipo_valid ? snap.ipo_pos : snap.measured_pos;
|
||||||
|
command_initialized_ = snap.ipo_valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
previous_session_state_ = fri_client_->getSessionState();
|
previous_session_state_ = fri_client_->getSessionState();
|
||||||
@@ -184,12 +300,12 @@ CallbackReturn IIWAHardwareInterface::on_deactivate(const rclcpp_lifecycle::Stat
|
|||||||
{
|
{
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG), "Деактивация...");
|
RCLCPP_INFO(rclcpp::get_logger(LOG), "Деактивация...");
|
||||||
|
|
||||||
if (!simulate_ && fri_running_.load()) {
|
if (!simulate_) {
|
||||||
fri_running_.store(false, std::memory_order_relaxed);
|
fri_running_.store(false, std::memory_order_release);
|
||||||
// Сначала закрываем сокет — это разблокирует recvfrom() в FRI-потоке.
|
// UdpConnection не обещает потокобезопасный disconnect(). Сначала ждём
|
||||||
// Только потом join(), иначе он зависнет навсегда.
|
// завершения step() (таймаут сокета 100 мс), затем закрываем приложение.
|
||||||
if (app_) { app_->disconnect(); }
|
|
||||||
if (fri_thread_.joinable()) { fri_thread_.join(); }
|
if (fri_thread_.joinable()) { fri_thread_.join(); }
|
||||||
|
if (app_) { app_->disconnect(); }
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток остановлен");
|
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток остановлен");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +315,7 @@ CallbackReturn IIWAHardwareInterface::on_deactivate(const rclcpp_lifecycle::Stat
|
|||||||
}
|
}
|
||||||
|
|
||||||
velocity_initialized_ = false;
|
velocity_initialized_ = false;
|
||||||
|
command_initialized_ = false;
|
||||||
return CallbackReturn::SUCCESS;
|
return CallbackReturn::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,9 +324,13 @@ CallbackReturn IIWAHardwareInterface::on_deactivate(const rclcpp_lifecycle::Stat
|
|||||||
|
|
||||||
CallbackReturn IIWAHardwareInterface::on_cleanup(const rclcpp_lifecycle::State &)
|
CallbackReturn IIWAHardwareInterface::on_cleanup(const rclcpp_lifecycle::State &)
|
||||||
{
|
{
|
||||||
fri_client_.reset();
|
fri_running_.store(false, std::memory_order_release);
|
||||||
connection_.reset();
|
if (fri_thread_.joinable()) {
|
||||||
|
fri_thread_.join();
|
||||||
|
}
|
||||||
app_.reset();
|
app_.reset();
|
||||||
|
connection_.reset();
|
||||||
|
fri_client_.reset();
|
||||||
return CallbackReturn::SUCCESS;
|
return CallbackReturn::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,11 +340,28 @@ void IIWAHardwareInterface::friThreadFunc()
|
|||||||
{
|
{
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток запущен");
|
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток запущен");
|
||||||
|
|
||||||
while (fri_running_.load(std::memory_order_relaxed)) {
|
try {
|
||||||
if (!app_->step()) {
|
while (fri_running_.load(std::memory_order_acquire)) {
|
||||||
RCLCPP_WARN_THROTTLE(rclcpp::get_logger(LOG), throttle_clock_, 2000,
|
if (!app_->step()) {
|
||||||
"FRI: step() вернул false, возможно потеряли соединение");
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
fri_running_.store(false, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG),
|
||||||
|
"FRI: step() вернул false, соединение потеряно");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (const KUKA::FRI::FRIException & ex) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
fri_running_.store(false, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "Исключение FRI: %s", ex.getErrorMessage());
|
||||||
|
} catch (const std::exception & ex) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
fri_running_.store(false, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "Ошибка FRI-потока: %s", ex.what());
|
||||||
|
} catch (...) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
fri_running_.store(false, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "Неизвестная ошибка FRI-потока");
|
||||||
}
|
}
|
||||||
|
|
||||||
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток завершён");
|
RCLCPP_INFO(rclcpp::get_logger(LOG), "FRI поток завершён");
|
||||||
@@ -262,11 +400,15 @@ void IIWAHardwareInterface::compute_velocity_(const IIWAStateSnapshot & snap)
|
|||||||
{1.71, 1.71, 1.75, 2.27, 2.44, 3.14, 3.14};
|
{1.71, 1.71, 1.75, 2.27, 2.44, 3.14, 3.14};
|
||||||
static constexpr double kVelDeadband = 1e-4;
|
static constexpr double kVelDeadband = 1e-4;
|
||||||
|
|
||||||
|
if (dt < 0.0) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "FRI timestamp пошёл назад");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dt > 0.0) {
|
if (dt > 0.0) {
|
||||||
// EMA alpha для фильтра скорости: tau=0 → alpha=1 (без фильтра)
|
// EMA alpha для фильтра скорости: tau=0 → alpha=1 (без фильтра)
|
||||||
const double vel_alpha = (joint_velocity_tau_ > 0.0)
|
const double vel_alpha = exponentialFilterAlpha(joint_velocity_tau_, dt);
|
||||||
? dt / (joint_velocity_tau_ + dt)
|
|
||||||
: 1.0;
|
|
||||||
|
|
||||||
for (size_t i = 0; i < N_JOINTS; ++i) {
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
const double raw = (snap.measured_pos[i] - prev_pos_[i]) / dt;
|
const double raw = (snap.measured_pos[i] - prev_pos_[i]) / dt;
|
||||||
@@ -300,7 +442,29 @@ hardware_interface::return_type IIWAHardwareInterface::read(
|
|||||||
return hardware_interface::return_type::OK;
|
return hardware_interface::return_type::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (communication_fault_.load(std::memory_order_acquire)) {
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
const auto snap = fri_client_->getStateSnapshot();
|
const auto snap = fri_client_->getStateSnapshot();
|
||||||
|
if (state_guard_.validateSnapshot(snap) != StateGuardResult::OK) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "FRI передал некорректный снимок состояния");
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto generation = fri_client_->getSnapshotGeneration();
|
||||||
|
if (generation == last_snapshot_generation_) {
|
||||||
|
++stale_snapshot_cycles_;
|
||||||
|
if (stale_snapshot_cycles_ > 20U) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "FRI не передаёт новые пакеты состояния");
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last_snapshot_generation_ = generation;
|
||||||
|
stale_snapshot_cycles_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Обнаружение потери управления: неожиданный выход из COMMANDING_ACTIVE.
|
// Обнаружение потери управления: неожиданный выход из COMMANDING_ACTIVE.
|
||||||
const auto current_state = fri_client_->getSessionState();
|
const auto current_state = fri_client_->getSessionState();
|
||||||
@@ -313,6 +477,12 @@ hardware_interface::return_type IIWAHardwareInterface::read(
|
|||||||
}
|
}
|
||||||
previous_session_state_ = current_state;
|
previous_session_state_ = current_state;
|
||||||
|
|
||||||
|
if (snap.safety_state != KUKA::FRI::NORMAL_OPERATION) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG), "FRI сообщил safety stop (%d)", snap.safety_state);
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
compute_velocity_(snap);
|
compute_velocity_(snap);
|
||||||
|
|
||||||
for (size_t i = 0; i < N_JOINTS; ++i) {
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
@@ -334,16 +504,51 @@ hardware_interface::return_type IIWAHardwareInterface::write(
|
|||||||
return hardware_interface::return_type::OK;
|
return hardware_interface::return_type::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fri_client_->getSessionState() != KUKA::FRI::COMMANDING_ACTIVE) {
|
if (communication_fault_.load(std::memory_order_acquire)) {
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto current_state = fri_client_->getSessionState();
|
||||||
|
if (current_state != KUKA::FRI::COMMANDING_ACTIVE) {
|
||||||
return hardware_interface::return_type::OK;
|
return hardware_interface::return_type::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto snap = fri_client_->getStateSnapshot();
|
||||||
|
const auto state_guard_result =
|
||||||
|
state_guard_.validatePositionState(snap, current_state);
|
||||||
|
if (state_guard_result != StateGuardResult::OK) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR_THROTTLE(rclcpp::get_logger(LOG), throttle_clock_, 1000,
|
||||||
|
"StateGuard запретил position-команду: %s",
|
||||||
|
stateGuardResultName(state_guard_result));
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
std::array<double, N_JOINTS> pos_cmd{};
|
std::array<double, N_JOINTS> pos_cmd{};
|
||||||
for (size_t i = 0; i < N_JOINTS; ++i) {
|
for (size_t i = 0; i < N_JOINTS; ++i) {
|
||||||
get_command(h_cmd_pos_[i], pos_cmd[i], false);
|
get_command(h_cmd_pos_[i], pos_cmd[i], false);
|
||||||
|
if (!std::isfinite(pos_cmd[i]) || pos_cmd[i] < command_min_[i] ||
|
||||||
|
pos_cmd[i] > command_max_[i]) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG),
|
||||||
|
"Команда %s вне допустимого диапазона: %.9f", info_.joints[i].name.c_str(), pos_cmd[i]);
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto command_guard_result = command_guard_.validate(
|
||||||
|
pos_cmd, last_command_pos_, snap.sample_time, command_initialized_);
|
||||||
|
if (command_guard_result != CommandGuardResult::OK) {
|
||||||
|
communication_fault_.store(true, std::memory_order_release);
|
||||||
|
RCLCPP_ERROR(rclcpp::get_logger(LOG),
|
||||||
|
"CommandGuard запретил position-команду: %s",
|
||||||
|
commandGuardResultName(command_guard_result));
|
||||||
|
return hardware_interface::return_type::ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
fri_client_->setTargetJointPositions(pos_cmd);
|
fri_client_->setTargetJointPositions(pos_cmd);
|
||||||
|
last_command_pos_ = pos_cmd;
|
||||||
|
command_initialized_ = true;
|
||||||
|
|
||||||
return hardware_interface::return_type::OK;
|
return hardware_interface::return_type::OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,5 +54,20 @@ install(FILES
|
|||||||
DESTINATION share/${PROJECT_NAME}
|
DESTINATION share/${PROJECT_NAME}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
find_package(ament_cmake_pytest REQUIRED)
|
||||||
|
|
||||||
|
ament_add_pytest_test(
|
||||||
|
test_move_to_pose_server
|
||||||
|
test/test_move_to_pose_server.py
|
||||||
|
APPEND_ENV PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
ament_add_pytest_test(
|
||||||
|
test_motion_sequence_runner
|
||||||
|
test/test_motion_sequence_runner.py
|
||||||
|
APPEND_ENV PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
ament_package()
|
ament_package()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
|
|
||||||
<test_depend>ament_lint_auto</test_depend>
|
<test_depend>ament_lint_auto</test_depend>
|
||||||
<test_depend>ament_lint_common</test_depend>
|
<test_depend>ament_lint_common</test_depend>
|
||||||
|
<test_depend>ament_cmake_pytest</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_cmake</build_type>
|
<build_type>ament_cmake</build_type>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ from rosidl_runtime_py.utilities import get_message
|
|||||||
from iiwa_msgs.action import MoveToJoints, MoveToPose
|
from iiwa_msgs.action import MoveToJoints, MoveToPose
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_RESULT_TIMEOUT_SEC = 300.0
|
||||||
|
|
||||||
|
|
||||||
def _is_joints_waypoint(wp: dict) -> bool:
|
def _is_joints_waypoint(wp: dict) -> bool:
|
||||||
return "joints" in wp
|
return "joints" in wp
|
||||||
|
|
||||||
@@ -92,8 +96,15 @@ class MotionSequenceRunner(Node):
|
|||||||
|
|
||||||
|
|
||||||
def _init_bag(self):
|
def _init_bag(self):
|
||||||
bag_dir = Path(self._bag_path)
|
bag_dir = Path(self._bag_path).expanduser().resolve(strict=False)
|
||||||
|
tmp_root = Path(tempfile.gettempdir()).resolve(strict=False)
|
||||||
|
if bag_dir == tmp_root or tmp_root not in bag_dir.parents:
|
||||||
|
raise ValueError(f'bag_path must stay under temporary root {tmp_root}')
|
||||||
|
|
||||||
|
self._bag_path = str(bag_dir)
|
||||||
if bag_dir.exists():
|
if bag_dir.exists():
|
||||||
|
if not bag_dir.is_dir():
|
||||||
|
raise ValueError(f'bag_path already exists and is not a directory: {bag_dir}')
|
||||||
shutil.rmtree(bag_dir)
|
shutil.rmtree(bag_dir)
|
||||||
self.get_logger().info(f'Removed existing bag at {self._bag_path}')
|
self.get_logger().info(f'Removed existing bag at {self._bag_path}')
|
||||||
|
|
||||||
@@ -171,6 +182,7 @@ class MotionSequenceRunner(Node):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
done = threading.Event()
|
done = threading.Event()
|
||||||
|
goal_handle_holder = [None]
|
||||||
result_holder: list[bool] = [False]
|
result_holder: list[bool] = [False]
|
||||||
|
|
||||||
def _on_result(future):
|
def _on_result(future):
|
||||||
@@ -180,6 +192,7 @@ class MotionSequenceRunner(Node):
|
|||||||
|
|
||||||
def _on_goal(future):
|
def _on_goal(future):
|
||||||
gh = future.result()
|
gh = future.result()
|
||||||
|
goal_handle_holder[0] = gh
|
||||||
if not gh.accepted:
|
if not gh.accepted:
|
||||||
self.get_logger().error('MoveToJoints goal rejected')
|
self.get_logger().error('MoveToJoints goal rejected')
|
||||||
done.set()
|
done.set()
|
||||||
@@ -187,7 +200,12 @@ class MotionSequenceRunner(Node):
|
|||||||
gh.get_result_async().add_done_callback(_on_result)
|
gh.get_result_async().add_done_callback(_on_result)
|
||||||
|
|
||||||
self._joints_client.send_goal_async(goal).add_done_callback(_on_goal)
|
self._joints_client.send_goal_async(goal).add_done_callback(_on_goal)
|
||||||
done.wait()
|
if not done.wait(timeout=ACTION_RESULT_TIMEOUT_SEC):
|
||||||
|
gh = goal_handle_holder[0]
|
||||||
|
if gh is not None:
|
||||||
|
gh.cancel_goal_async()
|
||||||
|
self.get_logger().error('MoveToJoints action timed out and was canceled')
|
||||||
|
return False
|
||||||
return result_holder[0]
|
return result_holder[0]
|
||||||
|
|
||||||
def _send_pose_goal(self, wp: dict, idx: int | None = None) -> bool:
|
def _send_pose_goal(self, wp: dict, idx: int | None = None) -> bool:
|
||||||
@@ -206,6 +224,7 @@ class MotionSequenceRunner(Node):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
done = threading.Event()
|
done = threading.Event()
|
||||||
|
goal_handle_holder = [None]
|
||||||
result_holder: list[bool] = [False]
|
result_holder: list[bool] = [False]
|
||||||
|
|
||||||
def _on_result(future):
|
def _on_result(future):
|
||||||
@@ -215,6 +234,7 @@ class MotionSequenceRunner(Node):
|
|||||||
|
|
||||||
def _on_goal(future):
|
def _on_goal(future):
|
||||||
gh = future.result()
|
gh = future.result()
|
||||||
|
goal_handle_holder[0] = gh
|
||||||
if not gh.accepted:
|
if not gh.accepted:
|
||||||
self.get_logger().error('MoveToPose goal rejected')
|
self.get_logger().error('MoveToPose goal rejected')
|
||||||
done.set()
|
done.set()
|
||||||
@@ -222,7 +242,12 @@ class MotionSequenceRunner(Node):
|
|||||||
gh.get_result_async().add_done_callback(_on_result)
|
gh.get_result_async().add_done_callback(_on_result)
|
||||||
|
|
||||||
self._pose_client.send_goal_async(goal).add_done_callback(_on_goal)
|
self._pose_client.send_goal_async(goal).add_done_callback(_on_goal)
|
||||||
done.wait()
|
if not done.wait(timeout=ACTION_RESULT_TIMEOUT_SEC):
|
||||||
|
gh = goal_handle_holder[0]
|
||||||
|
if gh is not None:
|
||||||
|
gh.cancel_goal_async()
|
||||||
|
self.get_logger().error('MoveToPose action timed out and was canceled')
|
||||||
|
return False
|
||||||
return result_holder[0]
|
return result_holder[0]
|
||||||
|
|
||||||
def _send_waypoint(self, wp: dict, idx: int | None = None) -> bool:
|
def _send_waypoint(self, wp: dict, idx: int | None = None) -> bool:
|
||||||
@@ -236,12 +261,18 @@ class MotionSequenceRunner(Node):
|
|||||||
for i in range(self._n_iter):
|
for i in range(self._n_iter):
|
||||||
self.get_logger().info(f'======= Iteration {i + 1}/{self._n_iter} =======')
|
self.get_logger().info(f'======= Iteration {i + 1}/{self._n_iter} =======')
|
||||||
|
|
||||||
self._send_waypoint(self._home)
|
if not self._send_waypoint(self._home):
|
||||||
|
self.get_logger().info('Home waypoint failed, stopping sequence')
|
||||||
|
return
|
||||||
|
|
||||||
for idx, wp in enumerate(self._waypoints):
|
for idx, wp in enumerate(self._waypoints):
|
||||||
self._send_waypoint(wp, idx=idx)
|
if not self._send_waypoint(wp, idx=idx):
|
||||||
|
self.get_logger().info(f'Waypoint #{idx} failed, stopping sequence')
|
||||||
|
return
|
||||||
|
|
||||||
self._send_waypoint(self._home)
|
if not self._send_waypoint(self._home):
|
||||||
|
self.get_logger().info('Final home waypoint failed, stopping sequence')
|
||||||
|
return
|
||||||
time.sleep(self._delay)
|
time.sleep(self._delay)
|
||||||
|
|
||||||
self.get_logger().info('======= Sequence complete =======')
|
self.get_logger().info('======= Sequence complete =======')
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
@@ -28,6 +30,10 @@ PLANNERS = {
|
|||||||
"chomp": ("chomp", "", 10.0),
|
"chomp": ("chomp", "", 10.0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_EXECUTION_TIMEOUT_SEC = 30.0
|
||||||
|
EXECUTION_POLL_PERIOD_SEC = 0.05
|
||||||
|
STOP_WAIT_TIMEOUT_SEC = 0.1
|
||||||
|
|
||||||
|
|
||||||
def _abc_to_quaternion(a: float, b: float, c: float):
|
def _abc_to_quaternion(a: float, b: float, c: float):
|
||||||
"""ZYX Euler (радианы, конвенция KUKA ABC) → (qx, qy, qz, qw)."""
|
"""ZYX Euler (радианы, конвенция KUKA ABC) → (qx, qy, qz, qw)."""
|
||||||
@@ -59,12 +65,14 @@ class IiwaMotionServer(Node):
|
|||||||
self.declare_parameter("default_frame", "base_link")
|
self.declare_parameter("default_frame", "base_link")
|
||||||
self.declare_parameter("default_planner", "ompl")
|
self.declare_parameter("default_planner", "ompl")
|
||||||
self.declare_parameter("planning_attempts", 3)
|
self.declare_parameter("planning_attempts", 3)
|
||||||
|
self.declare_parameter("execution_timeout_sec", DEFAULT_EXECUTION_TIMEOUT_SEC)
|
||||||
|
|
||||||
self._pose_link = self.get_parameter("pose_link").value
|
self._pose_link = self.get_parameter("pose_link").value
|
||||||
self._planning_group = self.get_parameter("planning_group").value
|
self._planning_group = self.get_parameter("planning_group").value
|
||||||
self._default_frame = self.get_parameter("default_frame").value
|
self._default_frame = self.get_parameter("default_frame").value
|
||||||
self._default_planner = self.get_parameter("default_planner").value
|
self._default_planner = self.get_parameter("default_planner").value
|
||||||
self._planning_attempts = self.get_parameter("planning_attempts").value
|
self._planning_attempts = self.get_parameter("planning_attempts").value
|
||||||
|
self._execution_timeout_sec = float(self.get_parameter("execution_timeout_sec").value)
|
||||||
|
|
||||||
def _setup_moveit(self):
|
def _setup_moveit(self):
|
||||||
self._moveit = MoveItPy(node_name="iiwa_motion_server")
|
self._moveit = MoveItPy(node_name="iiwa_motion_server")
|
||||||
@@ -73,17 +81,19 @@ class IiwaMotionServer(Node):
|
|||||||
|
|
||||||
def _setup_servers(self):
|
def _setup_servers(self):
|
||||||
cb = ReentrantCallbackGroup()
|
cb = ReentrantCallbackGroup()
|
||||||
|
self._motion_lock = threading.Lock()
|
||||||
|
self._active_motion_kind: str | None = None
|
||||||
|
|
||||||
ActionServer(
|
ActionServer(
|
||||||
self, MoveToPose, "cobot/move_to_pose", self._execute_pose,
|
self, MoveToPose, "cobot/move_to_pose", self._execute_pose,
|
||||||
callback_group=cb,
|
callback_group=cb,
|
||||||
goal_callback=lambda _: GoalResponse.ACCEPT,
|
goal_callback=self._handle_pose_goal,
|
||||||
cancel_callback=lambda _: CancelResponse.ACCEPT,
|
cancel_callback=lambda _: CancelResponse.ACCEPT,
|
||||||
)
|
)
|
||||||
ActionServer(
|
ActionServer(
|
||||||
self, MoveToJoints, "cobot/move_to_joints", self._execute_joints,
|
self, MoveToJoints, "cobot/move_to_joints", self._execute_joints,
|
||||||
callback_group=cb,
|
callback_group=cb,
|
||||||
goal_callback=lambda _: GoalResponse.ACCEPT,
|
goal_callback=self._handle_joints_goal,
|
||||||
cancel_callback=lambda _: CancelResponse.ACCEPT,
|
cancel_callback=lambda _: CancelResponse.ACCEPT,
|
||||||
)
|
)
|
||||||
self.create_service(MoveToNamedPose, "cobot/move_to_named", self._handle_named, callback_group=cb)
|
self.create_service(MoveToNamedPose, "cobot/move_to_named", self._handle_named, callback_group=cb)
|
||||||
@@ -99,6 +109,148 @@ class IiwaMotionServer(Node):
|
|||||||
params.max_acceleration_scaling_factor = accel_scale if accel_scale is not None else velocity_scale
|
params.max_acceleration_scaling_factor = accel_scale if accel_scale is not None else velocity_scale
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
def _busy_motion_message(self) -> str:
|
||||||
|
current = self._active_motion_kind or "motion"
|
||||||
|
return f"Another {current} is already in progress; parallel motions are not allowed"
|
||||||
|
|
||||||
|
def _reserve_motion(self, motion_kind: str) -> bool:
|
||||||
|
if not self._motion_lock.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
self._active_motion_kind = motion_kind
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _release_motion(self):
|
||||||
|
if self._motion_lock.locked():
|
||||||
|
self._active_motion_kind = None
|
||||||
|
self._motion_lock.release()
|
||||||
|
|
||||||
|
def _handle_pose_goal(self, _goal_request):
|
||||||
|
if self._reserve_motion("pose"):
|
||||||
|
return GoalResponse.ACCEPT
|
||||||
|
self.get_logger().error(self._busy_motion_message())
|
||||||
|
return GoalResponse.REJECT
|
||||||
|
|
||||||
|
def _handle_joints_goal(self, _goal_request):
|
||||||
|
if self._reserve_motion("joints"):
|
||||||
|
return GoalResponse.ACCEPT
|
||||||
|
self.get_logger().error(self._busy_motion_message())
|
||||||
|
return GoalResponse.REJECT
|
||||||
|
|
||||||
|
def _ensure_motion_reservation(self, motion_kind: str) -> bool:
|
||||||
|
if self._motion_lock.locked():
|
||||||
|
return False
|
||||||
|
if self._reserve_motion(motion_kind):
|
||||||
|
return True
|
||||||
|
raise RuntimeError(self._busy_motion_message())
|
||||||
|
|
||||||
|
def _normalize_scale(self, raw_value, label: str, minimum: float = 0.01) -> tuple[float | None, str | None]:
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, f"Invalid {label}: expected a finite number"
|
||||||
|
|
||||||
|
if not math.isfinite(value):
|
||||||
|
return None, f"Invalid {label}: expected a finite number"
|
||||||
|
|
||||||
|
return max(minimum, min(1.0, value)), None
|
||||||
|
|
||||||
|
def _normalize_named_accel(self, raw_value, velocity_scale: float) -> tuple[float | None, str | None]:
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "Invalid accel_scale: expected a finite number"
|
||||||
|
|
||||||
|
if not math.isfinite(value):
|
||||||
|
return None, "Invalid accel_scale: expected a finite number"
|
||||||
|
|
||||||
|
if value <= 0.0:
|
||||||
|
return velocity_scale, None
|
||||||
|
|
||||||
|
return max(0.01, min(1.0, value)), None
|
||||||
|
|
||||||
|
def _validate_pose_goal(self, request) -> str | None:
|
||||||
|
values = (request.x, request.y, request.z, request.a, request.b, request.c)
|
||||||
|
try:
|
||||||
|
finite = all(math.isfinite(float(value)) for value in values)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
finite = False
|
||||||
|
if not finite:
|
||||||
|
return "Invalid pose: all coordinates and ABC angles must be finite"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _joint_bounds_for_group(self) -> list[tuple[float | None, float | None]] | None:
|
||||||
|
if hasattr(self._robot_model, "get_joint_bounds"):
|
||||||
|
return list(self._robot_model.get_joint_bounds(self._planning_group))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _validate_joint_goal(self, raw_joints) -> tuple[list[float] | None, str | None]:
|
||||||
|
try:
|
||||||
|
joints = [float(value) for value in raw_joints]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "Invalid joints: expected numeric joint targets"
|
||||||
|
|
||||||
|
if not all(math.isfinite(value) for value in joints):
|
||||||
|
return None, "Invalid joints: expected finite joint targets"
|
||||||
|
|
||||||
|
bounds = self._joint_bounds_for_group()
|
||||||
|
expected_count = len(bounds) if bounds is not None else 7
|
||||||
|
if len(joints) != expected_count:
|
||||||
|
return None, f"Invalid joints: expected {expected_count} values"
|
||||||
|
|
||||||
|
if bounds is None:
|
||||||
|
return joints, None
|
||||||
|
|
||||||
|
for index, (value, bound_pair) in enumerate(zip(joints, bounds)):
|
||||||
|
lower, upper = bound_pair
|
||||||
|
if lower is not None and value < lower:
|
||||||
|
return None, f"Joint {index} violates limit [{lower}, {upper}]"
|
||||||
|
if upper is not None and value > upper:
|
||||||
|
return None, f"Joint {index} violates limit [{lower}, {upper}]"
|
||||||
|
|
||||||
|
return joints, None
|
||||||
|
|
||||||
|
def _plan_result_valid(self, plan_result) -> bool:
|
||||||
|
if not plan_result:
|
||||||
|
return False
|
||||||
|
|
||||||
|
trajectory = getattr(plan_result, "trajectory", None)
|
||||||
|
if trajectory is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
error_code = getattr(plan_result, "error_code", None)
|
||||||
|
if error_code is not None and getattr(error_code, "val", error_code) != 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = getattr(plan_result, "success", None)
|
||||||
|
if success is not None and not bool(success):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _execution_result_successful(self, execution_result) -> bool:
|
||||||
|
if execution_result is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = getattr(execution_result, "success", None)
|
||||||
|
if success is not None:
|
||||||
|
return bool(success)
|
||||||
|
|
||||||
|
error_code = getattr(execution_result, "error_code", None)
|
||||||
|
if error_code is not None:
|
||||||
|
return getattr(error_code, "val", error_code) == 1
|
||||||
|
|
||||||
|
status = getattr(execution_result, "status", None)
|
||||||
|
if status is not None:
|
||||||
|
return status == 1
|
||||||
|
|
||||||
|
return bool(execution_result)
|
||||||
|
|
||||||
|
def _stop_execution(self):
|
||||||
|
try:
|
||||||
|
self._moveit.get_trajectory_execution_manager().stop_execution()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _plan_and_execute(self, plan_params: PlanRequestParameters, goal_handle):
|
def _plan_and_execute(self, plan_params: PlanRequestParameters, goal_handle):
|
||||||
"""Планирует траекторию и выполняет её с поддержкой отмены.
|
"""Планирует траекторию и выполняет её с поддержкой отмены.
|
||||||
|
|
||||||
@@ -106,7 +258,7 @@ class IiwaMotionServer(Node):
|
|||||||
(None, 'canceled') если цель была отменена.
|
(None, 'canceled') если цель была отменена.
|
||||||
"""
|
"""
|
||||||
plan_result = self._arm.plan(single_plan_parameters=plan_params)
|
plan_result = self._arm.plan(single_plan_parameters=plan_params)
|
||||||
if not plan_result:
|
if not self._plan_result_valid(plan_result):
|
||||||
return False, "Планирование не удалось: поза недостижима или в столкновении"
|
return False, "Планирование не удалось: поза недостижима или в столкновении"
|
||||||
|
|
||||||
if goal_handle.is_cancel_requested:
|
if goal_handle.is_cancel_requested:
|
||||||
@@ -114,10 +266,14 @@ class IiwaMotionServer(Node):
|
|||||||
|
|
||||||
done = threading.Event()
|
done = threading.Event()
|
||||||
failed = threading.Event()
|
failed = threading.Event()
|
||||||
|
execution_success = {"ok": False}
|
||||||
|
|
||||||
def do_execute():
|
def do_execute():
|
||||||
try:
|
try:
|
||||||
self._moveit.execute(plan_result.trajectory, controllers=[])
|
execution_result = self._moveit.execute(plan_result.trajectory, controllers=[])
|
||||||
|
execution_success["ok"] = self._execution_result_successful(execution_result)
|
||||||
|
if not execution_success["ok"]:
|
||||||
|
failed.set()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.get_logger().error(f"Ошибка выполнения траектории: {exc}")
|
self.get_logger().error(f"Ошибка выполнения траектории: {exc}")
|
||||||
failed.set()
|
failed.set()
|
||||||
@@ -125,15 +281,17 @@ class IiwaMotionServer(Node):
|
|||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
threading.Thread(target=do_execute, daemon=True).start()
|
threading.Thread(target=do_execute, daemon=True).start()
|
||||||
|
started_at = time.monotonic()
|
||||||
|
|
||||||
while not done.wait(timeout=0.05):
|
while not done.wait(timeout=EXECUTION_POLL_PERIOD_SEC):
|
||||||
if goal_handle.is_cancel_requested:
|
if goal_handle.is_cancel_requested:
|
||||||
try:
|
self._stop_execution()
|
||||||
self._moveit.get_trajectory_execution_manager().stop_execution()
|
done.wait(timeout=STOP_WAIT_TIMEOUT_SEC)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
done.wait()
|
|
||||||
return None, "canceled"
|
return None, "canceled"
|
||||||
|
if time.monotonic() - started_at >= self._execution_timeout_sec:
|
||||||
|
self._stop_execution()
|
||||||
|
done.wait(timeout=STOP_WAIT_TIMEOUT_SEC)
|
||||||
|
return False, f"Trajectory execution timeout after {self._execution_timeout_sec:.2f} s"
|
||||||
|
|
||||||
if failed.is_set():
|
if failed.is_set():
|
||||||
return False, "Выполнение траектории завершилось ошибкой"
|
return False, "Выполнение траектории завершилось ошибкой"
|
||||||
@@ -154,123 +312,164 @@ class IiwaMotionServer(Node):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _execute_pose(self, goal_handle):
|
def _execute_pose(self, goal_handle):
|
||||||
|
reserved_here = False
|
||||||
req = goal_handle.request
|
req = goal_handle.request
|
||||||
feedback = MoveToPose.Feedback()
|
feedback = MoveToPose.Feedback()
|
||||||
result = MoveToPose.Result()
|
result = MoveToPose.Result()
|
||||||
|
|
||||||
velocity_scale = max(0.01, min(1.0, float(req.speed)))
|
try:
|
||||||
planner_key = (req.planner or self._default_planner).lower()
|
reserved_here = self._ensure_motion_reservation("pose")
|
||||||
|
velocity_scale, error = self._normalize_scale(req.speed, "speed")
|
||||||
|
if error is not None:
|
||||||
|
return self._finish_action(goal_handle, result, False, error)
|
||||||
|
error = self._validate_pose_goal(req)
|
||||||
|
if error is not None:
|
||||||
|
return self._finish_action(goal_handle, result, False, error)
|
||||||
|
|
||||||
if planner_key not in PLANNERS:
|
planner_key = (req.planner or self._default_planner).lower()
|
||||||
result.success = False
|
if planner_key not in PLANNERS:
|
||||||
result.message = f"Неизвестный планировщик '{planner_key}'. Доступные: {', '.join(PLANNERS)}"
|
return self._finish_action(
|
||||||
self.get_logger().error(result.message)
|
goal_handle,
|
||||||
goal_handle.abort()
|
result,
|
||||||
return result
|
False,
|
||||||
|
f"Неизвестный планировщик '{planner_key}'. Доступные: {', '.join(PLANNERS)}",
|
||||||
|
)
|
||||||
|
|
||||||
pipeline, planner_id, plan_time = PLANNERS[planner_key]
|
pipeline, planner_id, plan_time = PLANNERS[planner_key]
|
||||||
|
|
||||||
pose = PoseStamped()
|
pose = PoseStamped()
|
||||||
pose.header.frame_id = req.frame_id or self._default_frame
|
pose.header.frame_id = req.frame_id or self._default_frame
|
||||||
pose.pose.position.x = req.x
|
pose.pose.position.x = req.x
|
||||||
pose.pose.position.y = req.y
|
pose.pose.position.y = req.y
|
||||||
pose.pose.position.z = req.z
|
pose.pose.position.z = req.z
|
||||||
qx, qy, qz, qw = _abc_to_quaternion(req.a, req.b, req.c)
|
qx, qy, qz, qw = _abc_to_quaternion(req.a, req.b, req.c)
|
||||||
pose.pose.orientation.x = qx
|
pose.pose.orientation.x = qx
|
||||||
pose.pose.orientation.y = qy
|
pose.pose.orientation.y = qy
|
||||||
pose.pose.orientation.z = qz
|
pose.pose.orientation.z = qz
|
||||||
pose.pose.orientation.w = qw
|
pose.pose.orientation.w = qw
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"[pose] xyz=({req.x:.3f}, {req.y:.3f}, {req.z:.3f}) "
|
f"[pose] xyz=({req.x:.3f}, {req.y:.3f}, {req.z:.3f}) "
|
||||||
f"abc=({req.a:.3f}, {req.b:.3f}, {req.c:.3f}) рад "
|
f"abc=({req.a:.3f}, {req.b:.3f}, {req.c:.3f}) рад "
|
||||||
f"speed={velocity_scale:.2f} planner={planner_key}"
|
f"speed={velocity_scale:.2f} planner={planner_key}"
|
||||||
)
|
)
|
||||||
|
|
||||||
feedback.state = "planning"
|
feedback.state = "planning"
|
||||||
goal_handle.publish_feedback(feedback)
|
goal_handle.publish_feedback(feedback)
|
||||||
|
|
||||||
self._arm.set_start_state_to_current_state()
|
self._arm.set_start_state_to_current_state()
|
||||||
self._arm.set_goal_state(pose_stamped_msg=pose, pose_link=self._pose_link)
|
self._arm.set_goal_state(pose_stamped_msg=pose, pose_link=self._pose_link)
|
||||||
|
|
||||||
feedback.state = "executing"
|
feedback.state = "executing"
|
||||||
goal_handle.publish_feedback(feedback)
|
goal_handle.publish_feedback(feedback)
|
||||||
|
|
||||||
plan_params = self._make_plan_params(pipeline, planner_id, plan_time, velocity_scale)
|
plan_params = self._make_plan_params(pipeline, planner_id, plan_time, velocity_scale)
|
||||||
ok, msg = self._plan_and_execute(plan_params, goal_handle)
|
ok, msg = self._plan_and_execute(plan_params, goal_handle)
|
||||||
return self._finish_action(goal_handle, result, ok, msg)
|
return self._finish_action(goal_handle, result, ok, msg)
|
||||||
|
finally:
|
||||||
|
if reserved_here or self._motion_lock.locked():
|
||||||
|
self._release_motion()
|
||||||
|
|
||||||
def _execute_joints(self, goal_handle):
|
def _execute_joints(self, goal_handle):
|
||||||
|
reserved_here = False
|
||||||
req = goal_handle.request
|
req = goal_handle.request
|
||||||
feedback = MoveToJoints.Feedback()
|
feedback = MoveToJoints.Feedback()
|
||||||
result = MoveToJoints.Result()
|
result = MoveToJoints.Result()
|
||||||
|
|
||||||
velocity_scale = max(0.01, min(1.0, float(req.speed)))
|
try:
|
||||||
joints = list(req.joints)
|
reserved_here = self._ensure_motion_reservation("joints")
|
||||||
|
velocity_scale, error = self._normalize_scale(req.speed, "speed")
|
||||||
|
if error is not None:
|
||||||
|
return self._finish_action(goal_handle, result, False, error)
|
||||||
|
|
||||||
self.get_logger().info(
|
joints, error = self._validate_joint_goal(req.joints)
|
||||||
f"[joints] {[f'{v:.3f}' for v in joints]} speed={velocity_scale:.2f}"
|
if error is not None:
|
||||||
)
|
return self._finish_action(goal_handle, result, False, error)
|
||||||
|
|
||||||
feedback.state = "planning"
|
self.get_logger().info(
|
||||||
goal_handle.publish_feedback(feedback)
|
f"[joints] {[f'{v:.3f}' for v in joints]} speed={velocity_scale:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
# Формируем целевое состояние по суставным координатам
|
feedback.state = "planning"
|
||||||
goal_state = RobotState(self._robot_model)
|
goal_handle.publish_feedback(feedback)
|
||||||
goal_state.set_joint_group_positions(self._planning_group, joints)
|
|
||||||
goal_state.update()
|
|
||||||
|
|
||||||
self._arm.set_start_state_to_current_state()
|
goal_state = RobotState(self._robot_model)
|
||||||
self._arm.set_goal_state(robot_state=goal_state)
|
goal_state.set_joint_group_positions(self._planning_group, joints)
|
||||||
|
goal_state.update()
|
||||||
|
|
||||||
feedback.state = "executing"
|
self._arm.set_start_state_to_current_state()
|
||||||
goal_handle.publish_feedback(feedback)
|
self._arm.set_goal_state(robot_state=goal_state)
|
||||||
|
|
||||||
plan_params = self._make_plan_params(
|
feedback.state = "executing"
|
||||||
"ompl", "RRTConnectkConfigDefault", 10.0, velocity_scale
|
goal_handle.publish_feedback(feedback)
|
||||||
)
|
|
||||||
ok, msg = self._plan_and_execute(plan_params, goal_handle)
|
plan_params = self._make_plan_params(
|
||||||
return self._finish_action(goal_handle, result, ok, msg)
|
"ompl", "RRTConnectkConfigDefault", 10.0, velocity_scale
|
||||||
|
)
|
||||||
|
ok, msg = self._plan_and_execute(plan_params, goal_handle)
|
||||||
|
return self._finish_action(goal_handle, result, ok, msg)
|
||||||
|
finally:
|
||||||
|
if reserved_here or self._motion_lock.locked():
|
||||||
|
self._release_motion()
|
||||||
|
|
||||||
def _handle_named(self, request: MoveToNamedPose.Request, response: MoveToNamedPose.Response):
|
def _handle_named(self, request: MoveToNamedPose.Request, response: MoveToNamedPose.Response):
|
||||||
name = request.name.strip()
|
if not self._reserve_motion("named"):
|
||||||
velocity_scale = max(0.01, min(1.0, float(request.speed)))
|
response.success = False
|
||||||
raw_accel = float(request.accel_scale)
|
response.message = self._busy_motion_message()
|
||||||
accel_scale = max(0.01, min(1.0, raw_accel)) if raw_accel > 0.0 else velocity_scale
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
self.get_logger().info(
|
|
||||||
f"[named] name='{name}' speed={velocity_scale:.2f} accel={accel_scale:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._arm.set_start_state_to_current_state()
|
|
||||||
try:
|
try:
|
||||||
self._arm.set_goal_state(configuration_name=name)
|
name = request.name.strip()
|
||||||
except Exception as exc:
|
velocity_scale, error = self._normalize_scale(request.speed, "speed")
|
||||||
response.success = False
|
if error is not None:
|
||||||
response.message = f"Неизвестное состояние '{name}': {exc}"
|
response.success = False
|
||||||
self.get_logger().error(response.message)
|
response.message = error
|
||||||
return response
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
plan_params = self._make_plan_params(
|
accel_scale, error = self._normalize_named_accel(request.accel_scale, velocity_scale)
|
||||||
"pilz_industrial_motion_planner", "PTP", 2.0, velocity_scale, accel_scale
|
if error is not None:
|
||||||
)
|
response.success = False
|
||||||
plan_result = self._arm.plan(single_plan_parameters=plan_params)
|
response.message = error
|
||||||
if not plan_result:
|
self.get_logger().error(response.message)
|
||||||
response.success = False
|
return response
|
||||||
response.message = f"Не удалось построить траекторию для '{name}'"
|
|
||||||
self.get_logger().error(response.message)
|
|
||||||
return response
|
|
||||||
|
|
||||||
exec_result = self._moveit.execute(plan_result.trajectory, controllers=[])
|
self.get_logger().info(
|
||||||
if exec_result:
|
f"[named] name='{name}' speed={velocity_scale:.2f} accel={accel_scale:.2f}"
|
||||||
response.success = True
|
)
|
||||||
response.message = f"Переместился в '{name}'"
|
|
||||||
self.get_logger().info(response.message)
|
self._arm.set_start_state_to_current_state()
|
||||||
else:
|
try:
|
||||||
response.success = False
|
self._arm.set_goal_state(configuration_name=name)
|
||||||
response.message = f"Выполнение траектории для '{name}' прервано (hardware fault?)"
|
except Exception as exc:
|
||||||
self.get_logger().error(response.message)
|
response.success = False
|
||||||
return response
|
response.message = f"Неизвестное состояние '{name}': {exc}"
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
plan_params = self._make_plan_params(
|
||||||
|
"pilz_industrial_motion_planner", "PTP", 2.0, velocity_scale, accel_scale
|
||||||
|
)
|
||||||
|
plan_result = self._arm.plan(single_plan_parameters=plan_params)
|
||||||
|
if not self._plan_result_valid(plan_result):
|
||||||
|
response.success = False
|
||||||
|
response.message = f"Не удалось построить траекторию для '{name}'"
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
exec_result = self._moveit.execute(plan_result.trajectory, controllers=[])
|
||||||
|
if self._execution_result_successful(exec_result):
|
||||||
|
response.success = True
|
||||||
|
response.message = f"Переместился в '{name}'"
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
else:
|
||||||
|
response.success = False
|
||||||
|
response.message = f"Выполнение траектории для '{name}' завершилось ошибкой"
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
self._release_motion()
|
||||||
|
|
||||||
def _handle_stop(self, request: Trigger.Request, response: Trigger.Response):
|
def _handle_stop(self, request: Trigger.Request, response: Trigger.Response):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -362,9 +362,8 @@ public class ServerFriRos2 extends RoboticsAPIApplication {
|
|||||||
try {
|
try {
|
||||||
_lbr.move(posHold.addMotionOverlay(_friOverlay));
|
_lbr.move(posHold.addMotionOverlay(_friOverlay));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Normal exit path when the ROS 2 client closes the FRI session.
|
getLogger().error("Ошибка Position режима [" + e.getClass().getSimpleName()
|
||||||
// Штатный путь выхода при закрытии FRI-сессии со стороны ROS 2.
|
+ "]: " + String.valueOf(e.getMessage()));
|
||||||
getLogger().info("FRI сеанс закрыт.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFriSession();
|
closeFriSession();
|
||||||
@@ -414,7 +413,8 @@ public class ServerFriRos2 extends RoboticsAPIApplication {
|
|||||||
try {
|
try {
|
||||||
_lbr.move(posHold);
|
_lbr.move(posHold);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
getLogger().info("FRI сеанс закрыт.");
|
getLogger().error("Ошибка Monitor режима [" + e.getClass().getSimpleName()
|
||||||
|
+ "]: " + String.valueOf(e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFriSession();
|
closeFriSession();
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class WebCfg:
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
|
token: Optional[str]
|
||||||
endpoints: str # resolved absolute path to api_endpoints.yaml
|
endpoints: str # resolved absolute path to api_endpoints.yaml
|
||||||
joint_limits: str # resolved absolute path to joint_limits.yaml
|
joint_limits: str # resolved absolute path to joint_limits.yaml
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ _WEB_DEFAULTS: Dict[str, Any] = {
|
|||||||
"enabled": False,
|
"enabled": False,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8007,
|
"port": 8007,
|
||||||
|
"token": None,
|
||||||
"endpoints": "pkg://iiwa_config/config/api_endpoints.yaml",
|
"endpoints": "pkg://iiwa_config/config/api_endpoints.yaml",
|
||||||
"joint_limits": "pkg://iiwa_config/config/moveit/joint_limits.yaml",
|
"joint_limits": "pkg://iiwa_config/config/moveit/joint_limits.yaml",
|
||||||
}
|
}
|
||||||
@@ -201,10 +203,14 @@ def _parse_web(raw: Optional[Dict[str, Any]], settings_dir: str) -> WebCfg:
|
|||||||
def get(key: str) -> Any:
|
def get(key: str) -> Any:
|
||||||
return raw.get(key, _WEB_DEFAULTS[key])
|
return raw.get(key, _WEB_DEFAULTS[key])
|
||||||
|
|
||||||
|
raw_token = get("token")
|
||||||
|
token = str(raw_token).strip() if raw_token is not None else ""
|
||||||
|
|
||||||
return WebCfg(
|
return WebCfg(
|
||||||
enabled=bool(get("enabled")),
|
enabled=bool(get("enabled")),
|
||||||
host=str(get("host")),
|
host=str(get("host")),
|
||||||
port=int(get("port")),
|
port=int(get("port")),
|
||||||
|
token=token or None,
|
||||||
endpoints=resolve_path(str(get("endpoints")), settings_dir),
|
endpoints=resolve_path(str(get("endpoints")), settings_dir),
|
||||||
joint_limits=resolve_path(str(get("joint_limits")), settings_dir),
|
joint_limits=resolve_path(str(get("joint_limits")), settings_dir),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import hmac
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from sensor_msgs.msg import JointState
|
from sensor_msgs.msg import JointState
|
||||||
from std_srvs.srv import Trigger
|
from std_srvs.srv import Trigger
|
||||||
@@ -13,11 +18,100 @@ from .ros_node import CobotWebNode, get_bridge, set_bridge
|
|||||||
from . import runner, trajectory, positions
|
from . import runner, trajectory, positions
|
||||||
|
|
||||||
|
|
||||||
|
_PUBLIC_AUTH_PATHS = frozenset({
|
||||||
|
"/docs",
|
||||||
|
"/docs/oauth2-redirect",
|
||||||
|
"/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_auth_path(path: str) -> bool:
|
||||||
|
return path in _PUBLIC_AUTH_PATHS
|
||||||
|
|
||||||
|
|
||||||
|
def install_bearer_openapi(app: FastAPI, token: str | None) -> None:
|
||||||
|
"""Expose the Bearer scheme so Swagger UI can configure authenticated calls."""
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
|
||||||
|
def custom_openapi():
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
description=app.description,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
schema.setdefault("components", {}).setdefault("securitySchemes", {})[
|
||||||
|
"BearerAuth"
|
||||||
|
] = {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "opaque-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
for path_item in schema.get("paths", {}).values():
|
||||||
|
for operation in path_item.values():
|
||||||
|
if isinstance(operation, dict):
|
||||||
|
operation["security"] = [{"BearerAuth": []}]
|
||||||
|
|
||||||
|
app.openapi_schema = schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_safe_bind(host: str, token: str | None) -> None:
|
||||||
|
normalized = host.strip().lower()
|
||||||
|
if normalized == "localhost":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(normalized).is_loopback:
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Внешний bind Web API разрешён только при заданном IIWA_WEB_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_bearer_token(auth_header: str | None, token: str | None) -> bool:
|
||||||
|
if not token:
|
||||||
|
return True
|
||||||
|
if not auth_header:
|
||||||
|
return False
|
||||||
|
|
||||||
|
scheme, _, provided_token = auth_header.partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or not provided_token:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(provided_token, token)
|
||||||
|
|
||||||
|
|
||||||
|
def install_token_auth(app: FastAPI, token: str | None) -> None:
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def require_bearer_token(request: Request, call_next):
|
||||||
|
if is_public_auth_path(request.url.path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if not has_valid_bearer_token(request.headers.get("authorization"), token):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Bearer token required"},
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
rclpy.init()
|
rclpy.init()
|
||||||
node = CobotWebNode()
|
node = CobotWebNode()
|
||||||
set_bridge(node)
|
|
||||||
threading.Thread(target=rclpy.spin, args=(node,), daemon=True).start()
|
|
||||||
|
|
||||||
host = node.get_parameter('host').value
|
host = node.get_parameter('host').value
|
||||||
port = node.get_parameter('port').value
|
port = node.get_parameter('port').value
|
||||||
@@ -25,6 +119,16 @@ def main():
|
|||||||
joint_limits_path = node.get_parameter('joint_limits_path').value or None
|
joint_limits_path = node.get_parameter('joint_limits_path').value or None
|
||||||
|
|
||||||
positions.init()
|
positions.init()
|
||||||
|
token = os.getenv("IIWA_WEB_TOKEN") or None
|
||||||
|
try:
|
||||||
|
ensure_safe_bind(host, token)
|
||||||
|
except Exception:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
raise
|
||||||
|
|
||||||
|
set_bridge(node)
|
||||||
|
threading.Thread(target=rclpy.spin, args=(node,), daemon=True).start()
|
||||||
|
|
||||||
_schema_app = FastAPI()
|
_schema_app = FastAPI()
|
||||||
_schema_app.include_router(build_dynamic_router(endpoints_path, joint_limits_path))
|
_schema_app.include_router(build_dynamic_router(endpoints_path, joint_limits_path))
|
||||||
@@ -58,6 +162,8 @@ def main():
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return {"status": "stopped", "success": True, "message": "Планировщик не запущен"}
|
return {"status": "stopped", "success": True, "message": "Планировщик не запущен"}
|
||||||
|
|
||||||
|
install_bearer_openapi(app, token)
|
||||||
|
install_token_auth(app, token)
|
||||||
uvicorn.run(app, host=host, port=port)
|
uvicorn.run(app, host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class CobotWebNode(Node):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('cobot_web_node')
|
super().__init__('cobot_web_node')
|
||||||
|
|
||||||
self.declare_parameter('host', '0.0.0.0')
|
self.declare_parameter('host', '127.0.0.1')
|
||||||
self.declare_parameter('port', 8007)
|
self.declare_parameter('port', 8007)
|
||||||
self.declare_parameter('endpoints_path', '')
|
self.declare_parameter('endpoints_path', '')
|
||||||
self.declare_parameter('joint_limits_path', '')
|
self.declare_parameter('joint_limits_path', '')
|
||||||
@@ -102,7 +102,11 @@ class CobotWebNode(Node):
|
|||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while not result_future.done():
|
while not result_future.done():
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
raise TimeoutError(f"Таймаут выполнения action '{action_name}'")
|
cancel_future = goal_handle.cancel_goal_async()
|
||||||
|
cancel_deadline = time.monotonic() + min(2.0, max(timeout, 0.1))
|
||||||
|
while not cancel_future.done() and time.monotonic() <= cancel_deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
raise TimeoutError(f"Таймаут выполнения action '{action_name}', отправлена отмена goal")
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
return result_future.result()
|
return result_future.result()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -13,8 +14,12 @@ from .ros_node import get_bridge
|
|||||||
|
|
||||||
router = APIRouter(prefix="/sequences", tags=["sequences"])
|
router = APIRouter(prefix="/sequences", tags=["sequences"])
|
||||||
|
|
||||||
_UPLOAD_DIR = Path(tempfile.gettempdir()) / "iiwa_configs"
|
_TEMP_ROOT = Path(tempfile.gettempdir()) / "iiwa_web"
|
||||||
_UPLOAD_DIR.mkdir(exist_ok=True)
|
_UPLOAD_DIR = _TEMP_ROOT / "uploads"
|
||||||
|
_BAG_ROOT = _TEMP_ROOT / "bags"
|
||||||
|
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_BAG_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
MAX_UPLOAD_BYTES = 1_048_576
|
||||||
|
|
||||||
_LOG_BUFFER = 300
|
_LOG_BUFFER = 300
|
||||||
|
|
||||||
@@ -22,12 +27,43 @@ _process: Optional[subprocess.Popen] = None
|
|||||||
_log_lines: deque[str] = deque(maxlen=_LOG_BUFFER)
|
_log_lines: deque[str] = deque(maxlen=_LOG_BUFFER)
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
|
|
||||||
|
|
||||||
def _stream_output(proc: subprocess.Popen) -> None:
|
def _stream_output(proc: subprocess.Popen) -> None:
|
||||||
for line in proc.stdout:
|
for line in proc.stdout:
|
||||||
_log_lines.append(line.rstrip("\n"))
|
_log_lines.append(line.rstrip("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_upload_name(filename: str | None) -> str:
|
||||||
|
candidate = Path(filename or "config.json").name
|
||||||
|
if candidate in {"", ".", ".."}:
|
||||||
|
raise HTTPException(422, "Имя файла конфигурации недопустимо")
|
||||||
|
|
||||||
|
sanitized = _SAFE_NAME_RE.sub("_", candidate)
|
||||||
|
if sanitized in {"", ".", ".."}:
|
||||||
|
raise HTTPException(422, "Имя файла конфигурации недопустимо")
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bag_path(raw_bag_path: str) -> str:
|
||||||
|
if not raw_bag_path:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
candidate = Path(raw_bag_path)
|
||||||
|
resolved = candidate.resolve(strict=False) if candidate.is_absolute() else (_BAG_ROOT / candidate).resolve(strict=False)
|
||||||
|
allowed_root = _BAG_ROOT.resolve(strict=False)
|
||||||
|
if resolved == allowed_root:
|
||||||
|
raise HTTPException(422, "bag_path должен указывать на каталог внутри разрешённого временного каталога")
|
||||||
|
try:
|
||||||
|
resolved.relative_to(allowed_root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, "bag_path должен находиться внутри разрешённого временного каталога") from exc
|
||||||
|
|
||||||
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(resolved)
|
||||||
|
|
||||||
|
|
||||||
def _build_cmd(config_path: str, n_iterations: int, delay: float,
|
def _build_cmd(config_path: str, n_iterations: int, delay: float,
|
||||||
bag_path: str, topics: list[str],
|
bag_path: str, topics: list[str],
|
||||||
joints_action: str, pose_action: str) -> list[str]:
|
joints_action: str, pose_action: str) -> list[str]:
|
||||||
@@ -63,9 +99,14 @@ async def start_runner(
|
|||||||
if _process and _process.poll() is None:
|
if _process and _process.poll() is None:
|
||||||
raise HTTPException(409, f"Runner уже запущен (pid={_process.pid})")
|
raise HTTPException(409, f"Runner уже запущен (pid={_process.pid})")
|
||||||
|
|
||||||
filename = config.filename or "config.json"
|
filename = _sanitize_upload_name(config.filename)
|
||||||
dest = _UPLOAD_DIR / filename
|
dest = _UPLOAD_DIR / filename
|
||||||
dest.write_bytes(await config.read())
|
content = await config.read()
|
||||||
|
if len(content) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(413, f"Файл конфигурации слишком большой: максимум {MAX_UPLOAD_BYTES} байт")
|
||||||
|
dest.write_bytes(content)
|
||||||
|
|
||||||
|
safe_bag_path = _resolve_bag_path(bag_path)
|
||||||
|
|
||||||
topics_list = [t.strip() for t in topics.split(",") if t.strip()]
|
topics_list = [t.strip() for t in topics.split(",") if t.strip()]
|
||||||
|
|
||||||
@@ -74,7 +115,7 @@ async def start_runner(
|
|||||||
config_path=str(dest),
|
config_path=str(dest),
|
||||||
n_iterations=n_iterations,
|
n_iterations=n_iterations,
|
||||||
delay=delay_between_iterations,
|
delay=delay_between_iterations,
|
||||||
bag_path=bag_path,
|
bag_path=safe_bag_path,
|
||||||
topics=topics_list,
|
topics=topics_list,
|
||||||
joints_action=joints_action,
|
joints_action=joints_action,
|
||||||
pose_action=pose_action,
|
pose_action=pose_action,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from builtin_interfaces.msg import Duration
|
from builtin_interfaces.msg import Duration
|
||||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
|
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
|
||||||
|
|
||||||
from .config_loader import load_joint_limits, load_joint_names
|
from .config_loader import load_joint_limits, load_joint_names
|
||||||
@@ -41,6 +42,32 @@ def _validate_limits(points: list[list[float]]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rows(rows: list[tuple[list[float], float]]) -> None:
|
||||||
|
previous_time = -math.inf
|
||||||
|
for row_idx, (positions, point_time) in enumerate(rows):
|
||||||
|
if len(positions) != N_JOINTS:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"Точка {row_idx + 1}: ожидалось {N_JOINTS} суставов, получено {len(positions)}",
|
||||||
|
)
|
||||||
|
for joint_idx, position in enumerate(positions):
|
||||||
|
if not math.isfinite(position):
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"Точка {row_idx + 1}, сустав {joint_idx + 1}: позиция должна быть конечным числом",
|
||||||
|
)
|
||||||
|
if not math.isfinite(point_time):
|
||||||
|
raise HTTPException(422, f"Точка {row_idx + 1}: time_from_start должен быть конечным числом")
|
||||||
|
if point_time < previous_time:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"Время точек траектории должно быть монотонно неубывающим",
|
||||||
|
)
|
||||||
|
previous_time = point_time
|
||||||
|
|
||||||
|
_validate_limits([positions for positions, _time in rows])
|
||||||
|
|
||||||
|
|
||||||
def _build_msg(rows: list[tuple[list[float], float]]) -> JointTrajectory:
|
def _build_msg(rows: list[tuple[list[float], float]]) -> JointTrajectory:
|
||||||
msg = JointTrajectory()
|
msg = JointTrajectory()
|
||||||
msg.joint_names = JOINT_NAMES
|
msg.joint_names = JOINT_NAMES
|
||||||
@@ -63,11 +90,35 @@ class Waypoint(BaseModel):
|
|||||||
)
|
)
|
||||||
time_from_start: float = Field(..., ge=0.0, description="Время от начала траектории [с]")
|
time_from_start: float = Field(..., ge=0.0, description="Время от начала траектории [с]")
|
||||||
|
|
||||||
|
@field_validator("positions")
|
||||||
|
@classmethod
|
||||||
|
def validate_positions(cls, value: list[float]) -> list[float]:
|
||||||
|
for joint_idx, position in enumerate(value):
|
||||||
|
if not math.isfinite(position):
|
||||||
|
raise ValueError(f"Позиция сустава {joint_idx + 1} должна быть конечным числом")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("time_from_start")
|
||||||
|
@classmethod
|
||||||
|
def validate_time_from_start(cls, value: float) -> float:
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise ValueError("time_from_start должен быть конечным числом")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SendRequest(BaseModel):
|
class SendRequest(BaseModel):
|
||||||
points: list[Waypoint] = Field(..., min_length=1, description="Точки траектории")
|
points: list[Waypoint] = Field(..., min_length=1, description="Точки траектории")
|
||||||
validate_limits: bool = Field(True, description="Проверять лимиты суставов")
|
validate_limits: bool = Field(True, description="Проверять лимиты суставов")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_time_monotonicity(self) -> "SendRequest":
|
||||||
|
previous_time = -math.inf
|
||||||
|
for waypoint in self.points:
|
||||||
|
if waypoint.time_from_start < previous_time:
|
||||||
|
raise ValueError("Время точек траектории должно быть монотонно неубывающим")
|
||||||
|
previous_time = waypoint.time_from_start
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send", summary="Отправить траекторию вручную (JSON)")
|
@router.post("/send", summary="Отправить траекторию вручную (JSON)")
|
||||||
def send_trajectory(req: SendRequest):
|
def send_trajectory(req: SendRequest):
|
||||||
@@ -76,9 +127,7 @@ def send_trajectory(req: SendRequest):
|
|||||||
Публикует `JointTrajectory` в `/iiwa_arm_controller/joint_trajectory`.
|
Публикует `JointTrajectory` в `/iiwa_arm_controller/joint_trajectory`.
|
||||||
"""
|
"""
|
||||||
rows = [(wp.positions, wp.time_from_start) for wp in req.points]
|
rows = [(wp.positions, wp.time_from_start) for wp in req.points]
|
||||||
|
_validate_rows(rows)
|
||||||
if req.validate_limits:
|
|
||||||
_validate_limits([r[0] for r in rows])
|
|
||||||
|
|
||||||
_publish(_build_msg(rows))
|
_publish(_build_msg(rows))
|
||||||
_log(f"[send] {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
|
_log(f"[send] {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
|
||||||
@@ -163,8 +212,7 @@ async def send_csv_trajectory(
|
|||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(422, "CSV не содержит точек траектории")
|
raise HTTPException(422, "CSV не содержит точек траектории")
|
||||||
|
|
||||||
if validate_limits:
|
_validate_rows(rows)
|
||||||
_validate_limits([r[0] for r in rows])
|
|
||||||
|
|
||||||
_publish(_build_msg(rows))
|
_publish(_build_msg(rows))
|
||||||
_log(f"[csv] {file.filename} → {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
|
_log(f"[csv] {file.filename} → {len(rows)} точек, t_end={rows[-1][1]:.2f}с")
|
||||||
|
|||||||
@@ -11,8 +11,18 @@
|
|||||||
<exec_depend>python3-uvicorn</exec_depend>
|
<exec_depend>python3-uvicorn</exec_depend>
|
||||||
<exec_depend>python3-multipart</exec_depend>
|
<exec_depend>python3-multipart</exec_depend>
|
||||||
<exec_depend>python3-fastmcp</exec_depend>
|
<exec_depend>python3-fastmcp</exec_depend>
|
||||||
|
<exec_depend>python3-pydantic</exec_depend>
|
||||||
|
<exec_depend>python3-yaml</exec_depend>
|
||||||
|
<exec_depend>rclpy</exec_depend>
|
||||||
|
<exec_depend>builtin_interfaces</exec_depend>
|
||||||
|
<exec_depend>rosidl_runtime_py</exec_depend>
|
||||||
|
<exec_depend>rosbag2_py</exec_depend>
|
||||||
|
<exec_depend>iiwa_msgs</exec_depend>
|
||||||
|
<exec_depend>sensor_msgs</exec_depend>
|
||||||
|
<exec_depend>std_srvs</exec_depend>
|
||||||
<exec_depend>tf2_ros</exec_depend>
|
<exec_depend>tf2_ros</exec_depend>
|
||||||
<exec_depend>tf2_py</exec_depend>
|
<exec_depend>tf2_py</exec_depend>
|
||||||
|
<exec_depend>trajectory_msgs</exec_depend>
|
||||||
<exec_depend>moveit_msgs</exec_depend>
|
<exec_depend>moveit_msgs</exec_depend>
|
||||||
|
|
||||||
<test_depend>ament_copyright</test_depend>
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ setup(
|
|||||||
install_requires=[
|
install_requires=[
|
||||||
'setuptools',
|
'setuptools',
|
||||||
'fastapi>=0.100.0',
|
'fastapi>=0.100.0',
|
||||||
|
'pydantic>=2.0.0',
|
||||||
|
'PyYAML',
|
||||||
'starlette>=0.27.0',
|
'starlette>=0.27.0',
|
||||||
'uvicorn[standard]',
|
'uvicorn[standard]',
|
||||||
'python-multipart',
|
'python-multipart',
|
||||||
'fastmcp',
|
'fastmcp',
|
||||||
],
|
],
|
||||||
|
tests_require=['pytest'],
|
||||||
zip_safe=True,
|
zip_safe=True,
|
||||||
maintainer='daniel',
|
maintainer='daniel',
|
||||||
maintainer_email='grabardm@ml-dev.ru',
|
maintainer_email='grabardm@ml-dev.ru',
|
||||||
|
|||||||
Reference in New Issue
Block a user