Compare commits
6
Commits
253df68cd4
...
7250396a1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7250396a1f | ||
|
|
56b86699bd | ||
|
|
9f56b2b334 | ||
|
|
04f0d57068 | ||
|
|
c9cb4b8c9e | ||
|
|
bde8582de3 |
@@ -0,0 +1,58 @@
|
||||
# A pull request would otherwise run this twice on the same commit: once for the
|
||||
# branch push and once for the PR.
|
||||
|
||||
name: validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
DORA_VERSION: v1.0.1
|
||||
DORA_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the dora CLI
|
||||
run: |
|
||||
curl -fsSL -o dora-cli.tar.gz \
|
||||
"https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz"
|
||||
tar xzf dora-cli.tar.gz
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}"
|
||||
|
||||
- run: dora --version
|
||||
|
||||
# strict_types: true in the dataflow turns a type warning into a non-zero
|
||||
# exit, so a mismatched annotation fails here rather than at runtime.
|
||||
- name: Validate the dataflow
|
||||
run: dora validate dataflow.yml
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
# --locked fails if uv.lock is out of date with pyproject.toml, so a
|
||||
# dependency change that was never locked cannot reach main.
|
||||
- run: uv sync --locked --group dev
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv run pytest -q
|
||||
|
||||
markdownlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DavidAnson/markdownlint-cli2-action@v16
|
||||
with:
|
||||
globs: "*.md"
|
||||
@@ -0,0 +1,138 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding assistants when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a
|
||||
web browser, and decides which of the two is driving.
|
||||
|
||||
It is **the piece in the middle and nothing else**. It contains no robot driver — the arm
|
||||
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
|
||||
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by
|
||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
||||
Do not add either here. A teleop node that grew its own HTTP server is exactly the
|
||||
monolith `dora-ulite6` was rewritten to get out of.
|
||||
|
||||
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
|
||||
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
||||
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
|
||||
rebuild the monolith inside a node.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
uv sync # runtime deps
|
||||
uv sync --group dev # plus pytest and ruff
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts
|
||||
expected next to this one.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Two things carry the design: **what may touch the hardware**, and **what must happen when
|
||||
a source goes away**.
|
||||
|
||||
- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists
|
||||
to make: whether a velocity reaches the arm this tick, and from whom. **It never
|
||||
blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that
|
||||
is not an owner actively asking for motion returns a stop, so a case nobody thought of
|
||||
fails by halting rather than by carrying on. Keep it that way: the default has to be
|
||||
"still".
|
||||
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this
|
||||
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what
|
||||
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic.
|
||||
`Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as
|
||||
centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable.
|
||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions,
|
||||
no state, no hardware, because this is the part that has to be right and the cheapest to
|
||||
check. Output is always the arm node's units: mm/s and deg/s, base frame.
|
||||
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
|
||||
rather than one answer per input path. Ported from the monolithic repo's `RobotService`
|
||||
lease because the console's `app.js` is already built on it — it gates its jog pad on
|
||||
`t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean
|
||||
rewriting the page to match the node rather than the other way round.
|
||||
- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a
|
||||
contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`,
|
||||
`position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is
|
||||
where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is
|
||||
reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`).
|
||||
- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an
|
||||
unusable setting stops the node before it joins the dataflow instead of surfacing the
|
||||
first time somebody picks up the pad. Errors name the variable.
|
||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||
dynamic node's stdout.
|
||||
|
||||
## Rules that are not negotiable
|
||||
|
||||
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
|
||||
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
|
||||
- **E-STOP is never gated on the lease.** From either source, at any time.
|
||||
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The
|
||||
controller-side expiry is the backstop; a backstop that fires first backs up nothing.
|
||||
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out
|
||||
any timer.
|
||||
- **Do not reintroduce a web server or a robot driver here.** See above.
|
||||
|
||||
## What this node cannot do
|
||||
|
||||
The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner,
|
||||
so another node publishing `request` will preempt an operator mid-motion and nothing here
|
||||
can prevent it. Say so rather than implying otherwise.
|
||||
|
||||
`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm
|
||||
node, with no response and no error output. What this node sends, it learns nothing back
|
||||
about except through `robot_state` and telemetry.
|
||||
|
||||
## Two manifest rules worth knowing
|
||||
|
||||
`dora-node.yml` is the typed contract a consumer wires against, and the only one they get:
|
||||
no source package is shared between node repositories. `dora validate` checks a dataflow
|
||||
against it without running the node, so CI needs neither a gamepad nor a robot.
|
||||
|
||||
`jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately
|
||||
**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type
|
||||
checks on it, and several Arrow schemas legitimately flow over each. Annotating them would
|
||||
claim a contract neither side keeps.
|
||||
|
||||
## Key APIs
|
||||
|
||||
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
|
||||
iteration via `for event in node`.
|
||||
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux
|
||||
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for
|
||||
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only
|
||||
the display and joystick subsystems are initialised: `pygame.init()` also opens audio,
|
||||
which on a headless robot fills stderr with ALSA errors for a device never used. The
|
||||
display is needed despite there being no window, because SDL refreshes joystick state
|
||||
during the event pump and the pump needs it.
|
||||
|
||||
## Tools
|
||||
|
||||
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
|
||||
issues, pull requests, and other repository operations.
|
||||
|
||||
## Code style
|
||||
|
||||
- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises`
|
||||
sections as needed).
|
||||
- Run `ruff check .` and `ruff format .` before committing. CI enforces both.
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not mention AI tools in commit messages or documentation.
|
||||
@@ -0,0 +1,152 @@
|
||||
# dora-teleop
|
||||
|
||||
A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or
|
||||
open a browser and move it from there — one of the two at a time, decided here.
|
||||
|
||||
It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
|
||||
ordinary `jog` and `request` ports, and talks to the browser through
|
||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
||||
It contains no web server and no robot driver: it is the piece in the middle that decides
|
||||
what the arm should be doing, and that decision is the only thing it owns.
|
||||
|
||||
## The gamepad
|
||||
|
||||
Analog and blended — several axes at once, and how fast depends on how far you push. That
|
||||
is the whole reason a pad beats the web console's four-sector dial pad, which can only ask
|
||||
for one axis at a time at one fixed speed.
|
||||
|
||||
| Control | Does | Units |
|
||||
|-------------|-------------------------------------------|-------|
|
||||
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
|
||||
| Triggers | Lift on the right, lower on the left | mm/s |
|
||||
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
|
||||
| Bumpers | Yaw, left and right | deg/s |
|
||||
| A | Close the tool | — |
|
||||
| B | Open the tool | — |
|
||||
| X | Clear a controller error | — |
|
||||
| Y | Toggle freedrive | — |
|
||||
| Back | **E-STOP** | — |
|
||||
|
||||
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
|
||||
the same convention the web console's dial uses, so the two agree.
|
||||
|
||||
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
|
||||
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
|
||||
environment variable: an index nobody can verify from outside is worse than a line of code
|
||||
somebody can read.
|
||||
|
||||
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
|
||||
behind a deliberate click, not a thumb resting on a face button.
|
||||
|
||||
## The lease
|
||||
|
||||
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
||||
|
||||
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
|
||||
Nobody should have to reach for a browser to use the thing in their hands.
|
||||
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
||||
bench does not lock the browser out forever.
|
||||
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
|
||||
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
|
||||
is better placed than this code to decide they should have it.
|
||||
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
|
||||
the stop is the one moment when asking who owns the arm is the wrong question.
|
||||
|
||||
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
|
||||
Another node publishing `request` will preempt an operator mid-motion and nothing here can
|
||||
stop it — if that matters for your dataflow, do not wire one.
|
||||
|
||||
## The deadman
|
||||
|
||||
An arm under a velocity command keeps moving until told otherwise, so the question is
|
||||
never whether to start but whether anything has happened that means it must stop. Three
|
||||
layers, outermost last:
|
||||
|
||||
1. **Re-send every tick** (50 ms) while a source is driving.
|
||||
2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
|
||||
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged
|
||||
or the last browser disconnects. An unplugged pad is not a centred pad.
|
||||
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
|
||||
the controller whether or not this process is alive.
|
||||
|
||||
Keep them in that order. If this node's timer is longer than the controller's, the backstop
|
||||
fires before the thing it is backing up.
|
||||
|
||||
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
||||
controller, not in this process.
|
||||
|
||||
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
|
||||
believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
|
||||
## Ports
|
||||
|
||||
| Direction | Name | Description |
|
||||
|-----------|----------------|------------------------------------------------------|
|
||||
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
||||
| in | `ui_command` | One console message, from the bridge's `data_out` |
|
||||
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
|
||||
| in | `response` | An answer from the arm |
|
||||
| in | `motion_state` | How a motion ended |
|
||||
| in | `robot_state` | The arm's own state |
|
||||
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
|
||||
| in | `joint_pose` | Joint angles, six float32, degrees |
|
||||
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s |
|
||||
| out | `request` | A service call, `request_id` in the metadata |
|
||||
| out | `ui_state` | The console's telemetry snapshot, as JSON |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|--------------------------|---------|--------------------------------------------------------|
|
||||
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
|
||||
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
|
||||
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
|
||||
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
|
||||
| `JOYSTICK_INDEX` | `0` | Which pad to open |
|
||||
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
|
||||
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
|
||||
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
|
||||
|
||||
A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve
|
||||
stops the node at startup rather than the first time somebody picks up the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
See `dataflow.yml` for the whole stack wired together. The short version:
|
||||
|
||||
```yaml
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs: [jog, request, ui_state]
|
||||
```
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
||||
gamepad if you want one — the node starts without and says so.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that
|
||||
cannot be tested without hardware. Everything else sees a plain `Sample`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# The whole operator stack, and what CI validates.
|
||||
#
|
||||
# Built from checkouts rather than tags, so `dora validate` resolves each node's
|
||||
# manifest from its `path:` and checks the wiring against the versions in front
|
||||
# of you. The sibling repos are expected next to this one, which is how they are
|
||||
# cloned in practice.
|
||||
#
|
||||
# Three nodes, one loop: the bridge carries the browser, this node decides who
|
||||
# drives, and the arm node moves the arm and reports back.
|
||||
|
||||
strict_types: true
|
||||
|
||||
nodes:
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
|
||||
- id: ws-transceiver
|
||||
path: ../dora-websocket-transceiver
|
||||
build: pip install .
|
||||
inputs:
|
||||
data_in: teleop/ui_state
|
||||
outputs:
|
||||
- data_out
|
||||
- clients
|
||||
env:
|
||||
WS_PORT: "9000"
|
||||
STATIC_DIR: "../dora-teleop/web"
|
||||
HTTP_PORT: "9010"
|
||||
|
||||
- id: ulite6
|
||||
path: ../dora-ulite6
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/100
|
||||
request: teleop/request
|
||||
jog: teleop/jog
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
TOOL_TYPE: "vacuum"
|
||||
# Nothing moves because the node started. An operator asks for the first
|
||||
# motion, which is the whole point of a teleop stack.
|
||||
INIT_MODE: "none"
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Node manifest: the typed contract a consumer wires against.
|
||||
#
|
||||
# No source package is shared between node repositories, so this file and its
|
||||
# type URNs are the only contract a consumer has. `dora validate` checks a
|
||||
# dataflow against it without running the node, so CI needs neither a gamepad
|
||||
# nor a robot.
|
||||
#
|
||||
# `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are
|
||||
# deliberately **untyped**. dora treats a port carrying `request_id` as
|
||||
# pattern-correlated and skips type checks on it, and several Arrow schemas
|
||||
# legitimately flow over each — one per service, one per console message.
|
||||
# Annotating them would claim a contract neither side keeps.
|
||||
|
||||
apiVersion: 1
|
||||
name: dora-teleop
|
||||
namespace: liria
|
||||
description: Hands a robot arm to a human, from a gamepad or a browser
|
||||
categories: [robot, teleop, input]
|
||||
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
|
||||
|
||||
runtime: python
|
||||
entrypoint: dora-teleop
|
||||
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
|
||||
# gives the node its own managed venv under `.dora/python-envs/`, so the
|
||||
# dependencies it sees at runtime are the ones installed here.
|
||||
build: pip install .
|
||||
dora: ">=1.0"
|
||||
|
||||
inputs:
|
||||
tick:
|
||||
description: >-
|
||||
Drives everything time-based: re-sending the held velocity, expiring a
|
||||
source that has gone quiet, and publishing the console's telemetry. Use 50
|
||||
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
|
||||
firing between ticks, which an operator feels as the arm stuttering.
|
||||
type: std/core/v1/UInt64
|
||||
ui_command:
|
||||
required: false
|
||||
description: >-
|
||||
One message from the web console, as the JSON string the WebSocket bridge
|
||||
publishes on `data_out`. Wire it from the bridge. A message this node does
|
||||
not recognise is dropped rather than refused, so a page from a newer build
|
||||
cannot take the arm down.
|
||||
clients:
|
||||
required: false
|
||||
description: >-
|
||||
Connect and disconnect events from the WebSocket bridge, as
|
||||
`{connected, event}`. **This is the browser's deadman.** Without it a tab
|
||||
closing mid-jog leaves the arm travelling until the arm node's own expiry
|
||||
catches up, because a socket that went away sends nothing to say so.
|
||||
response:
|
||||
required: false
|
||||
description: >-
|
||||
An answer from the arm node, correlated by `request_id` in the metadata. A
|
||||
failure is passed to the console as a toast; a `get_state` answer fills in
|
||||
what telemetry does not carry.
|
||||
motion_state:
|
||||
required: false
|
||||
description: >-
|
||||
How a motion ended. Needed because this node sends motions with
|
||||
`wait: false`, and a non-blocking motion reports its outcome here and
|
||||
nowhere else.
|
||||
robot_state:
|
||||
required: false
|
||||
description: >-
|
||||
The arm's own state, one of `connecting`, `ready`, `moving`, `recovering`
|
||||
or `unavailable`. Translated into the vocabulary the console draws.
|
||||
type: std/core/v1/String
|
||||
tcp_pose:
|
||||
required: false
|
||||
description: >-
|
||||
Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and
|
||||
degrees, base frame. Shown in the console's pose readout.
|
||||
type: std/core/v1/Float32
|
||||
joint_pose:
|
||||
required: false
|
||||
description: >-
|
||||
Joint angles, six float32 in degrees. Shown in the console's joint readout.
|
||||
type: std/core/v1/Float32
|
||||
|
||||
outputs:
|
||||
jog:
|
||||
description: >-
|
||||
A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}`
|
||||
in mm/s and deg/s. Re-sent every tick while a source is driving, and
|
||||
followed by exactly one all-zero vector when it stops — once, not
|
||||
repeatedly, because a stream of zeroes would keep the arm node believing a
|
||||
jog is live.
|
||||
request:
|
||||
description: >-
|
||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
||||
metadata.
|
||||
ui_state:
|
||||
description: >-
|
||||
The console's telemetry snapshot, as a JSON string for the WebSocket
|
||||
bridge to broadcast. Also carries error toasts. Every key in it is read by
|
||||
name in the console's `app.js`.
|
||||
|
||||
env:
|
||||
SPEED_MM_S:
|
||||
type: float
|
||||
default: 60.0
|
||||
description: >-
|
||||
Translation speed at full stick deflection, in mm/s. A stick is analog, so
|
||||
this is a maximum and anything less moves proportionally slower. Defaults
|
||||
to 60.0.
|
||||
SPEED_DEG_S:
|
||||
type: float
|
||||
default: 30.0
|
||||
description: Rotation speed at full deflection, in deg/s. Defaults to 30.0.
|
||||
DEADZONE:
|
||||
type: float
|
||||
default: 0.12
|
||||
description: >-
|
||||
Stick travel near centre to ignore, as a fraction. A worn stick that does
|
||||
not centre would otherwise drive the arm on its own. What is left is
|
||||
rescaled, so motion still starts from zero just outside the band.
|
||||
Defaults to 0.12.
|
||||
EXPO:
|
||||
type: float
|
||||
default: 0.4
|
||||
description: >-
|
||||
How much of the response curve is cubic, from 0 (linear) to 1. Fine
|
||||
positioning happens near centre and a linear stick gives it almost no
|
||||
resolution there. Defaults to 0.4.
|
||||
JOYSTICK_INDEX:
|
||||
type: int
|
||||
default: 0
|
||||
description: Which pad to open, when more than one is plugged in. Defaults to 0.
|
||||
JOG_TTL_S:
|
||||
type: float
|
||||
default: 0.4
|
||||
description: >-
|
||||
How long the owning source may go quiet before the arm is stopped. Keep it
|
||||
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
|
||||
is the controller-side backstop behind this one. Defaults to 0.4.
|
||||
GAMEPAD_IDLE_RELEASE_S:
|
||||
type: float
|
||||
default: 2.0
|
||||
description: >-
|
||||
How long a pad may sit untouched before it hands the arm back, so a
|
||||
browser can take over without anyone pressing anything. Defaults to 2.0.
|
||||
STATE_POLL_S:
|
||||
type: float
|
||||
default: 0.5
|
||||
description: >-
|
||||
How often to call `get_state` for what telemetry does not carry: whether
|
||||
the tool is engaged and whether freedrive is on. Defaults to 0.5.
|
||||
|
||||
example: |
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
@@ -0,0 +1 @@
|
||||
"""Human control of a robot arm, from a gamepad or a browser."""
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Who is allowed to drive the arm.
|
||||
|
||||
Two sources want one arm, and the arm cannot take both. This is the whole of
|
||||
that decision, in one place, so there is exactly one answer to "why did it not
|
||||
move" rather than one per input path.
|
||||
|
||||
The model comes from the monolithic repo's `RobotService` lease, and is kept
|
||||
because the old web console is built on it: its jog pad is gated on owning the
|
||||
arm and it renders an owner chip. A simpler last-writer-wins scheme would mean
|
||||
rewriting the console to match the node rather than the other way round.
|
||||
|
||||
The arm node itself has no idea any of this exists. A lease held here binds only
|
||||
what goes through this node — another node publishing `request` to the arm will
|
||||
preempt an operator mid-motion, and nothing here can stop it.
|
||||
"""
|
||||
|
||||
from dora_teleop.log import log
|
||||
|
||||
GAMEPAD = "gamepad"
|
||||
WEB = "web"
|
||||
|
||||
|
||||
class Arbiter:
|
||||
"""The lease on the arm, and the rules for handing it between sources."""
|
||||
|
||||
def __init__(self, idle_release_s: float):
|
||||
self.idle_release_s = idle_release_s
|
||||
self._owner: str | None = None
|
||||
self._last_activity = 0.0
|
||||
|
||||
@property
|
||||
def owner(self) -> str | None:
|
||||
"""Who holds the arm, or None if it is free."""
|
||||
return self._owner
|
||||
|
||||
def take(self, who: str, now: float, preempt: bool = False) -> bool:
|
||||
"""Give ``who`` the arm if it is theirs to take, and say whether it worked.
|
||||
|
||||
A free arm goes to whoever asks. A held one stays held unless the caller
|
||||
asked to preempt, which the console's TAKE CONTROL button offers
|
||||
deliberately: an operator who can see the arm is better placed than this
|
||||
code to decide they should have it.
|
||||
"""
|
||||
if self._owner not in (None, who) and not preempt:
|
||||
return False
|
||||
if self._owner != who:
|
||||
log(f"{who} has the arm" + (f", preempting {self._owner}" if self._owner else ""))
|
||||
self._owner = who
|
||||
self._last_activity = now
|
||||
return True
|
||||
|
||||
def release(self, who: str) -> None:
|
||||
"""Give the arm back, if this source had it."""
|
||||
if self._owner == who:
|
||||
log(f"{who} released the arm")
|
||||
self._owner = None
|
||||
|
||||
def note_activity(self, who: str, now: float) -> None:
|
||||
"""Record that the holder is still there, so the idle timer does not fire."""
|
||||
if self._owner == who:
|
||||
self._last_activity = now
|
||||
|
||||
def expire_idle_gamepad(self, now: float) -> None:
|
||||
"""Hand the arm back after the pad has sat untouched long enough.
|
||||
|
||||
Only the gamepad expires. It takes the arm implicitly, on the first
|
||||
nudge of a stick, so it has to give it back the same way or a pad left
|
||||
on the bench would lock the browser out forever. The web takes the arm
|
||||
by pressing a button and gives it back by pressing another.
|
||||
"""
|
||||
if self._owner != GAMEPAD:
|
||||
return
|
||||
if now - self._last_activity >= self.idle_release_s:
|
||||
self.release(GAMEPAD)
|
||||
|
||||
def may_drive(self, who: str) -> bool:
|
||||
"""Whether this source's jog should reach the arm."""
|
||||
return self._owner == who
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Everything the node reads from the environment, parsed once at startup.
|
||||
|
||||
Parsing happens here rather than where each value is used, so an unusable
|
||||
setting stops the node before it joins the dataflow instead of surfacing the
|
||||
first time somebody picks up the gamepad.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def env_float(name: str, default: float) -> float:
|
||||
"""Read a number, naming the variable if it cannot be read as one."""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
"""Read a whole number, naming the variable if it cannot be read as one."""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadmanConfig:
|
||||
"""How long a source may go quiet before the arm is told to stop.
|
||||
|
||||
Three layers, outermost last. This node re-sends the current velocity every
|
||||
tick; `jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||
source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`,
|
||||
which the controller enforces whether or not this process is alive.
|
||||
|
||||
Keep them in that order — ours shorter than the controller's — or the
|
||||
backstop fires before the thing it is backing up.
|
||||
"""
|
||||
|
||||
jog_ttl_s: float = 0.4
|
||||
# How long a gamepad may sit untouched before it gives the arm back, so the
|
||||
# browser can take over without anyone having to press anything.
|
||||
gamepad_idle_release_s: float = 2.0
|
||||
# How often to ask the arm for the things telemetry does not carry: whether
|
||||
# the tool is engaged, whether freedrive is on, whether it is ready.
|
||||
state_poll_s: float = 0.5
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "DeadmanConfig":
|
||||
"""Build the timings from the environment."""
|
||||
return cls(
|
||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
||||
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""The node's settings."""
|
||||
|
||||
# Full-deflection speeds. A stick is analog, so these are the maximum and
|
||||
# anything less moves proportionally slower — which is the whole reason a
|
||||
# gamepad beats the console's dial pad.
|
||||
speed_mm_s: float
|
||||
speed_deg_s: float
|
||||
# Stick travel to ignore. A worn stick that does not centre would otherwise
|
||||
# drive the arm across the table on its own.
|
||||
deadzone: float
|
||||
# How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine
|
||||
# positioning happens near centre, and expo is what buys resolution there.
|
||||
expo: float
|
||||
joystick_index: int
|
||||
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
"""Build the configuration from the environment, refusing anything unusable."""
|
||||
deadzone = env_float("DEADZONE", 0.12)
|
||||
if not 0.0 <= deadzone < 1.0:
|
||||
raise ValueError(f"DEADZONE must be at least 0 and below 1, not {deadzone}")
|
||||
expo = env_float("EXPO", 0.4)
|
||||
if not 0.0 <= expo <= 1.0:
|
||||
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
|
||||
return cls(
|
||||
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
||||
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
||||
deadzone=deadzone,
|
||||
expo=expo,
|
||||
joystick_index=env_int("JOYSTICK_INDEX", 0),
|
||||
deadman=DeadmanConfig.from_env(),
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""The gamepad, and the thread that reads it.
|
||||
|
||||
**Nothing outside this file may touch pygame.** The rest of the node sees only
|
||||
:class:`Sample`, which is a plain immutable reading — that is what lets every
|
||||
other module be tested without a pad plugged in, and what keeps SDL's quirks
|
||||
from leaking into the arm logic.
|
||||
|
||||
The reader is a thread because SDL wants to be pumped at its own cadence and
|
||||
because a pad being unplugged should be noticed within milliseconds rather than
|
||||
on whenever the dataflow next ticks. It never publishes anything; it fills a
|
||||
slot, and the event loop drains it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dora_teleop.log import log
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
"""One reading of the pad.
|
||||
|
||||
``present`` is the one that matters for safety: a pad that has been
|
||||
unplugged reads as absent rather than as centred, so the difference between
|
||||
"the operator let go" and "the operator's pad fell off the desk" is not lost.
|
||||
"""
|
||||
|
||||
axes: list[float] = field(default_factory=list)
|
||||
buttons: list[bool] = field(default_factory=list)
|
||||
present: bool = False
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether anything is being touched at all.
|
||||
|
||||
Deliberately raw — no deadzone. This decides whether the pad is asking
|
||||
for the arm, and :mod:`dora_teleop.mapping` decides how far to move it.
|
||||
A separate, tighter threshold here would mean a stick could claim the
|
||||
arm and then not move it.
|
||||
"""
|
||||
return self.present and (any(abs(a) > 0.3 for a in self.axes) or any(self.buttons))
|
||||
|
||||
|
||||
ABSENT = Sample()
|
||||
|
||||
|
||||
class Gamepad:
|
||||
"""Reads one pad on a thread and keeps the latest reading available."""
|
||||
|
||||
def __init__(self, index: int, poll_interval_s: float = 0.01):
|
||||
self.index = index
|
||||
self.poll_interval_s = poll_interval_s
|
||||
self._lock = threading.Lock()
|
||||
self._sample = ABSENT
|
||||
self._stopping = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="teleop-gamepad", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start reading. Never blocks, and never fails for want of a pad."""
|
||||
self._thread.start()
|
||||
|
||||
def read(self) -> Sample:
|
||||
"""Return the most recent reading."""
|
||||
with self._lock:
|
||||
return self._sample
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop reading and let the thread finish."""
|
||||
self._stopping.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Open the pad, read it, and keep trying if it is not there.
|
||||
|
||||
A missing pad is an ordinary state, not an error: the browser is a whole
|
||||
control path of its own, and the node has no business refusing to start
|
||||
because nobody has plugged a controller in yet.
|
||||
"""
|
||||
# Two subsystems, not all of them. `pygame.init()` also opens audio,
|
||||
# which on a headless robot means a screenful of ALSA errors in the
|
||||
# dataflow's stderr for a device this node never uses. The display is
|
||||
# needed despite there being no window: SDL refreshes joystick state
|
||||
# during the event pump, and the pump needs the video subsystem.
|
||||
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
||||
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||
import pygame
|
||||
|
||||
pygame.display.init()
|
||||
pygame.joystick.init()
|
||||
joystick = None
|
||||
|
||||
while not self._stopping.is_set():
|
||||
pygame.event.pump()
|
||||
if joystick is None:
|
||||
joystick = self._open(pygame)
|
||||
if joystick is None:
|
||||
self._store(ABSENT)
|
||||
self._stopping.wait(1.0)
|
||||
continue
|
||||
try:
|
||||
sample = Sample(
|
||||
axes=[joystick.get_axis(i) for i in range(joystick.get_numaxes())],
|
||||
buttons=[bool(joystick.get_button(i)) for i in range(joystick.get_numbuttons())],
|
||||
present=True,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 — an unplugged pad raises from inside SDL
|
||||
log(f"the gamepad went away ({error})")
|
||||
joystick = None
|
||||
self._store(ABSENT)
|
||||
continue
|
||||
self._store(sample)
|
||||
self._stopping.wait(self.poll_interval_s)
|
||||
|
||||
def _open(self, pygame):
|
||||
"""Open the configured pad, or return None if it is not there."""
|
||||
pygame.joystick.quit()
|
||||
pygame.joystick.init()
|
||||
if pygame.joystick.get_count() <= self.index:
|
||||
return None
|
||||
joystick = pygame.joystick.Joystick(self.index)
|
||||
joystick.init()
|
||||
log(f"gamepad {self.index}: {joystick.get_name()}")
|
||||
return joystick
|
||||
|
||||
def _store(self, sample: Sample) -> None:
|
||||
with self._lock:
|
||||
self._sample = sample
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
|
||||
return time.monotonic()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Timestamped stderr logging.
|
||||
|
||||
stderr rather than stdout: dora reads a dynamic node's stdout, and a print that
|
||||
lands there is noise in the wrong stream.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""Print a timestamped line for the node."""
|
||||
print(f"[teleop {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""The dora event loop: routing, the deadman, and nothing that can block.
|
||||
|
||||
The loop owns the one decision this node exists to make — whether a velocity
|
||||
reaches the arm this tick, and from whom. Everything else is delegated:
|
||||
:mod:`dora_teleop.gamepad` owns the hardware, :mod:`dora_teleop.mapping` turns a
|
||||
reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and
|
||||
:mod:`dora_teleop.ui` speaks the console's language.
|
||||
|
||||
**The safety-critical part is `_drive`.** An arm moving under a velocity command
|
||||
keeps moving until told otherwise, so the question is never "should it start"
|
||||
but "has anything happened that means it must stop". Three things mean that: the
|
||||
source went quiet, the source went away, or this process is going away. All
|
||||
three end in the same place — one all-zero velocity, sent once.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
import pyarrow as pa
|
||||
from dora import Node
|
||||
|
||||
from dora_teleop import mapping, ui
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Gamepad, now
|
||||
from dora_teleop.log import log
|
||||
from dora_teleop.mapping import LAYOUT, pressed
|
||||
|
||||
REQUEST_ID = "request_id"
|
||||
STOP_VELOCITY = [0.0] * 6
|
||||
|
||||
|
||||
class Teleop:
|
||||
"""Everything the loop remembers between ticks."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
||||
self.view = ui.ArmView()
|
||||
# The velocity the browser last asked for, and when. A jog is a stream;
|
||||
# what matters is whether the stream is still arriving.
|
||||
self.web_velocity = STOP_VELOCITY
|
||||
self.web_seen_at = 0.0
|
||||
self.clients = 0
|
||||
# Whether the arm is currently being driven by us. Used to send the stop
|
||||
# once rather than every tick: a stream of zeroes would keep the arm
|
||||
# node believing a jog is live and hold `robot_state` at `moving`.
|
||||
self.jogging = False
|
||||
self.buttons: list[bool] = []
|
||||
self.requests = 0
|
||||
self.polled_at = 0.0
|
||||
|
||||
def next_request_id(self, service: str) -> str:
|
||||
"""Mint a correlation id we will recognise coming back."""
|
||||
self.requests += 1
|
||||
return f"teleop-{service}-{self.requests}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Connect to the dataflow, start reading the pad, and run until dora stops us."""
|
||||
args = _parse_args()
|
||||
config = Config.from_env()
|
||||
|
||||
# Passing the name lets the node also run standalone as a dynamic node; when
|
||||
# dora spawns it, the id from the dataflow wins.
|
||||
node = Node(args.name)
|
||||
state = Teleop(config)
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
|
||||
try:
|
||||
for event in node:
|
||||
if event["type"] == "ERROR":
|
||||
raise RuntimeError(event["error"])
|
||||
if event["type"] != "INPUT":
|
||||
continue
|
||||
_handle(node, state, gamepad, event)
|
||||
finally:
|
||||
# A motion lives in the arm's controller, not in this process. Leaving
|
||||
# without zeroing the velocity leaves it travelling until the
|
||||
# controller's own expiry catches up, which is the one moment nobody is
|
||||
# watching for it.
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
gamepad.shutdown()
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
"""Read the node name, for when dora did not spawn us."""
|
||||
parser = argparse.ArgumentParser(description="Drive a robot arm from a gamepad or a browser.")
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
default="teleop",
|
||||
help="The name of the node in the dataflow, used when running as a dynamic node.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
||||
"""Route one input to whatever deals with it."""
|
||||
event_id = event["id"]
|
||||
if event_id == "tick":
|
||||
_tick(node, state, gamepad)
|
||||
elif event_id == "ui_command":
|
||||
_accept_ui_command(node, state, event)
|
||||
elif event_id == "clients":
|
||||
_accept_clients(state, event)
|
||||
elif event_id == "robot_state":
|
||||
state.view.state = event["value"][0].as_py()
|
||||
elif event_id == "tcp_pose":
|
||||
state.view.pose = event["value"].to_pylist()
|
||||
elif event_id == "joint_pose":
|
||||
state.view.joints = event["value"].to_pylist()
|
||||
elif event_id == "response":
|
||||
_accept_response(node, state, event)
|
||||
elif event_id == "motion_state":
|
||||
_accept_motion_state(node, state, event)
|
||||
else:
|
||||
log(f"ignoring unexpected input `{event_id}`")
|
||||
|
||||
|
||||
# --- the tick, where the arm is actually driven ---
|
||||
|
||||
|
||||
def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None:
|
||||
"""Decide what the arm should be doing, and say so."""
|
||||
moment = now()
|
||||
sample = gamepad.read()
|
||||
|
||||
_read_buttons(node, state, sample)
|
||||
|
||||
# A pad taking the arm the moment a stick moves is what keeps an operator
|
||||
# from having to reach for a browser to use the thing in their hands.
|
||||
if sample.active and state.arbiter.owner is None:
|
||||
state.arbiter.take(GAMEPAD, moment)
|
||||
if sample.active:
|
||||
state.arbiter.note_activity(GAMEPAD, moment)
|
||||
state.arbiter.expire_idle_gamepad(moment)
|
||||
|
||||
_drive(node, state, sample, moment)
|
||||
_poll_state(node, state, moment)
|
||||
_publish(node, state)
|
||||
|
||||
|
||||
def _drive(node: Node, state: Teleop, sample, moment: float) -> None:
|
||||
"""Send the velocity the owner is asking for, or the stop that ends it.
|
||||
|
||||
A velocity is only re-sent while it is non-zero: the arm node treats a
|
||||
stream of jogs as one continuous one, and the re-send exists so the
|
||||
controller's own expiry never fires mid-motion.
|
||||
"""
|
||||
velocity = _wanted_velocity(state, sample, moment)
|
||||
|
||||
if mapping.is_moving(velocity):
|
||||
state.jogging = True
|
||||
_send_jog(node, velocity)
|
||||
return
|
||||
|
||||
if state.jogging:
|
||||
# Once, not every tick. Repeating it would keep the arm node believing a
|
||||
# jog is live and hold `robot_state` at `moving` forever.
|
||||
state.jogging = False
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
|
||||
|
||||
def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]:
|
||||
"""Return what the arm should be doing, which is usually nothing.
|
||||
|
||||
Every path that is not an owner actively asking for motion returns a stop.
|
||||
That is deliberate: the default has to be "still", so that a case nobody
|
||||
thought of fails by halting rather than by carrying on.
|
||||
"""
|
||||
owner = state.arbiter.owner
|
||||
if owner == GAMEPAD:
|
||||
# An unplugged pad is not a centred pad. Stopping here rather than
|
||||
# waiting out the TTL is the difference between a dropped controller
|
||||
# costing 10 ms of travel and costing 400.
|
||||
if not sample.present:
|
||||
return STOP_VELOCITY
|
||||
return mapping.velocity(sample.axes, sample.buttons, state.config)
|
||||
|
||||
if owner == WEB:
|
||||
# The console refreshes its jog every 150 ms; the browser going quiet,
|
||||
# or the tab closing, has to stop the arm without anything being sent.
|
||||
if state.clients == 0:
|
||||
return STOP_VELOCITY
|
||||
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
||||
return STOP_VELOCITY
|
||||
return state.web_velocity
|
||||
|
||||
return STOP_VELOCITY
|
||||
|
||||
|
||||
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||
"""Fire the services a gamepad button asks for, on the press only.
|
||||
|
||||
Edge-triggered, because these are one-shot requests rather than a held
|
||||
velocity: holding `grasp` should not send a request every 50 ms.
|
||||
"""
|
||||
previous, state.buttons = state.buttons, list(sample.buttons)
|
||||
if not sample.present:
|
||||
return
|
||||
|
||||
def tapped(index: int) -> bool:
|
||||
return pressed(sample.buttons, index) and not pressed(previous, index)
|
||||
|
||||
# The stop is not gated on the lease. Somebody reaching for it is the one
|
||||
# moment when asking who owns the arm is the wrong question.
|
||||
if tapped(LAYOUT.estop):
|
||||
_estop(node, state)
|
||||
if not state.arbiter.may_drive(GAMEPAD):
|
||||
return
|
||||
if tapped(LAYOUT.grasp):
|
||||
_request(node, state, {"service": "grasp"})
|
||||
if tapped(LAYOUT.release):
|
||||
_request(node, state, {"service": "release"})
|
||||
if tapped(LAYOUT.clear):
|
||||
_request(node, state, {"service": "clear"})
|
||||
if tapped(LAYOUT.freedrive):
|
||||
_request(node, state, {"service": "freedrive", "enabled": not state.view.freedrive_on})
|
||||
|
||||
|
||||
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
||||
"""Ask the arm for what telemetry does not carry.
|
||||
|
||||
Whether the tool is engaged and whether freedrive is on only come back from
|
||||
`get_state`, which the arm node answers off its report cache and so can
|
||||
answer during a motion.
|
||||
"""
|
||||
if moment - state.polled_at < state.config.deadman.state_poll_s:
|
||||
return
|
||||
state.polled_at = moment
|
||||
_request(node, state, {"service": "get_state"})
|
||||
|
||||
|
||||
# --- what the console asks for ---
|
||||
|
||||
|
||||
def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
||||
"""Take one console message and do what it says."""
|
||||
raw = event["value"][0].as_py() if len(event["value"]) else None
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except json.JSONDecodeError:
|
||||
log("ignoring a console message that was not JSON")
|
||||
return
|
||||
|
||||
command = ui.command_of(payload)
|
||||
if command is None:
|
||||
return
|
||||
moment = now()
|
||||
|
||||
if command.type == "estop":
|
||||
_estop(node, state)
|
||||
return
|
||||
if command.type == "control":
|
||||
_control(node, state, command, moment)
|
||||
return
|
||||
if command.type == "reset":
|
||||
# Clearing the latch is this node's business; clearing the controller's
|
||||
# error is the arm's. Both, in that order, or the console's RESET button
|
||||
# leaves one of the two still saying stop.
|
||||
state.view.estop = False
|
||||
_request(node, state, {"service": "clear"})
|
||||
return
|
||||
|
||||
if not state.arbiter.may_drive(WEB):
|
||||
_post_ui(node, ui.error("take control first"))
|
||||
return
|
||||
|
||||
if command.type == "jog":
|
||||
state.web_velocity = command.velocity
|
||||
state.web_seen_at = moment
|
||||
state.arbiter.note_activity(WEB, moment)
|
||||
elif command.type == "jog_stop":
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_seen_at = moment
|
||||
elif command.type == "home":
|
||||
_request(node, state, {"service": "home", "wait": False})
|
||||
elif command.type == "gripper":
|
||||
_request(node, state, {"service": "grasp" if command.enabled else "release"})
|
||||
elif command.type == "freedrive":
|
||||
_request(node, state, {"service": "freedrive", "enabled": command.enabled})
|
||||
elif command.type == "move":
|
||||
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
||||
|
||||
|
||||
def _control(node: Node, state: Teleop, command, moment: float) -> None:
|
||||
"""Hand the arm to the console, or take it back."""
|
||||
if command.action == "release":
|
||||
state.arbiter.release(WEB)
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
return
|
||||
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
||||
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
||||
|
||||
|
||||
def _accept_clients(state: Teleop, event: dict) -> None:
|
||||
"""Track who is watching, because the last one leaving has to stop the arm."""
|
||||
row = event["value"][0].as_py()
|
||||
state.clients = int(row.get("connected", 0))
|
||||
if state.clients == 0:
|
||||
state.arbiter.release(WEB)
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
|
||||
|
||||
# --- what the arm says back ---
|
||||
|
||||
|
||||
def _accept_response(node: Node, state: Teleop, event: dict) -> None:
|
||||
"""Absorb one answer, and tell the console about it if it was bad news."""
|
||||
row = event["value"][0].as_py()
|
||||
if row.get("service") == "get_state":
|
||||
state.view.take_get_state(row)
|
||||
return
|
||||
if not row.get("ok", True):
|
||||
message = row.get("message", "")
|
||||
log(f"{row.get('service')} failed: {message}")
|
||||
_post_ui(node, ui.error(message))
|
||||
|
||||
|
||||
def _accept_motion_state(node: Node, state: Teleop, event: dict) -> None:
|
||||
"""Report a motion that failed, which a non-blocking request learns no other way."""
|
||||
row = event["value"][0].as_py()
|
||||
if row.get("state") == "failed":
|
||||
_post_ui(node, ui.error(row.get("message", "the motion failed")))
|
||||
|
||||
|
||||
# --- talking to the dataflow ---
|
||||
|
||||
|
||||
def _estop(node: Node, state: Teleop) -> None:
|
||||
"""Halt the arm, from any source, held lease or not."""
|
||||
log("e-stop")
|
||||
state.view.estop = True
|
||||
state.jogging = False
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
_send_jog(node, STOP_VELOCITY)
|
||||
_request(node, state, {"service": "stop"})
|
||||
|
||||
|
||||
def _send_jog(node: Node, velocity: list[float]) -> None:
|
||||
"""Send one velocity to the arm. There is no answer to a jog, ever."""
|
||||
node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}]))
|
||||
|
||||
|
||||
def _request(node: Node, state: Teleop, row: dict) -> None:
|
||||
"""Call one service on the arm under an id we will recognise coming back."""
|
||||
node.send_output("request", pa.array([row]), metadata={REQUEST_ID: state.next_request_id(row["service"])})
|
||||
|
||||
|
||||
def _publish(node: Node, state: Teleop) -> None:
|
||||
"""Send the console its picture of the arm."""
|
||||
_post_ui(node, ui.snapshot(state.view, state.arbiter.owner, state.jogging, time.time()))
|
||||
|
||||
|
||||
def _post_ui(node: Node, payload: dict) -> None:
|
||||
"""Hand one object to the bridge, which broadcasts it to every browser."""
|
||||
node.send_output("ui_state", pa.array([json.dumps(payload)]), metadata={"encoding": "json"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Turning stick deflection into a velocity the arm understands.
|
||||
|
||||
Pure functions, no state and no hardware, because this is the part that has to
|
||||
be right and the part that is cheapest to check. Everything here is in the
|
||||
arm node's units: `[vx, vy, vz, wx, wy, wz]`, mm/s and deg/s, in the robot's
|
||||
**base** frame — the same frame `tcp_pose` reports.
|
||||
|
||||
The screen convention comes from the old web console, so the two agree: stick
|
||||
up is +X, stick right is +Y.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The arm takes six numbers; these name them.
|
||||
VX, VY, VZ, WX, WY, WZ = range(6)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
"""Which axis and button index does what, in SDL's numbering.
|
||||
|
||||
The defaults are the layout SDL reports for an Xbox-style pad, which is also
|
||||
what it reports for most PlayStation-style ones. A pad that disagrees is a
|
||||
change here rather than a new environment variable: an index nobody can
|
||||
verify from the outside is worse than a line of code somebody can read.
|
||||
"""
|
||||
|
||||
left_x: int = 0
|
||||
left_y: int = 1
|
||||
left_trigger: int = 2
|
||||
right_x: int = 3
|
||||
right_y: int = 4
|
||||
right_trigger: int = 5
|
||||
|
||||
yaw_left: int = 4 # LB
|
||||
yaw_right: int = 5 # RB
|
||||
grasp: int = 0 # A
|
||||
release: int = 1 # B
|
||||
clear: int = 2 # X
|
||||
freedrive: int = 3 # Y
|
||||
estop: int = 6 # Back
|
||||
|
||||
|
||||
LAYOUT = Layout()
|
||||
|
||||
|
||||
def deadzone(value: float, width: float) -> float:
|
||||
"""Ignore travel near centre, and rescale what is left to the full range.
|
||||
|
||||
Rescaling matters: without it the arm jumps to `width` of full speed the
|
||||
instant the stick leaves the dead band, which feels like a broken control
|
||||
rather than a gentle one.
|
||||
"""
|
||||
if width <= 0.0:
|
||||
return value
|
||||
size = abs(value)
|
||||
if size < width:
|
||||
return 0.0
|
||||
scaled = (size - width) / (1.0 - width)
|
||||
return scaled if value > 0 else -scaled
|
||||
|
||||
|
||||
def expo(value: float, factor: float) -> float:
|
||||
"""Bend the response so small deflections move the arm slowly.
|
||||
|
||||
Fine positioning happens near centre, and a linear stick gives it almost no
|
||||
resolution there. `factor` is how much of the curve is cubic: 0 is linear.
|
||||
"""
|
||||
return (1.0 - factor) * value + factor * value**3
|
||||
|
||||
|
||||
def trigger(value: float) -> float:
|
||||
"""Return a trigger's travel as 0 at rest and 1 fully pressed.
|
||||
|
||||
SDL reports a trigger from -1 to 1, unlike a stick, and a trigger that has
|
||||
never been touched sometimes reads 0 instead of -1 until it first moves.
|
||||
Clamping keeps that from reading as half pressed.
|
||||
"""
|
||||
return max(0.0, min(1.0, (value + 1.0) / 2.0))
|
||||
|
||||
|
||||
def axis(values: list[float], index: int) -> float:
|
||||
"""Read one axis, treating a pad that does not report it as centred."""
|
||||
return values[index] if index < len(values) else 0.0
|
||||
|
||||
|
||||
def pressed(buttons: list[bool], index: int) -> bool:
|
||||
"""Read one button, treating a pad that does not report it as unpressed."""
|
||||
return bool(buttons[index]) if index < len(buttons) else False
|
||||
|
||||
|
||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading to a base-frame velocity.
|
||||
|
||||
Translation comes from the left stick and the triggers, rotation from the
|
||||
right stick and the bumpers. Several axes move at once on purpose: a stick
|
||||
is analog and blended, which is exactly what the console's four-sector dial
|
||||
pad could not do.
|
||||
"""
|
||||
|
||||
def shaped(value: float) -> float:
|
||||
return expo(deadzone(value, config.deadzone), config.expo)
|
||||
|
||||
out = [0.0] * 6
|
||||
# Up on the stick is negative in SDL, and up should send the arm away from
|
||||
# the operator, along +X.
|
||||
out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s
|
||||
out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s
|
||||
|
||||
lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger))
|
||||
out[VZ] = shaped(lift) * config.speed_mm_s
|
||||
|
||||
out[WX] = shaped(axis(axes, layout.right_x)) * config.speed_deg_s
|
||||
out[WY] = shaped(-axis(axes, layout.right_y)) * config.speed_deg_s
|
||||
|
||||
yaw = pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left)
|
||||
out[WZ] = yaw * config.speed_deg_s
|
||||
return out
|
||||
|
||||
|
||||
def is_moving(velocity_vector: list[float]) -> bool:
|
||||
"""Whether this velocity would actually drive the arm."""
|
||||
return any(velocity_vector)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Translation between the web console and the dataflow.
|
||||
|
||||
The console was written against the monolithic repo's REST API and reads a
|
||||
telemetry snapshot by key. Those keys are the contract here: `app.js` does
|
||||
`t.owner === "web"` and `t.estop` directly, so renaming one breaks the page
|
||||
silently. They are reproduced rather than improved for that reason.
|
||||
|
||||
The two vocabularies do not line up, which is most of what this file is for. The
|
||||
arm node reports `connecting|ready|moving|recovering|unavailable`; the console
|
||||
draws `idle|moving|jogging|freedrive|estop|stopped|disconnected`. Neither is
|
||||
wrong — one describes a driver's lifecycle, the other an operator's screen — and
|
||||
:func:`arm_state` is where the two are reconciled.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# What the arm node publishes on `robot_state`.
|
||||
CONNECTING = "connecting"
|
||||
READY = "ready"
|
||||
MOVING = "moving"
|
||||
RECOVERING = "recovering"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
# What the console draws.
|
||||
UI_IDLE = "idle"
|
||||
UI_MOVING = "moving"
|
||||
UI_JOGGING = "jogging"
|
||||
UI_FREEDRIVE = "freedrive"
|
||||
UI_ESTOP = "estop"
|
||||
UI_STOPPED = "stopped"
|
||||
UI_DISCONNECTED = "disconnected"
|
||||
|
||||
# An arm the node cannot speak for is `disconnected` whatever the reason, because
|
||||
# that is the only thing an operator can act on. `recovering` is its own case:
|
||||
# the arm is there and answering, it just will not move yet.
|
||||
ARM_STATE = {
|
||||
CONNECTING: UI_DISCONNECTED,
|
||||
UNAVAILABLE: UI_DISCONNECTED,
|
||||
RECOVERING: UI_STOPPED,
|
||||
READY: UI_IDLE,
|
||||
MOVING: UI_MOVING,
|
||||
}
|
||||
|
||||
POSE_KEYS = ("x", "y", "z", "roll", "pitch", "yaw")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
"""One thing the console asked for."""
|
||||
|
||||
type: str
|
||||
velocity: list[float] = field(default_factory=list)
|
||||
action: str = ""
|
||||
preempt: bool = False
|
||||
enabled: bool = False
|
||||
row: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def command_of(payload: dict) -> Command | None:
|
||||
"""Read one console message, or None if it is not one we serve.
|
||||
|
||||
A message we do not recognise is dropped rather than refused. The console
|
||||
and this node version independently, and a page from a newer build saying
|
||||
something we have not learned yet must not take the arm down.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
kind = payload.get("type")
|
||||
if not isinstance(kind, str):
|
||||
return None
|
||||
|
||||
if kind == "jog":
|
||||
velocity = payload.get("velocity") or []
|
||||
if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity):
|
||||
return None
|
||||
return Command(type="jog", velocity=[float(v) for v in velocity])
|
||||
if kind in ("jog_stop", "estop", "reset", "home"):
|
||||
return Command(type=kind)
|
||||
if kind == "control":
|
||||
return Command(
|
||||
type="control",
|
||||
action=str(payload.get("action", "")),
|
||||
preempt=bool(payload.get("preempt", False)),
|
||||
)
|
||||
if kind == "gripper":
|
||||
return Command(type="gripper", enabled=bool(payload.get("vacuum", False)))
|
||||
if kind == "freedrive":
|
||||
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
||||
if kind == "move":
|
||||
row = {key: payload[key] for key in POSE_KEYS if isinstance(payload.get(key), (int, float))}
|
||||
if not all(key in row for key in ("x", "y", "z")):
|
||||
return None
|
||||
if isinstance(payload.get("speed"), (int, float)):
|
||||
row["speed"] = float(payload["speed"])
|
||||
return Command(type="move", row=row)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArmView:
|
||||
"""What this node currently believes about the arm.
|
||||
|
||||
Assembled from four places — `robot_state`, `tcp_pose`, `joint_pose` and the
|
||||
answers to `get_state` — because no single one of them carries everything
|
||||
the console draws. Whatever has not arrived yet stays at its default, so a
|
||||
page that loads before the arm is reachable renders as disconnected rather
|
||||
than as a stale pose.
|
||||
"""
|
||||
|
||||
state: str = CONNECTING
|
||||
pose: list[float] = field(default_factory=list)
|
||||
joints: list[float] = field(default_factory=list)
|
||||
connected: bool = False
|
||||
ready: bool = False
|
||||
tool_engaged: bool = False
|
||||
freedrive_on: bool = False
|
||||
estop: bool = False
|
||||
|
||||
def take_get_state(self, row: dict) -> None:
|
||||
"""Absorb a `get_state` answer, which carries what telemetry does not."""
|
||||
self.connected = bool(row.get("connected", False))
|
||||
self.ready = bool(row.get("ready", False))
|
||||
self.tool_engaged = bool(row.get("tool_engaged", False))
|
||||
self.freedrive_on = bool(row.get("freedrive", False))
|
||||
|
||||
|
||||
def arm_state(view: ArmView, jogging: bool) -> str:
|
||||
"""Return the state the console should draw.
|
||||
|
||||
Order matters, and it is the order of what an operator most needs to know.
|
||||
A latched e-stop wins over everything: the arm may well read `ready`, and
|
||||
saying so would invite somebody to push it. Freedrive outranks idle because
|
||||
an arm that can be pushed by hand is not the same as one holding station.
|
||||
"""
|
||||
if view.estop:
|
||||
return UI_ESTOP
|
||||
if view.state in (CONNECTING, UNAVAILABLE):
|
||||
return ARM_STATE[view.state]
|
||||
if view.freedrive_on:
|
||||
return UI_FREEDRIVE
|
||||
if jogging and view.state == MOVING:
|
||||
return UI_JOGGING
|
||||
return ARM_STATE.get(view.state, UI_DISCONNECTED)
|
||||
|
||||
|
||||
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
|
||||
"""Build the telemetry object the console reads.
|
||||
|
||||
Every key here is read by name in `app.js`. `vacuum` is the console's name
|
||||
for the mounted tool whatever it actually is, kept because renaming it would
|
||||
mean editing the page for no gain.
|
||||
"""
|
||||
return {
|
||||
"type": "telemetry",
|
||||
"ts": timestamp,
|
||||
"connected": view.connected,
|
||||
"ready": view.ready,
|
||||
"arm_state": arm_state(view, jogging),
|
||||
"owner": owner or "none",
|
||||
"estop": view.estop,
|
||||
"vacuum": view.tool_engaged,
|
||||
"freedrive": view.freedrive_on,
|
||||
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
|
||||
"joints": list(view.joints),
|
||||
}
|
||||
|
||||
|
||||
def error(message: str) -> dict:
|
||||
"""Build the message the console shows as a toast."""
|
||||
return {"type": "error", "detail": message}
|
||||
@@ -0,0 +1,48 @@
|
||||
[project]
|
||||
name = "dora-teleop"
|
||||
version = "0.1.0"
|
||||
description = "Dora node giving a human the arm, from a gamepad or a browser"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Liria" }]
|
||||
|
||||
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"dora-rs >= 1.0.1",
|
||||
"pyarrow >= 17.0",
|
||||
# pygame-ce rather than evdev: Littlehand targets Linux and macOS, evdev is
|
||||
# Linux-only, and pygame-ce ships prebuilt wheels for linux-x86_64,
|
||||
# linux-aarch64 and macos-arm64 — so no dependency here compiles from source.
|
||||
"pygame-ce >= 2.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest >= 8.1", "ruff >= 0.9"]
|
||||
|
||||
[project.scripts]
|
||||
dora-teleop = "dora_teleop.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["dora_teleop"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 110
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["D", "I", "N", "RET", "UP"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "numpy"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Test names are the documentation here; a docstring would only repeat them.
|
||||
"tests/*" = ["D1"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Shared fakes. Nothing here needs a gamepad, an arm, a browser or a daemon."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Sample
|
||||
|
||||
|
||||
class RecordingNode:
|
||||
"""A dora node that records outputs *with* their metadata.
|
||||
|
||||
`dora.testing.MockNode` drops the metadata dict on `send_output`, and the
|
||||
metadata is where `request_id` rides. So the tests record it themselves.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_output(self, output_id, data, metadata=None):
|
||||
self.sent.append((output_id, data, dict(metadata or {})))
|
||||
|
||||
def outputs(self, output_id):
|
||||
"""Return every message sent on ``output_id`` as (value, metadata) pairs."""
|
||||
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
|
||||
|
||||
def jogs(self):
|
||||
"""Return every velocity sent to the arm, in order."""
|
||||
return [row["velocity"] for row, _ in self.outputs("jog")]
|
||||
|
||||
def services(self):
|
||||
"""Return the name of every service called, in order."""
|
||||
return [row["service"] for row, _ in self.outputs("request")]
|
||||
|
||||
def ui(self):
|
||||
"""Return every object handed to the bridge, parsed."""
|
||||
return [json.loads(value) for value, _ in self.outputs("ui_state")]
|
||||
|
||||
def clear(self):
|
||||
self.sent.clear()
|
||||
|
||||
|
||||
class FakeGamepad:
|
||||
"""Stands in for the reader thread: hands back whatever the test set."""
|
||||
|
||||
def __init__(self, sample=None):
|
||||
self.sample = sample or Sample()
|
||||
self.stopped = False
|
||||
|
||||
def read(self):
|
||||
return self.sample
|
||||
|
||||
def shutdown(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def stick(axes=None, buttons=None, present=True):
|
||||
"""Build one gamepad reading, padded to a full six axes."""
|
||||
values = list(axes or [])
|
||||
values += [0.0] * (6 - len(values))
|
||||
# A trigger at rest reads -1, and a test that forgot to say so would look
|
||||
# like both triggers held down.
|
||||
for index in (2, 5):
|
||||
if axes is None or index >= len(axes):
|
||||
values[index] = -1.0
|
||||
return Sample(axes=values, buttons=list(buttons or []), present=present)
|
||||
|
||||
|
||||
def input_event(event_id, value, metadata=None):
|
||||
"""Build the event dict dora hands a node for one input."""
|
||||
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
"""Run every test against a bare environment, whatever the shell set."""
|
||||
for name in (
|
||||
"SPEED_MM_S",
|
||||
"SPEED_DEG_S",
|
||||
"DEADZONE",
|
||||
"EXPO",
|
||||
"JOYSTICK_INDEX",
|
||||
"JOG_TTL_S",
|
||||
"GAMEPAD_IDLE_RELEASE_S",
|
||||
"STATE_POLL_S",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
"""Return the default configuration, with expo off so a test can do the arithmetic."""
|
||||
built = Config.from_env()
|
||||
built.expo = 0.0
|
||||
return built
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The lease: who may drive the arm, and how it changes hands."""
|
||||
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
|
||||
|
||||
|
||||
def build(idle_release_s=2.0):
|
||||
return Arbiter(idle_release_s)
|
||||
|
||||
|
||||
def test_a_free_arm_goes_to_whoever_asks():
|
||||
arbiter = build()
|
||||
|
||||
assert arbiter.take(WEB, now := 100.0) is True
|
||||
assert arbiter.owner == WEB
|
||||
assert arbiter.may_drive(WEB)
|
||||
assert now == 100.0
|
||||
|
||||
|
||||
def test_a_held_arm_is_not_handed_over_for_the_asking():
|
||||
arbiter = build()
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
assert arbiter.take(WEB, 101.0) is False
|
||||
assert arbiter.owner == GAMEPAD
|
||||
|
||||
|
||||
def test_a_non_owner_may_not_drive():
|
||||
arbiter = build()
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
assert arbiter.may_drive(WEB) is False
|
||||
|
||||
|
||||
def test_preempting_takes_a_held_arm():
|
||||
# The console offers this deliberately: an operator who can see the arm is
|
||||
# better placed than this code to decide they should have it.
|
||||
arbiter = build()
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
assert arbiter.take(WEB, 101.0, preempt=True) is True
|
||||
assert arbiter.owner == WEB
|
||||
|
||||
|
||||
def test_taking_an_arm_you_already_hold_is_not_a_conflict():
|
||||
arbiter = build()
|
||||
arbiter.take(WEB, 100.0)
|
||||
|
||||
assert arbiter.take(WEB, 101.0) is True
|
||||
|
||||
|
||||
def test_releasing_frees_the_arm():
|
||||
arbiter = build()
|
||||
arbiter.take(WEB, 100.0)
|
||||
|
||||
arbiter.release(WEB)
|
||||
|
||||
assert arbiter.owner is None
|
||||
|
||||
|
||||
def test_releasing_an_arm_somebody_else_holds_does_nothing():
|
||||
arbiter = build()
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
arbiter.release(WEB)
|
||||
|
||||
assert arbiter.owner == GAMEPAD
|
||||
|
||||
|
||||
def test_an_idle_gamepad_hands_the_arm_back():
|
||||
# A pad left on the bench would otherwise lock the browser out forever.
|
||||
arbiter = build(idle_release_s=2.0)
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
arbiter.expire_idle_gamepad(102.0)
|
||||
|
||||
assert arbiter.owner is None
|
||||
|
||||
|
||||
def test_a_gamepad_still_being_used_keeps_the_arm():
|
||||
arbiter = build(idle_release_s=2.0)
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
arbiter.note_activity(GAMEPAD, 101.5)
|
||||
arbiter.expire_idle_gamepad(102.0)
|
||||
|
||||
assert arbiter.owner == GAMEPAD
|
||||
|
||||
|
||||
def test_the_web_never_expires_on_its_own():
|
||||
# It took the arm by pressing a button and gives it back by pressing another.
|
||||
arbiter = build(idle_release_s=2.0)
|
||||
arbiter.take(WEB, 100.0)
|
||||
|
||||
arbiter.expire_idle_gamepad(999.0)
|
||||
|
||||
assert arbiter.owner == WEB
|
||||
|
||||
|
||||
def test_activity_from_a_non_owner_does_not_extend_anyone_else():
|
||||
arbiter = build(idle_release_s=2.0)
|
||||
arbiter.take(GAMEPAD, 100.0)
|
||||
|
||||
arbiter.note_activity(WEB, 101.9)
|
||||
arbiter.expire_idle_gamepad(102.0)
|
||||
|
||||
assert arbiter.owner is None
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Settings: parsed once at startup, and refused there rather than mid-motion."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dora_teleop.config import Config
|
||||
|
||||
|
||||
def test_the_defaults_need_no_environment():
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.speed_mm_s == 60.0
|
||||
assert config.speed_deg_s == 30.0
|
||||
assert config.joystick_index == 0
|
||||
|
||||
|
||||
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
||||
# The arm node's VELOCITY_COMMAND_TTL_S defaults to 0.5 s and is the
|
||||
# backstop. Ours has to fire first, or the backstop backs up nothing.
|
||||
assert Config.from_env().deadman.jog_ttl_s < 0.5
|
||||
|
||||
|
||||
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
||||
monkeypatch.setenv("SPEED_MM_S", " ")
|
||||
|
||||
assert Config.from_env().speed_mm_s == 60.0
|
||||
|
||||
|
||||
def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
|
||||
monkeypatch.setenv("SPEED_MM_S", "brisk")
|
||||
|
||||
with pytest.raises(ValueError, match="SPEED_MM_S"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch):
|
||||
monkeypatch.setenv("JOYSTICK_INDEX", "1.5")
|
||||
|
||||
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1.0", "-0.1", "2"])
|
||||
def test_a_deadzone_outside_the_stick_is_refused_at_startup(monkeypatch, value):
|
||||
# A deadzone of 1 would silently make the pad inert; catching it here beats
|
||||
# finding out when somebody picks it up.
|
||||
monkeypatch.setenv("DEADZONE", value)
|
||||
|
||||
with pytest.raises(ValueError, match="DEADZONE"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["-0.1", "1.1"])
|
||||
def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value):
|
||||
monkeypatch.setenv("EXPO", value)
|
||||
|
||||
with pytest.raises(ValueError, match="EXPO"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
||||
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
||||
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.deadman.jog_ttl_s == 0.25
|
||||
assert config.deadman.gamepad_idle_release_s == 5.0
|
||||
@@ -0,0 +1,215 @@
|
||||
"""The deadman: every way a source can go away, and the stop that follows it."""
|
||||
|
||||
import json
|
||||
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from dora_teleop import main as loop
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Sample
|
||||
from tests.conftest import FakeGamepad, RecordingNode, input_event, stick
|
||||
|
||||
STOP = [0.0] * 6
|
||||
PUSHED = [0.0, -1.0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
"""Return a clock the test moves by hand, so no test waits out a real timeout."""
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def advance(self, seconds):
|
||||
self.t += seconds
|
||||
|
||||
moved = Clock()
|
||||
monkeypatch.setattr("dora_teleop.main.now", lambda: moved.t)
|
||||
return moved
|
||||
|
||||
|
||||
def build():
|
||||
config = Config.from_env()
|
||||
config.expo = 0.0
|
||||
return RecordingNode(), loop.Teleop(config), FakeGamepad()
|
||||
|
||||
|
||||
def jog_from(node, state, gamepad, count=1):
|
||||
"""Run `count` ticks and return every velocity that reached the arm."""
|
||||
for _ in range(count):
|
||||
loop._tick(node, state, gamepad)
|
||||
return node.jogs()
|
||||
|
||||
|
||||
def web_jog(node, state, velocity):
|
||||
"""Deliver one console jog message."""
|
||||
payload = json.dumps({"type": "jog", "velocity": velocity})
|
||||
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
||||
|
||||
|
||||
# --- the gamepad ---
|
||||
|
||||
|
||||
def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
||||
# Nobody should have to reach for a browser to use the pad in their hands.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
|
||||
assert state.arbiter.owner == GAMEPAD
|
||||
assert jogs[-1][0] > 0
|
||||
|
||||
|
||||
def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock):
|
||||
# An unplugged pad is not a centred pad, and waiting out the TTL here is the
|
||||
# difference between a dropped controller costing 10 ms of travel and 400.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = Sample(present=False)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
|
||||
# A stream of zeroes would keep the arm node believing a jog is live and
|
||||
# hold robot_state at `moving` forever.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
jog_from(node, state, gamepad)
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad, count=5)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_held_stick_is_resent_every_tick(clock):
|
||||
# The arm's controller expires a velocity on its own; the re-send is what
|
||||
# keeps that backstop from firing mid-motion.
|
||||
node, state, gamepad = build()
|
||||
gamepad.sample = stick(axes=PUSHED)
|
||||
|
||||
jogs = jog_from(node, state, gamepad, count=4)
|
||||
|
||||
assert len(jogs) == 4
|
||||
assert all(jog[0] > 0 for jog in jogs)
|
||||
|
||||
|
||||
# --- the browser ---
|
||||
|
||||
|
||||
def test_the_last_browser_leaving_stops_the_arm(clock):
|
||||
# Closing the tab has to stop the arm, and nothing is sent when it does.
|
||||
node, state, gamepad = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
jog_from(node, state, gamepad)
|
||||
node.clear()
|
||||
|
||||
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
|
||||
assert jogs == [STOP]
|
||||
assert state.arbiter.owner is None
|
||||
|
||||
|
||||
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
|
||||
# The console refreshes every 150 ms against a 400 ms deadline.
|
||||
node, state, gamepad = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
jog_from(node, state, gamepad)
|
||||
node.clear()
|
||||
|
||||
clock.advance(0.5)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
||||
node, state, gamepad = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
|
||||
sent = []
|
||||
for _ in range(4):
|
||||
clock.advance(0.15)
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state, gamepad)
|
||||
sent = node.jogs()
|
||||
|
||||
assert all(jog[0] == pytest.approx(10.0) for jog in sent)
|
||||
|
||||
|
||||
def test_a_non_owner_jog_is_ignored(clock):
|
||||
node, state, gamepad = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(GAMEPAD, clock.t)
|
||||
|
||||
web_jog(node, state, [10.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state, gamepad)
|
||||
|
||||
assert node.jogs() == []
|
||||
assert any(message.get("type") == "error" for message in node.ui())
|
||||
|
||||
|
||||
# --- the stop that is never gated ---
|
||||
|
||||
|
||||
def test_estop_is_never_gated_by_the_lease(clock):
|
||||
# Somebody reaching for the stop is the one moment when asking who owns the
|
||||
# arm is the wrong question.
|
||||
node, state, _ = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(GAMEPAD, clock.t)
|
||||
|
||||
payload = json.dumps({"type": "estop"})
|
||||
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
||||
|
||||
assert node.jogs() == [STOP]
|
||||
assert "stop" in node.services()
|
||||
|
||||
|
||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
||||
node, state, gamepad = build()
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
gamepad.sample = stick(buttons=[False] * 6 + [True])
|
||||
|
||||
loop._tick(node, state, gamepad)
|
||||
|
||||
assert "stop" in node.services()
|
||||
|
||||
|
||||
def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||
node, state, _ = build()
|
||||
state.view.estop = True
|
||||
|
||||
payload = json.dumps({"type": "reset"})
|
||||
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
|
||||
|
||||
assert state.view.estop is False
|
||||
assert "clear" in node.services()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Stick deflection to base-frame velocity: units, signs and the shaping curve."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dora_teleop import mapping
|
||||
from dora_teleop.mapping import VX, VY, VZ, WX, WY, WZ
|
||||
from tests.conftest import stick
|
||||
|
||||
|
||||
def test_a_stick_at_rest_asks_for_nothing(config):
|
||||
velocity = mapping.velocity(stick().axes, [], config)
|
||||
|
||||
assert velocity == [0.0] * 6
|
||||
assert not mapping.is_moving(velocity)
|
||||
|
||||
|
||||
def test_pushing_the_stick_up_moves_the_arm_along_positive_x(config):
|
||||
# Up is negative in SDL, and up should send the arm away from the operator.
|
||||
velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config)
|
||||
|
||||
assert velocity[VX] == pytest.approx(config.speed_mm_s)
|
||||
assert velocity[VY] == 0.0
|
||||
|
||||
|
||||
def test_pushing_the_stick_right_moves_the_arm_along_positive_y(config):
|
||||
velocity = mapping.velocity(stick(axes=[1.0, 0.0]).axes, [], config)
|
||||
|
||||
assert velocity[VY] == pytest.approx(config.speed_mm_s)
|
||||
|
||||
|
||||
def test_the_right_trigger_lifts_and_the_left_one_lowers(config):
|
||||
up = mapping.velocity(stick(axes=[0, 0, -1.0, 0, 0, 1.0]).axes, [], config)
|
||||
down = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, -1.0]).axes, [], config)
|
||||
|
||||
assert up[VZ] == pytest.approx(config.speed_mm_s)
|
||||
assert down[VZ] == pytest.approx(-config.speed_mm_s)
|
||||
|
||||
|
||||
def test_both_triggers_together_cancel(config):
|
||||
velocity = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, 1.0]).axes, [], config)
|
||||
|
||||
assert velocity[VZ] == 0.0
|
||||
|
||||
|
||||
def test_an_untouched_trigger_does_not_read_as_half_pressed(config):
|
||||
# SDL reports a trigger from -1 to 1, and one that has never moved sometimes
|
||||
# reads 0 until it does. Treating that as half travel would lift the arm.
|
||||
assert mapping.trigger(-1.0) == 0.0
|
||||
assert mapping.trigger(0.0) == pytest.approx(0.5)
|
||||
assert mapping.trigger(1.0) == 1.0
|
||||
|
||||
|
||||
def test_the_right_stick_turns_the_tool_in_degrees_per_second(config):
|
||||
velocity = mapping.velocity(stick(axes=[0, 0, -1, 1.0, -1.0, -1]).axes, [], config)
|
||||
|
||||
assert velocity[WX] == pytest.approx(config.speed_deg_s)
|
||||
assert velocity[WY] == pytest.approx(config.speed_deg_s)
|
||||
|
||||
|
||||
def test_the_bumpers_yaw_in_opposite_directions(config):
|
||||
right = mapping.velocity(stick().axes, [False] * 5 + [True], config)
|
||||
left = mapping.velocity(stick().axes, [False] * 4 + [True, False], config)
|
||||
|
||||
assert right[WZ] == pytest.approx(config.speed_deg_s)
|
||||
assert left[WZ] == pytest.approx(-config.speed_deg_s)
|
||||
|
||||
|
||||
def test_several_axes_move_at_once(config):
|
||||
# The whole reason a pad beats the console's four-sector dial: the dial can
|
||||
# only ever ask for one axis.
|
||||
velocity = mapping.velocity(stick(axes=[1.0, -1.0, -1, 0, 0, 1.0]).axes, [], config)
|
||||
|
||||
assert velocity[VX] > 0
|
||||
assert velocity[VY] > 0
|
||||
assert velocity[VZ] > 0
|
||||
|
||||
|
||||
def test_drift_inside_the_deadzone_is_ignored(config):
|
||||
# A worn stick that does not centre would otherwise drive the arm across the
|
||||
# table with nobody touching it.
|
||||
velocity = mapping.velocity(stick(axes=[config.deadzone / 2, 0.0]).axes, [], config)
|
||||
|
||||
assert velocity == [0.0] * 6
|
||||
|
||||
|
||||
def test_motion_starts_from_zero_just_outside_the_deadzone(config):
|
||||
# Without rescaling, the arm would jump to a fraction of full speed the
|
||||
# instant the stick left the dead band.
|
||||
just_outside = mapping.deadzone(config.deadzone + 1e-9, config.deadzone)
|
||||
|
||||
assert just_outside == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_the_deadzone_still_reaches_full_speed_at_full_deflection(config):
|
||||
assert mapping.deadzone(1.0, config.deadzone) == pytest.approx(1.0)
|
||||
assert mapping.deadzone(-1.0, config.deadzone) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_expo_slows_small_deflections_without_capping_large_ones():
|
||||
assert mapping.expo(0.5, 0.0) == pytest.approx(0.5)
|
||||
assert mapping.expo(0.5, 1.0) == pytest.approx(0.125)
|
||||
assert mapping.expo(1.0, 1.0) == pytest.approx(1.0)
|
||||
assert mapping.expo(-0.5, 1.0) == pytest.approx(-0.125)
|
||||
|
||||
|
||||
def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config):
|
||||
velocity = mapping.velocity([0.0, 0.0], [], config)
|
||||
|
||||
assert velocity[VX] == 0.0
|
||||
assert velocity[WZ] == 0.0
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Translation both ways: what the console asks for, and what it is shown."""
|
||||
|
||||
from dora_teleop import ui
|
||||
from dora_teleop.arbiter import GAMEPAD, WEB
|
||||
|
||||
# --- what the console asks for ---
|
||||
|
||||
|
||||
def test_a_jog_carries_its_six_numbers():
|
||||
command = ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, 6]})
|
||||
|
||||
assert command.type == "jog"
|
||||
assert command.velocity == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||
|
||||
|
||||
def test_a_jog_of_the_wrong_length_is_not_a_jog():
|
||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3]}) is None
|
||||
|
||||
|
||||
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
|
||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, "fast"]}) is None
|
||||
|
||||
|
||||
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
|
||||
# The console and this node version independently; a page from a newer build
|
||||
# must not take the arm down by saying something we have not learned yet.
|
||||
assert ui.command_of({"type": "hologram"}) is None
|
||||
assert ui.command_of({}) is None
|
||||
assert ui.command_of("not even an object") is None
|
||||
|
||||
|
||||
def test_taking_control_carries_whether_it_may_preempt():
|
||||
command = ui.command_of({"type": "control", "action": "take", "preempt": True})
|
||||
|
||||
assert (command.action, command.preempt) == ("take", True)
|
||||
|
||||
|
||||
def test_the_gripper_button_says_which_way():
|
||||
assert ui.command_of({"type": "gripper", "vacuum": True}).enabled is True
|
||||
assert ui.command_of({"type": "gripper", "vacuum": False}).enabled is False
|
||||
|
||||
|
||||
def test_a_move_needs_a_position():
|
||||
assert ui.command_of({"type": "move", "x": 1.0, "y": 2.0}) is None
|
||||
|
||||
command = ui.command_of({"type": "move", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40})
|
||||
|
||||
assert command.row == {"x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}
|
||||
|
||||
|
||||
# --- what the console is shown ---
|
||||
|
||||
|
||||
def test_an_arm_that_cannot_be_reached_reads_as_disconnected():
|
||||
view = ui.ArmView(state=ui.UNAVAILABLE)
|
||||
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
|
||||
|
||||
|
||||
def test_an_arm_still_starting_up_reads_as_disconnected():
|
||||
view = ui.ArmView(state=ui.CONNECTING)
|
||||
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
|
||||
|
||||
|
||||
def test_a_latched_estop_outranks_everything_else():
|
||||
# The arm may well read ready, and saying so would invite somebody to push it.
|
||||
view = ui.ArmView(state=ui.READY, estop=True, freedrive_on=True)
|
||||
|
||||
assert ui.arm_state(view, jogging=True) == ui.UI_ESTOP
|
||||
|
||||
|
||||
def test_freedrive_outranks_idle():
|
||||
# An arm that can be pushed by hand is not one holding station.
|
||||
view = ui.ArmView(state=ui.READY, freedrive_on=True)
|
||||
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_FREEDRIVE
|
||||
|
||||
|
||||
def test_a_moving_arm_we_are_jogging_reads_as_jogging():
|
||||
view = ui.ArmView(state=ui.MOVING)
|
||||
|
||||
assert ui.arm_state(view, jogging=True) == ui.UI_JOGGING
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_MOVING
|
||||
|
||||
|
||||
def test_a_recovering_arm_is_there_but_will_not_move():
|
||||
view = ui.ArmView(state=ui.RECOVERING)
|
||||
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_STOPPED
|
||||
|
||||
|
||||
def test_a_state_we_do_not_know_reads_as_disconnected_rather_than_as_ready():
|
||||
view = ui.ArmView(state="dancing")
|
||||
|
||||
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
|
||||
|
||||
|
||||
def test_the_snapshot_carries_every_key_the_page_reads_by_name():
|
||||
view = ui.ArmView(state=ui.READY, pose=[1, 2, 3, 4, 5, 6], joints=[0] * 6, connected=True)
|
||||
|
||||
snapshot = ui.snapshot(view, WEB, jogging=False, timestamp=123.0)
|
||||
|
||||
for key in (
|
||||
"type",
|
||||
"ts",
|
||||
"connected",
|
||||
"ready",
|
||||
"arm_state",
|
||||
"owner",
|
||||
"estop",
|
||||
"vacuum",
|
||||
"freedrive",
|
||||
"position",
|
||||
"joints",
|
||||
):
|
||||
assert key in snapshot
|
||||
assert snapshot["position"] == {"x": 1, "y": 2, "z": 3, "roll": 4, "pitch": 5, "yaw": 6}
|
||||
|
||||
|
||||
def test_an_arm_with_no_pose_yet_reports_none_rather_than_a_stale_one():
|
||||
snapshot = ui.snapshot(ui.ArmView(), None, jogging=False, timestamp=0.0)
|
||||
|
||||
assert snapshot["position"] is None
|
||||
assert snapshot["owner"] == "none"
|
||||
|
||||
|
||||
def test_the_owner_is_named_so_the_page_can_gate_its_jog_pad():
|
||||
assert ui.snapshot(ui.ArmView(), GAMEPAD, False, 0.0)["owner"] == "gamepad"
|
||||
assert ui.snapshot(ui.ArmView(), WEB, False, 0.0)["owner"] == "web"
|
||||
|
||||
|
||||
def test_get_state_fills_in_what_telemetry_does_not_carry():
|
||||
view = ui.ArmView()
|
||||
|
||||
view.take_get_state({"connected": True, "ready": True, "tool_engaged": True, "freedrive": True})
|
||||
|
||||
assert (view.connected, view.ready, view.tool_engaged, view.freedrive_on) == (True, True, True, True)
|
||||
@@ -0,0 +1,450 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dora-rs"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/fc/9dd2e1cc4da1caf46496353da6c12f7b7c90512186d828f1d587dc6deddc/dora_rs-1.0.1.tar.gz", hash = "sha256:8cef7fa133d93e7ca46581a9efef6b45b5768cecef5ee6b9e22632978b8fe8a2", size = 1690155, upload-time = "2026-09-03T12:21:54.217Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/8b/2040480f8393da850f544347a389dab6bf99c84131da9cb5a2273829a76f/dora_rs-1.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:56fb3daf26a978e6f535b0d6b04ac3abd362aff62de6771cd74c2dee06207cb8", size = 24759810, upload-time = "2026-09-03T12:21:42.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/4c/c563464b74cd31d801fe37c693751005717c80834210c1fa61cb44d7bd34/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:61fd33ba5a5cfa01ba4aed4c75bda490b5ad0e5d7180bde7b075d17e0c782486", size = 24061908, upload-time = "2026-09-03T12:21:29.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/20/125f14f222c2eab928b23d310eeed61c93e9805d30575d21f05df285fb84/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:6b0075de47b8310bf3801f2ce70b13b6925ebe1c8a7b04c9084bcd11688506ea", size = 22804362, upload-time = "2026-09-03T12:21:32.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1b/98e0ce52006fbd6d91f33037c5a76ab12edf8c74a01062af39c4dab4c403/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_i686.whl", hash = "sha256:7f1377dda91435ca9d69ecce88a621052c172c2eb07f9c0aa0c1634d6bbfc057", size = 25245130, upload-time = "2026-09-03T12:21:37.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/80/f931008dec0806d3446de9d762cb2f16883530857e0941d36449b554b84b/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a28d634659a23dc8df9fbd40ad093f712e202821060f949f3dba5af8f7f36377", size = 24887127, upload-time = "2026-09-03T12:21:39.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/43/01a610cb1f696410698ffb9a6e6d5f2dd3c6ae22ad63aed366c00136b3ae/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3255d040778b44542f3d03288268a22f85253db45099d60d33776b7ef92ac1c6", size = 29797596, upload-time = "2026-09-03T12:21:44.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f5/6668702f8a107c2a701bc8368d14c18d1d36b620c8c7cbc5ac2d83c58cf7/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0febdd43c0c0b511c60def3691d83d69a54ed8543207af3bb238989f7a1f5c0b", size = 28151873, upload-time = "2026-09-03T12:21:46.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/51/0141e6fe69a7444115d0e9e56bbed234cd86035d3d5588f61d56e0778a61/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:f774a046e6d869b4e40b9c9fea5d1a46175c3d83179b44d1f3310c788562b72e", size = 29301986, upload-time = "2026-09-03T12:21:49.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b2/5032b628e1e46ef76180860a6f14e32b940d2979971e931630c6e10e3316/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d8c8affc29e401558ac83136055750b9de10ac3d3a98855b59866b0c08260dfa", size = 29999168, upload-time = "2026-09-03T12:21:51.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3b/bb7c131dd2778909f4ec4d3e0151a7fa342a38ea8fa3ca64d2ef87d93f2f/dora_rs-1.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:fbca33bf87e77fb9532e3bbeff023adb0e9b8e713d6f9106f114e0b0408666f5", size = 22396575, upload-time = "2026-09-03T12:21:55.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dora-teleop"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dora-rs" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pygame-ce" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||
{ name = "pyarrow", specifier = ">=17.0" },
|
||||
{ name = "pygame-ce", specifier = ">=2.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.1" },
|
||||
{ name = "ruff", specifier = ">=0.9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "25.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygame-ce"
|
||||
version = "2.5.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/2d/0f942ec31d558a6a1f2fd0df9965ff0055f165ed5b8d36f6509b1f3768a2/pygame_ce-2.5.8.tar.gz", hash = "sha256:3c8e69088ead310037972c391306ea58e74d7296b35d1890067749235fd554ba", size = 5300831, upload-time = "2026-08-09T11:39:49.226Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/9b/884fb7367951e74e7f77061bb08e893272794c07b1212dc61ba258c3c71e/pygame_ce-2.5.8-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:9e98223c8874177c8441923a8f60da76b1de9d7f2450602a4c7ef24c15e84f1a", size = 16588698, upload-time = "2026-08-09T11:38:07.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/7d/b3f3a58cd09e97dbbedde419bedb7ead1f7e4d3cd6465b00bdf56766ded9/pygame_ce-2.5.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a7b2a54548926eaf535ac574cfa6528f96bd1e2f524a20fea514b2922dd68bcc", size = 12060566, upload-time = "2026-08-09T11:38:09.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/8b/7ba5dbf40ff461984327539a6895ada8ab2eac4c035238f97a0fd9da9866/pygame_ce-2.5.8-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff283d1bee65e208cf2f0da7f2f4e3d0993d1787938898de2872fbefab1f786b", size = 12599420, upload-time = "2026-08-09T11:38:12.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/02/c315738725cd08d2ff4cc8f8dd29638e69e70f200d3610bcd291cbb886b8/pygame_ce-2.5.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:33e1b5a78cd6d25c2c8f00c09cdc0ba8000eec81b53dc7d8d7323d8319691d69", size = 12155284, upload-time = "2026-08-09T11:38:14.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/3d/b9d57ebab57fdef946626ae275b111c077b83fe0d0ccc3a2ec12b17523d2/pygame_ce-2.5.8-cp311-cp311-win32.whl", hash = "sha256:b919b49b8a41564f7d0ad43f6dc97a66f29d3956fb592581f4abd62442644c9c", size = 9122893, upload-time = "2026-08-09T11:38:16.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/68/54cbebea295bbd9598604e3b4c3ef2aff75df1612482c2d102963151096c/pygame_ce-2.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:0f75f6cf17607e380687aa5551c46aa830c8037b2950c71e48be8891ddc737da", size = 9666898, upload-time = "2026-08-09T11:38:19.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8a/79129cc0c755d639d180a582869e4c7a0d5106dbf611262bcf5d554ce59d/pygame_ce-2.5.8-cp311-cp311-win_arm64.whl", hash = "sha256:8e7931bd6b8ffc1474e7b3d5717e18bf8ff3eed95ab9042f1b9a0907e9ff6519", size = 10212252, upload-time = "2026-08-09T11:38:21.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/9c48c56cb36baa49d8732fc41896d2d8a6027bf94155aa1a5663fd72f7ff/pygame_ce-2.5.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ae2141c81595b4b13b780d325b9ea0016c6647f8b3a6004278b00765a96664d", size = 16570732, upload-time = "2026-08-09T11:38:24.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/83/f4979079f5a8f729b0f488e987db1227086816c26e1b985d61fc9a60605f/pygame_ce-2.5.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e401643af9455cb69f8cb77050c325f290314da0051e9ab82c5ccaf8017c314a", size = 12048869, upload-time = "2026-08-09T11:38:26.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/38/9961f6543bfb4ecc4dbeee0fdb57edbd633945dc143df9c70ee476416cc4/pygame_ce-2.5.8-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6d4839f127b1b66f1dfe7e0dca3d2233b6f3a63077690081d1996e24087c6055", size = 12587614, upload-time = "2026-08-09T11:38:29.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/07/e139faa8911d48991e161e356e652be2a642303f3baf6a57c7ba4a1f0f49/pygame_ce-2.5.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf9c8b4376077efec6ae31443172ae98545f278d0e4426852e81c28ab1f5d7c2", size = 12147342, upload-time = "2026-08-09T11:38:32.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e6/238b48936733ed1908486df072137e7eef61fd26de4620d54e438b69d509/pygame_ce-2.5.8-cp312-cp312-win32.whl", hash = "sha256:91d2434d8cbdb7cd04c9b37e76a8154641da25f4e274265806407f495fe6e889", size = 9126160, upload-time = "2026-08-09T11:38:34.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/13/2bba0ebe563047ae9de68dc05656dc0bfa9394c60970548b06d2f2e106c5/pygame_ce-2.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:2cfe8afe1e7955780f0bd4f01d65f1b4903f561407052081cfcec31e80957c3c", size = 9668595, upload-time = "2026-08-09T11:38:37.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2e/871161f0546d549fa458609029771d7a36c1e7758e164a3b2c2ed61b507b/pygame_ce-2.5.8-cp312-cp312-win_arm64.whl", hash = "sha256:6c48a3ad5f7102e076055a3c817c12e932ee5c9b831080db2ea9754236b0490d", size = 10213955, upload-time = "2026-08-09T11:38:39.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/06/f2e2d9fe3eb2dc1fde30b90e250274e85ba355af729892ac71ba653924a1/pygame_ce-2.5.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56441a9bb75c2461750dc0e6e4a46e3833b3cf6339bfbaf16f93ecac037510f3", size = 16562516, upload-time = "2026-08-09T11:38:42.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/fe/4be67df98bb05f7b024900f80fe4d07a72cec7022262d17c449b2a9f6034/pygame_ce-2.5.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3183bc0d808e739ca2400df45a5bfae10704ccbcacab0f04f9adc9027e86427", size = 12045702, upload-time = "2026-08-09T11:38:44.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8f/f7d283799aaa2208c1276f085d514f2ac37ce1bb5df940c7cbac7e6a6320/pygame_ce-2.5.8-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:841aecccb498419367936bea0b1b0c6e9d635dc81c41ef26ec1769158f1ab871", size = 12585829, upload-time = "2026-08-09T11:38:47.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4d/03fd52c7f958b8e929757a118cada9aee16ae9d4fd84f2682ee0cbd3941d/pygame_ce-2.5.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5bb966b610e161a8f6d908b4c62ef3a1f3e922bc7bed73bd70a3e15dc780b90e", size = 12145623, upload-time = "2026-08-09T11:38:49.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/61/c02613190a3256a656258bb4f677aecc09c3cb1f5521490ce7d5fb6308dd/pygame_ce-2.5.8-cp313-cp313-win32.whl", hash = "sha256:cab944d76af71e707803856b4db984ca90d9d6453e470e89771ed963c11ed912", size = 9123635, upload-time = "2026-08-09T11:38:52.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/da9186e5b88714c16fdb23bc4ba0bca4a75c21e3a5cf9607765773f68d22/pygame_ce-2.5.8-cp313-cp313-win_amd64.whl", hash = "sha256:f495b0eb7a5c54c59da58e964bc7f68073c3f43cf307729fd48309104a04c190", size = 9668342, upload-time = "2026-08-09T11:38:54.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d3/78136bc51be25afbd7954c22488860e80f32d9a885f6667fed8aac7df0d6/pygame_ce-2.5.8-cp313-cp313-win_arm64.whl", hash = "sha256:dea22b4d4c418bbd0bd9853ae797ecfa882436d2684ceb6063ba8b520adc8500", size = 10211021, upload-time = "2026-08-09T11:38:57.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e6/8e83904cf4184223419345a78c905e7c1ace10228befd87eb497ba015c8c/pygame_ce-2.5.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ec4efa6b57a194f5ad51b92211cc74b485320df951b0cc870235b2b889b9b6d", size = 16571019, upload-time = "2026-08-09T11:39:00.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/f3/4f90a0b5e86635d741111084eaa4d4c65fcab2f461edb8c9efa502e4c630/pygame_ce-2.5.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb91d0bb0b5e2a4da61910d752769d4580cacffb4b7338cee57c932351c67e7b", size = 12051251, upload-time = "2026-08-09T11:39:02.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/7e/b0c4f5d43261e8707353ef40cf6620d46d42f43a502c236e93ce6ea82e7b/pygame_ce-2.5.8-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68bd9c4ac42bb2549c034d56399404f16c87c56714db721ed975a759798a3d16", size = 12585932, upload-time = "2026-08-09T11:39:04.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/62/06f0ceb7f5a154e0071a4ce81a16de47b9f3b85034fa391686d489d15be5/pygame_ce-2.5.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0027bd2255cbb39789d9c7ff95a5f040f56d1cfeb09504497447c0ee06169a24", size = 12147357, upload-time = "2026-08-09T11:39:07.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/57/14d6b40318af71cf20e4c750866795424e018f79c5a5cf3a9a74a8717c4c/pygame_ce-2.5.8-cp314-cp314-win32.whl", hash = "sha256:c5285d444e4b789ef95522bbd2b90433163ad2b89e0ae4891236fde88a027fbd", size = 9255121, upload-time = "2026-08-09T11:39:09.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/31/92d32a9bf9b78e9ef10cec7224e2f00efc957757505822af8dc4225e9b41/pygame_ce-2.5.8-cp314-cp314-win_amd64.whl", hash = "sha256:b4c2e28c201240199356952e6bd0221969d2eb8a1a15c3454d77db209bad7891", size = 9833757, upload-time = "2026-08-09T11:39:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/26/4024483d4a3161ed8fb8d3f3f4957580af31d468e0c31df7e6038f45ccdc/pygame_ce-2.5.8-cp314-cp314-win_arm64.whl", hash = "sha256:b23034594412456504822d3088cf5f292f63bbe415eac29735340a59999e4116", size = 10410381, upload-time = "2026-08-09T11:39:14.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/11/0b47f80b261d6379c86b9cc3fa405af5bd62af10506de7e28c2275d67b86/pygame_ce-2.5.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a4e0804d53bae8d9aa5192fc2259b40dd084b48ea273dd7dd6e1c12b73303c4", size = 16571139, upload-time = "2026-08-09T11:39:16.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/95/694cd02641a0c0956b6c5919b56599af302524ac133551b654f550f28919/pygame_ce-2.5.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7f03d38b3693c014dd47d4e281620b6756669129e545af1cd608a9484582846", size = 12051154, upload-time = "2026-08-09T11:39:19.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5e/bed22b0d07d9e96bb9a448acc9da7a235f046280264c33476f0c0753d302/pygame_ce-2.5.8-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d084b79535f3529aa157edd9401f4f33e5082f318d287029291d6596bb225105", size = 12585976, upload-time = "2026-08-09T11:39:22.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/ce/8c167ba5ba736e372730ae29ef0f8cb2e62a88580e647ae987bc112eb1cb/pygame_ce-2.5.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9cbf6e2648ae6d76aa8ece82187d240300cfd9f4c90860e1e466f78cf0ab14a", size = 12147466, upload-time = "2026-08-09T11:39:24.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/92/c99ed51479f2d2a5d7fc9d5aa3b880e4c0fcc87f30cb95ecf325f35dd2fd/pygame_ce-2.5.8-cp315-cp315-win32.whl", hash = "sha256:312a01ff5439a0bd2e55b56683518454da4cbe4cad493d0580e9c49f6aacc2d8", size = 9254928, upload-time = "2026-08-09T11:39:29.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/41/404836598d666ffe02681fd0c29f013daaa8baf18e5de5f7a5d4870b6105/pygame_ce-2.5.8-cp315-cp315-win_amd64.whl", hash = "sha256:5b789aa7e4239be025c9de8428c2c422f0811f0e3dd702b574d836fd9ae040eb", size = 9833657, upload-time = "2026-08-09T11:39:31.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a2/7a844f772c6f0967e75d49cb10602fbb878b9daee1cc080d2b9597bcc873/pygame_ce-2.5.8-cp315-cp315-win_arm64.whl", hash = "sha256:28c4fed3870e3edf72ea7ea359bb5f64c55db0fb9cb3bdb26b4b674b4b4bcff4", size = 10410231, upload-time = "2026-08-09T11:39:34.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b8/525dfcfda50ea9e98cd4097d6d0beabcfa0a6007475c04dab2021077c6de/pygame_ce-2.5.8-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:0aa908c9fce4d508205e0b9cdc0f7338066da63322f6bcaa9a9c8aff9155a87e", size = 16485912, upload-time = "2026-08-09T11:39:37.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/33/d02386a4314be22bc6198d93c2d99f372c0c8c7b71fc9c390cf36d5c5c99/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:894faf17699ea7f14eabd9161ef532c1d248e40f568dab9240fc68b4c1ea85dc", size = 12008829, upload-time = "2026-08-09T11:39:39.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/26/a14abbac97c681385dbb01d5c44c867992dbf2265a784af35141c485813e/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d0c4b2b38e1946ea7a7c625ea317e237573e8731e4a56055eb4361b3c2daa0f", size = 12538786, upload-time = "2026-08-09T11:39:42.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/6c/2a8d9ca41d40e2d30e940107a019bbc17de518948c360789f59a439d689a/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ea02f653f76d496870766e1fb8419b6a6aebd81d40265057672f1f6d63d3509", size = 12102335, upload-time = "2026-08-09T11:39:44.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b5/1068d4d24891b17fcb6cf3556b1de8d5821a2bc44ebbea2b25dbd767ae2b/pygame_ce-2.5.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2f2f1c03dc8d4460779f9e55f959a7c7e523101b3530f60f3208e5ca65a6216", size = 9635346, upload-time = "2026-08-09T11:39:46.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>teleop — placeholder console</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
h1 { font-size: 16px; margin: 0 0 4px; }
|
||||
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 60ch; }
|
||||
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 16px; max-width: 60ch; }
|
||||
.row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
|
||||
.row span:last-child { color: #7fd1e8; }
|
||||
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
||||
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
||||
button:hover { background: #363648; }
|
||||
button.stop { background: #5a1f27; border-color: #7d2b35; }
|
||||
#link { color: #8a8a9e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>teleop — placeholder console</h1>
|
||||
<p class="note">
|
||||
Not the real console. This page exists so the dataflow has something to serve and so the
|
||||
node can be checked end to end before the Control section is ported across from the
|
||||
monolithic repo. It reads telemetry and can take, release and stop — nothing else.
|
||||
</p>
|
||||
|
||||
<div class="card" id="state"></div>
|
||||
|
||||
<div class="card">
|
||||
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
|
||||
<button onclick="send({type:'control', action:'release'})">Release</button>
|
||||
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
|
||||
<button onclick="send({type:'reset'})">Reset</button>
|
||||
</div>
|
||||
|
||||
<div id="link"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "vacuum", "freedrive"];
|
||||
let socket = null;
|
||||
|
||||
function send(payload) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "data", payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function render(telemetry) {
|
||||
const rows = KEYS.map((key) =>
|
||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||
const pose = telemetry.position;
|
||||
if (pose) {
|
||||
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
|
||||
rows.push(`<div class="row"><span>position</span><span>${shown} mm</span></div>`);
|
||||
}
|
||||
document.getElementById("state").innerHTML = rows.join("");
|
||||
}
|
||||
|
||||
function connect() {
|
||||
// The socket is the bridge's port, not this page's — the page is served over
|
||||
// HTTP on a different one.
|
||||
const url = `ws://${location.hostname}:9000`;
|
||||
document.getElementById("link").textContent = `socket: ${url}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type !== "data" || !message.payload) return;
|
||||
if (message.payload.type === "telemetry") render(message.payload);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user