Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1c33f5b1 | ||
|
|
da1b079b7b | ||
|
|
ffbba0f4d1 | ||
|
|
ebd3b3e493 | ||
|
|
1b63ce97c9 | ||
|
|
177511af1f | ||
|
|
d336f8d499 | ||
|
|
885b7a569f |
@@ -35,6 +35,18 @@ jobs:
|
||||
- name: Validate the dataflow
|
||||
run: dora validate dataflow.yml
|
||||
|
||||
# The gamepad dataflow pulls dora-joy by `git:`, but validation resolves
|
||||
# the wiring without fetching it, so this stays offline and still catches
|
||||
# a mistyped port.
|
||||
- name: Validate the gamepad dataflow
|
||||
run: dora validate dataflow-gamepad.yml
|
||||
|
||||
# Same again for the real-arm stack. Its `output_types:` block is what
|
||||
# lets this pass without an arm or a clone of the arm node, so a drift
|
||||
# between it and the tag it pins fails here.
|
||||
- name: Validate the robot dataflow
|
||||
run: dora validate dataflow-robot.yml
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -12,3 +12,6 @@ build/
|
||||
.ruff_cache/
|
||||
out/
|
||||
.dora/
|
||||
# Where `dora build` clones a `git:` node source, which
|
||||
# dataflow-robot.yml and dataflow-gamepad.yml both have.
|
||||
git/
|
||||
|
||||
@@ -7,17 +7,33 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
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.
|
||||
It is **the piece in the middle**, and it **touches no hardware at all**. The arm is
|
||||
[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
|
||||
ordinary `jog` and `request` ports. The gamepad is
|
||||
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw axes and buttons
|
||||
on `joy` and knows nothing about arms. Do not add either here: a node whose job is policy
|
||||
should not carry a hardware dependency, and a pad nobody else can reach is a pad that gets
|
||||
reimplemented.
|
||||
|
||||
**The web console is the exception, and it is deliberate.** This node serves the page and
|
||||
holds the browsers' sockets itself (`dora_teleop/server.py`), rather than going through
|
||||
`dora-websocket-transceiver` as it did up to v0.2.0. A bridge node for one page meant a
|
||||
second node, a second port and a round trip through the dataflow for every button press,
|
||||
to carry a UI that only this node can interpret anyway. That is not the monolith
|
||||
`dora-ulite6` was rewritten to escape — the monolith was one process owning the robot
|
||||
driver, perception and the UI at once. What is forbidden is the hardware, and that has not
|
||||
moved.
|
||||
|
||||
**What stays here is everything about what a reading *means***: the deadzone, the response
|
||||
curve, the layout (which button is grasp), and the mapping to base-frame mm/s and deg/s.
|
||||
`dora-joy` publishes raw SDL and nothing else, so there is exactly one place shaping each
|
||||
number.
|
||||
|
||||
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.
|
||||
rebuild the monolith inside a node. That boundary is the one that matters, and the
|
||||
built-in server does not move it: the console can ask for motion and nothing else.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -29,16 +45,17 @@ uv sync --group dev # plus pytest and ruff
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
|
||||
dora run dataflow-gamepad.yml --uv # the same, with dora-joy pulled in by git:
|
||||
```
|
||||
|
||||
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts
|
||||
expected next to this one.
|
||||
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||||
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
@@ -54,17 +71,29 @@ a source goes away**.
|
||||
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/joy.py`** — The pad as it arrives from `dora-joy`. `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.
|
||||
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
|
||||
into another process added a way for it to go away that did not exist before — that
|
||||
process dying — and from here that looks like silence rather than like an unplugged pad.
|
||||
So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
|
||||
because `dora-joy` publishes on every tick whether or not the pad moved; if that ever
|
||||
changes, this layer is gone and nothing here will say so.
|
||||
- **`dora_teleop/message.py`** — The wire shapes, **declared and never inferred**.
|
||||
pyarrow types a struct from its first row, so `{"value": None}` is `null` and
|
||||
`{"value": 1.0}` is `double` — an inferred `command` would change its port's type
|
||||
between one verb and the next.
|
||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity, and the layout
|
||||
that says which index is which. 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. `is_active` lives here rather than on `Sample`
|
||||
because it needs the layout: **a trigger rests at -1.0**, so the pad-is-in-use test has
|
||||
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
|
||||
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires.
|
||||
- **`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
|
||||
lease because the console is already built on it — it gates its controls 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
|
||||
@@ -74,31 +103,84 @@ a source goes away**.
|
||||
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.
|
||||
first time somebody picks up the pad. Errors name the variable. It also refuses deadman
|
||||
layers that are out of order, which is otherwise invisible until the day it matters.
|
||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||
dynamic node's stdout.
|
||||
- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml`
|
||||
- **`dora_teleop/server.py`** — The console's HTTP and WebSocket server, on a thread of
|
||||
its own. Serves `dora_teleop/web/index.html` at `/` and holds the browsers at `/ws`, on
|
||||
one port so the page finds its socket on its own origin. Both queues are bounded: a
|
||||
flood of messages is dropped rather than grown, and a slow browser loses stale snapshots
|
||||
rather than the newest one. **The browser's deadman lives here** — `clients` counts open
|
||||
sockets, and the tick reads it.
|
||||
- **`examples/bench.py`** — A stand-in for the arm, so `dataflow.yml`
|
||||
is self-contained and CI can validate it with no sibling clones. **It is not a
|
||||
simulator** and must not become one: it never integrates a velocity or moves a pose. Its
|
||||
job is to close the loop so this node can be watched, not to stand in for
|
||||
`dora-ulite6` — anything needing the arm to actually respond needs the arm.
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the
|
||||
velocity that would be sent, with no dataflow and no arm. It exists because the indices
|
||||
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
|
||||
running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
|
||||
the buttons and the velocity that would be sent. It exists because the indices in
|
||||
`mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
disagrees is otherwise found by watching a real arm move the wrong way. It goes through
|
||||
the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm
|
||||
would get — keep it that way, or it stops being evidence.
|
||||
the same `velocity()` the node runs, on the same readings, so what it prints is what the
|
||||
arm would get — keep it that way, or it stops being evidence. `dataflow-gamepad.yml`
|
||||
declares it as `path: dynamic`. For a pad whose layout is unknown, `dora-joy-probe`
|
||||
needs no dataflow at all.
|
||||
|
||||
## The standard, and why it is one
|
||||
|
||||
This node publishes `twist` (SI) and `command` (robot-agnostic verbs), not one robot's
|
||||
vocabulary. The contract is in the vault at `resources/robotics/teleop-contract.md`, which
|
||||
is where it lives because no source package is shared between node repositories.
|
||||
|
||||
**Normalise first, scale once.** Every control source becomes a `[-1, 1]` six-vector
|
||||
before anything is scaled, and the scaling happens in exactly one function. The deadzone
|
||||
and the response curve are applied only to the gamepad, because they correct a worn
|
||||
physical stick and a browser has none. Break this and the console silently acquires the
|
||||
robot's units, which is what it used to have and the reason it fitted only one arm.
|
||||
|
||||
`jog` and `request` still carry the same intent in `dora-ulite6`'s units and service
|
||||
names. They are deprecated, exist so the two repositories can move independently, and go
|
||||
away once the arm node takes the standard ports. `LEGACY_REQUEST` in `main.py` is the only
|
||||
place that knows what that node calls a verb.
|
||||
|
||||
`joint_jog` is specified in `dora-node.yml` and deliberately **not implemented**. A
|
||||
joint-space robot needs IK and position integration against joint limits, neither of which
|
||||
belongs here, and a port nothing wires reads as supported while having never run.
|
||||
|
||||
## Rules that are not negotiable
|
||||
|
||||
- **Send the change, not the state.** A velocity is a set-point the arm holds. `_drive`
|
||||
sends when it changes and otherwise repeats on `JOG_KEEPALIVE_S`, which exists only to
|
||||
beat the arm's own expiry. Repeating every tick made a real xArm re-plan the same motion
|
||||
twenty times a second, which reads as stepping. Do not restore the every-tick re-send.
|
||||
A change must never be made to wait for the repeat.
|
||||
- **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.
|
||||
- **E-STOP is never gated on the lease, and neither is its release.** From either
|
||||
source, at any time. The latch (`view.estop`) is checked first in
|
||||
`_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
|
||||
not a stop. `_clear` is the only way out and does both halves, this node's latch and the
|
||||
controller's error; the pad's X and the console's RESET both go through it, so a
|
||||
pad-only dataflow is not left with a latch nothing can release.
|
||||
- **The deadman layers must stay in order**: `JOY_TTL_S` (0.2) < `JOG_TTL_S` (0.4) < the
|
||||
arm node's `VELOCITY_COMMAND_TTL_S` (0.5). Each is the backstop for the one inside it,
|
||||
and a backstop that fires first backs up nothing. `Config.from_env` enforces the first
|
||||
pair; nothing can enforce the second from here.
|
||||
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
|
||||
without waiting out any timer beyond the one that detects it.
|
||||
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
|
||||
a velocity straight through, which is why the console had to speak mm/s. One conversion
|
||||
point or none.
|
||||
- **Declare every published Arrow schema.** See `message.py`.
|
||||
- **Do not add a robot driver or a gamepad driver here.** See above. In particular: no
|
||||
`pygame`, no `evdev`, and no environment variable naming a device. The web server is
|
||||
the one deliberate exception and is scoped to the Control console; anything the console
|
||||
cannot ask the arm to do does not belong on it.
|
||||
- **Nothing on the server thread may block the control loop.** `server.py` meets the loop
|
||||
at two non-blocking points only — the queue the tick drains and the snapshot handed
|
||||
back. A browser that stops reading must never stall the thread that stops the arm.
|
||||
|
||||
## What this node cannot do
|
||||
|
||||
@@ -116,22 +198,19 @@ about except through `robot_state` and telemetry.
|
||||
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
|
||||
`joy`, `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.
|
||||
checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
|
||||
standard joystick type. 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.
|
||||
- **The `joy` message** (`dora-joy`): the Arrow struct
|
||||
`{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
|
||||
Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
|
||||
and no timestamp: freshness is arrival time here.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -4,16 +4,22 @@ A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the
|
||||
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.
|
||||
ordinary `jog` and `request` ports, reads the pad from
|
||||
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and **serves the browser console
|
||||
itself** on one port. It contains no robot driver and no gamepad 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. It touches no hardware at all.
|
||||
|
||||
## The gamepad
|
||||
|
||||
The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
||||
axes and buttons and knows nothing about arms. Everything below — the deadzone, the
|
||||
response curve, which button is which, and what any of it means in mm/s — happens here.
|
||||
|
||||
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.
|
||||
is the whole reason a pad beats the console, whose ring sectors ask for one direction at
|
||||
the speed the dial is set to. The console can still hold two at once — a sector and a Z
|
||||
pill — but it cannot ask for a third of full speed on one axis and full on another.
|
||||
|
||||
| Control | Does | Units |
|
||||
|-------------|-------------------------------------------|-------|
|
||||
@@ -23,9 +29,9 @@ for one axis at a time at one fixed speed.
|
||||
| Bumpers | Yaw, left and right | deg/s |
|
||||
| A | Close the tool | — |
|
||||
| B | Open the tool | — |
|
||||
| X | Clear a controller error | — |
|
||||
| X | Release the E-STOP and clear the arm's error | — |
|
||||
| Y | Toggle freedrive | — |
|
||||
| Back | **E-STOP** | — |
|
||||
| Back | **E-STOP** — latches until X | — |
|
||||
|
||||
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.
|
||||
@@ -33,20 +39,30 @@ 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.
|
||||
somebody can read. The layout lives here rather than in `dora-joy` for the same reason —
|
||||
`dora-joy` does not know what a button is for, which is what makes it reusable.
|
||||
|
||||
`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.
|
||||
|
||||
**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm
|
||||
still no matter what the sticks do — a stop somebody had to reach for is not undone by the
|
||||
next thumb movement. X is the only way back, and it does both halves: releases the latch
|
||||
here and clears the error on the controller. Neither button is gated on the lease, because
|
||||
an idle pad hands the arm back two seconds after the stop it just made, and a latch you
|
||||
must first take the arm back to release is a trap.
|
||||
|
||||
### Checking your pad
|
||||
|
||||
```bash
|
||||
uv run dora-teleop-probe
|
||||
dora up
|
||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
uv run dora-teleop-probe # in another terminal
|
||||
```
|
||||
|
||||
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
|
||||
with no dataflow and no arm. **It cannot move anything.** There is no dora node and no
|
||||
connection to the robot; it only reads.
|
||||
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
|
||||
dynamic node, publishes nothing and never talks to the arm; it only reads.
|
||||
|
||||
```text
|
||||
axes (6 reported)
|
||||
@@ -64,6 +80,7 @@ connection to the robot; it only reads.
|
||||
wz +30.00 deg/s yaw <--
|
||||
|
||||
moving: yes
|
||||
asking for the arm: yes
|
||||
```
|
||||
|
||||
Run this before trusting the table above. The indices are SDL's documented layout, not
|
||||
@@ -75,8 +92,33 @@ by watching an arm move the wrong way. Two things to look for:
|
||||
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
||||
itself, and a reason to raise `DEADZONE`.
|
||||
|
||||
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is
|
||||
what the arm would be sent.
|
||||
It goes through the same `velocity()` the node runs, on the same readings, so what it
|
||||
prints is what the arm would be sent.
|
||||
|
||||
For a pad whose layout is not known at all — which index is even which — use
|
||||
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow and no coordinator.
|
||||
|
||||
## Speaking to any robot
|
||||
|
||||
Nothing above is specific to one arm. Both control sources are reduced to a **normalised**
|
||||
six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only
|
||||
then scaled, in one function. A gamepad reaches that vector through the deadzone and the
|
||||
response curve, which exist to correct a worn physical stick; the browser sends it already
|
||||
clean, because a browser has no worn centre.
|
||||
|
||||
That is what lets the same node and the same console drive a robot in mm/s and one in m/s:
|
||||
neither source ever knew the difference. It is also why a gamepad and the console asking
|
||||
for the same deflection produce byte-identical output.
|
||||
|
||||
`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt
|
||||
takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person
|
||||
reading a dataflow can judge.
|
||||
|
||||
`command` carries verbs rather than service names: `gripper_close` rather than `grasp`,
|
||||
so a robot that swaps a vacuum for a two-finger gripper needs no change at either end.
|
||||
|
||||
The full contract, including the reserved `joint_jog` port for joint-space robots, is in
|
||||
the vault at `resources/robotics/teleop-contract.md`.
|
||||
|
||||
## The lease
|
||||
|
||||
@@ -99,18 +141,23 @@ 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
|
||||
never whether to start but whether anything has happened that means it must stop. Four
|
||||
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
|
||||
1. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) while a source is driving.
|
||||
2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
|
||||
process dying looks like silence rather than like an unplugged pad — so silence means
|
||||
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
|
||||
is what makes silence unambiguous.
|
||||
3. **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 reports
|
||||
itself gone or the last browser disconnects. An unplugged pad is not a centred pad.
|
||||
4. **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.
|
||||
Keep them in that order, each shorter than the one behind it. A backstop that fires before
|
||||
the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at
|
||||
startup rather than letting that happen quietly.
|
||||
|
||||
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
||||
controller, not in this process.
|
||||
@@ -118,21 +165,44 @@ 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.
|
||||
|
||||
### What goes on the wire is the change
|
||||
|
||||
A velocity is a set-point the arm holds, not a frame in a sequence. This node sends one
|
||||
when it **changes** — a new direction, or the stop — and otherwise repeats the current one
|
||||
only every `JOG_KEEPALIVE_S` (0.2 s), which exists solely so the arm's own expiry never
|
||||
fires mid-motion.
|
||||
|
||||
It used to repeat every tick. On a real xArm that means the controller is handed the same
|
||||
velocity twenty times a second and re-plans the same motion each time, which an operator
|
||||
feels as the arm moving in steps rather than travelling. Keep the repeat well under the
|
||||
arm's `VELOCITY_COMMAND_TTL_S` and no faster than it needs to be.
|
||||
|
||||
**A change never waits for the repeat.** Releasing a button is acted on the tick it
|
||||
happens, so sending less does not cost anything at the operator's end.
|
||||
|
||||
## 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 | `tick` | 50 ms — change detection, deadman, telemetry |
|
||||
| in | `joy` | One pad reading from `dora-joy` |
|
||||
| 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 |
|
||||
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
|
||||
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
|
||||
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
|
||||
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
|
||||
| out | `ui_state` | The console's telemetry snapshot, as JSON — see below |
|
||||
|
||||
Wire `twist` **or** `jog`, never both: they carry the same intent, and a robot reading
|
||||
both is driven twice. Same for `command` and `request`.
|
||||
|
||||
The browser is not on this table. It reaches the node over the node's own HTTP and
|
||||
WebSocket port rather than through the dataflow, so there is nothing to wire for it —
|
||||
`ui_state` is published for anyone else watching and has no consumer in the stacks below.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -142,51 +212,124 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
| `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 |
|
||||
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
|
||||
| `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.
|
||||
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
|
||||
`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time
|
||||
somebody picks up the pad.
|
||||
|
||||
`JOYSTICK_INDEX` moved to `dora-joy` along with the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
||||
The real stack is three nodes: this one, the pad and the arm. The console comes with
|
||||
this node.
|
||||
|
||||
```yaml
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
tag: v0.3.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
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:
|
||||
WEB_PORT: "7000"
|
||||
```
|
||||
|
||||
Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this
|
||||
repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node.
|
||||
Wire `ulite6`'s `request` and `jog` from this node, and add `dora-joy` on a 20 ms tick for
|
||||
the pad — `queue_size: 1` matters, because it ticks faster than this node does and an arm
|
||||
should be driven by the newest reading or by none.
|
||||
|
||||
**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.
|
||||
Then open `http://<the machine running teleop>:7000` for the console. Nothing else needs
|
||||
wiring: the node serves the page and holds the browsers' sockets, and the page finds the
|
||||
socket on its own origin.
|
||||
|
||||
Leave `joy` unwired for a browser-only console; the node starts without it. Set
|
||||
`WEB_PORT: "0"` for a pad-only one, and no port is opened.
|
||||
|
||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No
|
||||
hardware: the pad and the arm are other nodes, and the browser is any browser.
|
||||
|
||||
### Driving a real arm
|
||||
|
||||
`dataflow-robot.yml` is the one that moves a robot: the pad, this node and
|
||||
[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), with no bench and no browser.
|
||||
Both sibling nodes come in by `git:`, so it runs from a fresh clone with nothing else
|
||||
checked out.
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # see the note below before reaching for --uv
|
||||
dora build dataflow-robot.yml # clones and installs both, needs the network
|
||||
dora run dataflow-robot.yml
|
||||
```
|
||||
|
||||
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv`
|
||||
installs each `git:` node into a venv inside its own clone directory, while `dora run
|
||||
--uv` resolves the entrypoint by running `uv run which <name>` from the *dataflow's*
|
||||
directory — which only ever sees the dataflow's own venv. The node is installed where the
|
||||
spawner does not look, and the run fails with `Could not find source path dora-joy within
|
||||
uv`. Building into one activated environment, as above, puts every node's entrypoint
|
||||
somewhere the spawner finds it. The venv needs `pip` for that (`uv pip install pip`),
|
||||
since `uv` does not create one.
|
||||
|
||||
`dataflow.yml` is unaffected and `--uv` is fine there: its only Python node is this repo,
|
||||
which is already in this repo's venv.
|
||||
|
||||
Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults
|
||||
are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so
|
||||
nothing moves until somebody pushes a stick.
|
||||
|
||||
The deadman timings have to stay ordered, each below the one behind it, and the file pins
|
||||
all of them so the chain is visible in one place:
|
||||
|
||||
| Layer | Setting | Default |
|
||||
|---|---|---|
|
||||
| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
|
||||
| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
|
||||
| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
|
||||
| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
|
||||
|
||||
The last one is enforced by `dora-ulite6` itself from v0.3.1 on, not by the controller:
|
||||
the velocity is set once and held, so that node stops the arm rather than letting a
|
||||
controller-side timer do it.
|
||||
|
||||
`dora` type-checks a dataflow before it fetches a `git:` source, so it cannot read the
|
||||
arm's manifest from here. The `output_types:` block on the `ulite6` node repeats what
|
||||
`dora-ulite6` v0.3.1 annotates, which is also what pinning that tag asserts — re-check it
|
||||
when moving to a newer one.
|
||||
|
||||
The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening
|
||||
`http://<that machine>:7000` gets you the same arm the pad drives, under the same lease.
|
||||
|
||||
### Running it with no arm and no browser
|
||||
|
||||
`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in,
|
||||
so you can drive the real thing from a real gamepad with no robot in the room:
|
||||
with **no pad**: pulling `dora-joy` in by `git:` would cost that file the one property it
|
||||
has, which is that it resolves and runs from this checkout alone, with no network and no
|
||||
sibling clones.
|
||||
|
||||
```bash
|
||||
dora run dataflow.yml --uv
|
||||
dora run dataflow.yml --uv # bench only, browser path
|
||||
dora run dataflow-gamepad.yml --uv # the same bench with a real pad
|
||||
```
|
||||
|
||||
`dataflow-gamepad.yml` adds `dora-joy` by `git:` and declares the probe. It needs the
|
||||
network on its first build.
|
||||
|
||||
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
||||
it is sent:
|
||||
|
||||
@@ -209,13 +352,14 @@ rather than `dora run`:
|
||||
|
||||
```bash
|
||||
dora up
|
||||
dora start dataflow.yml --uv --name teleop-bench
|
||||
dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
```
|
||||
|
||||
Then, in another terminal:
|
||||
|
||||
```bash
|
||||
dora topic list -d teleop-bench # every edge in the graph
|
||||
dora topic echo -d teleop-bench joy/joy # what the pad is reporting
|
||||
dora topic echo -d teleop-bench teleop/jog # every velocity, live
|
||||
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
|
||||
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
|
||||
@@ -227,14 +371,63 @@ deadzone `teleop/jog` should sit near the 50 ms tick.
|
||||
|
||||
Tear it down with `dora stop --name teleop-bench && dora down`.
|
||||
|
||||
## The console
|
||||
|
||||
`dora_teleop/web/index.html` is the browser half: two analog sticks, a lift lane and a
|
||||
yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry
|
||||
readout. Plain HTML with no build step — the node serves the file as it stands, and a
|
||||
console you cannot fix without a toolchain is the wrong thing to have between a person and
|
||||
a robot.
|
||||
|
||||
**Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold,
|
||||
with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's
|
||||
jog control on the controller at `:18333`, and deliberately the same directions, because
|
||||
two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a
|
||||
preference.
|
||||
|
||||
| Ring | Sectors | Pills above |
|
||||
|-----------|------------------------------------------------|----------------|
|
||||
| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` |
|
||||
| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` |
|
||||
|
||||
**`Y+` is on the left**, matching xArm Studio and the right-hand rule with `+X` away from
|
||||
the operator and `+Z` up. It is the one direction that does not read as "screen right is
|
||||
right", and it is that way on purpose.
|
||||
|
||||
Directions are held, not analog: speed comes from the dial, and every held direction is a
|
||||
separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one
|
||||
gesture.
|
||||
|
||||
The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same
|
||||
port, so the console is one URL. That work happens on a thread of its own and meets the
|
||||
control loop at two points only — the messages the next tick drains, and the snapshot
|
||||
handed back — because a browser that stops reading must never be able to stall the thread
|
||||
that stops the arm.
|
||||
|
||||
**The browser's deadman is the socket itself.** The last one closing releases the lease
|
||||
and stops the arm, which is why the tab closing mid-jog does not leave it travelling.
|
||||
|
||||
Hold a direction to move, release to stop. The page re-sends its jog every 150 ms against
|
||||
the node's 400 ms deadline, so letting go stops the robot even if the release is lost. It
|
||||
sends a **fraction of full deflection**, never a speed, so it never learns the robot's
|
||||
units; the speed dial sets that fraction. Every control is dimmed and inert unless the
|
||||
console holds the lease, so one that would be refused looks refused.
|
||||
|
||||
If `WEB_PORT` is already taken the node **refuses to start** and says so, rather than
|
||||
carrying on with a console nobody can open.
|
||||
|
||||
`tests/test_console.py` checks the page against `ui.py` — every message type it sends is
|
||||
one the node serves, and every telemetry key it reads is one the node sends. Neither
|
||||
language catches a rename, and the wrong place to discover one is on a machine that moves.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
||||
uv run pytest # 112 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`.
|
||||
Every file here is testable without hardware, because there is no hardware. The pad
|
||||
arrives on `joy` as a plain `Sample`.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# The same bench, with a real gamepad and a probe.
|
||||
#
|
||||
# `dataflow.yml` is what CI validates, and it deliberately has no pad in it: the
|
||||
# pad is `dora-joy`, another repo, and pulling it in by `git:` would cost that
|
||||
# file its one property — that it resolves and runs from this checkout alone.
|
||||
# This file is the other half. It needs the network on first build, and a pad.
|
||||
#
|
||||
# The bench answers services and publishes telemetry but never moves anything.
|
||||
# What this is for is watching the node decide: the lease changing hands, the
|
||||
# deadman firing when you let go, and the arm being stopped when the pad — or
|
||||
# the node reading it — goes away.
|
||||
#
|
||||
# Run it under a coordinator rather than `dora run`, so `dora topic` can read the
|
||||
# wire and so the probe has something to attach to:
|
||||
#
|
||||
# dora up
|
||||
# dora start dataflow-gamepad.yml --uv --name teleop-bench
|
||||
# dora-teleop-probe # in another terminal
|
||||
# dora topic echo -d teleop-bench teleop/jog
|
||||
# dora stop --name teleop-bench && dora down
|
||||
|
||||
strict_types: true
|
||||
|
||||
debug:
|
||||
enable_debug_inspection: true
|
||||
|
||||
nodes:
|
||||
- id: joy
|
||||
git: https://gitea.liria.cl/liria/dora-joy.git
|
||||
tag: v0.1.1
|
||||
build: pip install .
|
||||
path: dora-joy
|
||||
inputs:
|
||||
tick: dora/timer/millis/20
|
||||
outputs:
|
||||
- joy
|
||||
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
|
||||
# loop would work through a backlog of readings that have already been
|
||||
# overtaken. An arm should be driven by the newest one or by none.
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
response: bench/response
|
||||
robot_state: bench/robot_state
|
||||
tcp_pose: bench/tcp_pose
|
||||
joint_pose: bench/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
# This one is about the pad, so the console stays shut. Set a port to
|
||||
# bring it up alongside and watch the lease move between the two.
|
||||
WEB_PORT: "0"
|
||||
|
||||
# Declared, not spawned: `dora-teleop-probe` attaches to this id when you run
|
||||
# it, and the dataflow runs perfectly well when nobody does.
|
||||
- id: teleop-probe
|
||||
path: dynamic
|
||||
inputs:
|
||||
joy: joy/joy
|
||||
|
||||
- id: bench
|
||||
path: examples/bench.py
|
||||
inputs:
|
||||
tick: dora/timer/millis/200
|
||||
jog: teleop/jog
|
||||
request: teleop/request
|
||||
ui_state: teleop/ui_state
|
||||
outputs:
|
||||
- response
|
||||
- robot_state
|
||||
- tcp_pose
|
||||
- joint_pose
|
||||
@@ -0,0 +1,111 @@
|
||||
# Joystick control of a real arm. Three nodes, no bench and no browser.
|
||||
#
|
||||
# This is the one that moves a robot. `dataflow.yml` and `dataflow-gamepad.yml`
|
||||
# drive a stand-in; this one drives `dora-ulite6`, so everything the pad does
|
||||
# here happens in the room. Read `The gamepad` in README.md before picking it up
|
||||
# — Back is the E-STOP.
|
||||
#
|
||||
# Both sibling nodes are pulled by `git:`, so this runs from a fresh clone with
|
||||
# no other checkouts. It needs the network on its first build, and an arm.
|
||||
#
|
||||
# source .venv/bin/activate
|
||||
# dora build dataflow-robot.yml
|
||||
# dora run dataflow-robot.yml
|
||||
#
|
||||
# Not `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone and
|
||||
# then looks for its entrypoint in this dataflow's venv instead. README.md has the
|
||||
# detail. `dataflow.yml` has no `git:` nodes and is fine with --uv.
|
||||
#
|
||||
# Set ROBOT_IP and TOOL_TYPE below to match the arm in front of you. The
|
||||
# defaults are `dora-ulite6`'s own: 192.168.1.192 and a vacuum tool.
|
||||
#
|
||||
# The web console comes with the node: it serves the page and holds the
|
||||
# browsers' sockets itself, so `http://<this machine>:7000` is a second way to
|
||||
# drive the same arm, with the same lease deciding who gets it. Set WEB_PORT to
|
||||
# 0 below for a pad-only run that opens no port at all.
|
||||
|
||||
strict_types: true
|
||||
|
||||
# Lets `dora topic echo` read what is on the wire — the velocities going to the
|
||||
# arm, above all. Run under `dora start` rather than `dora run` to use it.
|
||||
debug:
|
||||
enable_debug_inspection: true
|
||||
|
||||
nodes:
|
||||
- id: joy
|
||||
git: https://gitea.liria.cl/liria/dora-joy.git
|
||||
tag: v0.1.1
|
||||
build: pip install .
|
||||
path: dora-joy
|
||||
inputs:
|
||||
tick: dora/timer/millis/20
|
||||
outputs:
|
||||
- joy
|
||||
|
||||
- id: teleop
|
||||
path: dora-teleop
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
|
||||
# loop would work through a backlog of readings that have already been
|
||||
# overtaken. An arm should be driven by the newest one or by none.
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
|
||||
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
|
||||
# inside it, and a backstop that fires first backs up nothing.
|
||||
JOY_TTL_S: "0.2"
|
||||
JOG_TTL_S: "0.4"
|
||||
# The console, on this machine. It is bound to every interface because
|
||||
# the operator is rarely sitting at the box wired to the arm.
|
||||
WEB_HOST: "0.0.0.0"
|
||||
WEB_PORT: "7000"
|
||||
|
||||
- id: ulite6
|
||||
git: https://gitea.liria.cl/liria/dora-ulite6.git
|
||||
tag: v0.3.1
|
||||
build: pip install .
|
||||
path: dora-ulite6
|
||||
inputs:
|
||||
tick: dora/timer/millis/100
|
||||
request: teleop/request
|
||||
jog: teleop/jog
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
# dora type-checks before it fetches a `git:` source, so it cannot read the
|
||||
# arm's own manifest from here. These three repeat what `dora-ulite6`
|
||||
# v0.3.1 annotates; `response` and `motion_state` stay untyped there and so
|
||||
# stay untyped here. Re-check them when moving to a new tag.
|
||||
output_types:
|
||||
joint_pose: std/core/v1/Float32
|
||||
tcp_pose: std/core/v1/Float32
|
||||
robot_state: std/core/v1/String
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
TOOL_TYPE: "vacuum"
|
||||
# The outermost deadman, enforced by the controller whether or not any of
|
||||
# this is alive. Every timer above must stay below it.
|
||||
VELOCITY_COMMAND_TTL_S: "0.5"
|
||||
# The pad does not home. Nothing moves until somebody pushes a stick.
|
||||
INIT_MODE: "none"
|
||||
+12
-5
@@ -4,10 +4,13 @@
|
||||
# browser, both built from this checkout, so `dora validate` resolves every
|
||||
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
|
||||
#
|
||||
# There is **no gamepad here**, because the pad is another node and wiring it in
|
||||
# by `git:` would cost this file the one property it has. For a real pad on a
|
||||
# real bench, use `dataflow-gamepad.yml`.
|
||||
#
|
||||
# This is **not** the real stack. The bench answers services and publishes
|
||||
# telemetry but never moves anything — it exists so the node can be driven from
|
||||
# a real gamepad with no arm in the room. README.md carries the three-node
|
||||
# wiring a consumer actually uses.
|
||||
# telemetry but never moves anything. README.md carries the four-node wiring a
|
||||
# consumer actually uses.
|
||||
|
||||
strict_types: true
|
||||
|
||||
@@ -23,18 +26,23 @@ nodes:
|
||||
build: pip install .
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
clients: bench/clients
|
||||
response: bench/response
|
||||
robot_state: bench/robot_state
|
||||
tcp_pose: bench/tcp_pose
|
||||
joint_pose: bench/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
env:
|
||||
SPEED_MM_S: "60"
|
||||
SPEED_DEG_S: "30"
|
||||
# The console is served by this node. CI runs this dataflow, so it binds
|
||||
# to localhost and stays off any interface a colleague could reach.
|
||||
WEB_HOST: "127.0.0.1"
|
||||
WEB_PORT: "7000"
|
||||
|
||||
- id: bench
|
||||
path: examples/bench.py
|
||||
@@ -48,4 +56,3 @@ nodes:
|
||||
- robot_state
|
||||
- tcp_pose
|
||||
- joint_pose
|
||||
- clients
|
||||
|
||||
+120
-35
@@ -5,11 +5,12 @@
|
||||
# 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
|
||||
# `joy`, `jog`, `request`, `response`, `motion_state` and `ui_state` 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.
|
||||
# pattern-correlated and skips type checks on it, several Arrow schemas
|
||||
# legitimately flow over each — one per service, one per console message — and
|
||||
# dora 1.0 ships no standard joystick type. Annotating them would claim a
|
||||
# contract neither side keeps.
|
||||
|
||||
apiVersion: 1
|
||||
name: dora-teleop
|
||||
@@ -29,25 +30,30 @@ dora: ">=1.0"
|
||||
inputs:
|
||||
tick:
|
||||
description: >-
|
||||
Drives everything time-based: re-sending the held velocity, expiring a
|
||||
Drives everything time-based: noticing a changed velocity, repeating the
|
||||
held one, 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:
|
||||
joy:
|
||||
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.
|
||||
One gamepad reading from `dora-joy`, as the Arrow struct
|
||||
`{axes: list<float32>, buttons: list<bool>, present: bool}`. Raw SDL — the
|
||||
deadzone, the response curve and the base-frame mapping all happen here.
|
||||
Wire it with `queue_size: 1`: `dora-joy` ticks faster than this node does,
|
||||
and a backlog of old readings is the last thing an arm should be driven
|
||||
from.
|
||||
|
||||
**`present: false` stops the arm at once**, without waiting out any timer,
|
||||
because an unplugged pad is not a centred pad. So does the stream going
|
||||
quiet for longer than `JOY_TTL_S` — `dora-joy` publishes on every tick
|
||||
whether or not the pad moved, so silence means that node died, and a dead
|
||||
pad node is indistinguishable from an unplugged pad as far as the arm is
|
||||
concerned.
|
||||
|
||||
Leave it unwired for a browser-only console. The node says so and starts.
|
||||
response:
|
||||
required: false
|
||||
description: >-
|
||||
@@ -79,23 +85,69 @@ inputs:
|
||||
type: std/core/v1/Float32
|
||||
|
||||
outputs:
|
||||
twist:
|
||||
description: >-
|
||||
**The standard motion port.** A velocity, as
|
||||
`{linear: [x, y, z], angular: [x, y, z], frame}` in **SI** — metres per
|
||||
second and radians per second — in the frame named by `frame`, currently
|
||||
always `base`.
|
||||
|
||||
Sent when the velocity **changes**, and otherwise repeated every
|
||||
`JOG_KEEPALIVE_S` so the consumer's own expiry never fires mid-motion — a
|
||||
velocity is a set-point a consumer holds, not a frame in a sequence, and a
|
||||
consumer that re-plans on every message moves in steps if it is fed one
|
||||
every tick. A stop is a change: exactly one all-zero Twist, not repeatedly,
|
||||
because a stream of zeroes keeps a consumer believing a jog is live. There
|
||||
is no answer to a Twist, ever: it expires on the consumer's own TTL.
|
||||
|
||||
Both control sources are normalised to a fraction of full speed before
|
||||
anything is scaled, so a gamepad and the web console asking for the same
|
||||
deflection produce byte-identical Twists.
|
||||
command:
|
||||
description: >-
|
||||
**The standard discrete port.** One thing to do, as `{action, value}`.
|
||||
`action` is robot-agnostic — `gripper_open`, `gripper_close`, `stop`,
|
||||
`home`, `clear`, `freedrive` — so a consumer that swaps a vacuum for a
|
||||
two-finger gripper needs no change here. `value` carries the argument
|
||||
where a verb has one (`freedrive` uses 1.0 and 0.0) and is null otherwise.
|
||||
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.
|
||||
**Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units:
|
||||
`{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame.
|
||||
Published alongside `twist` while that node learns the standard one.
|
||||
|
||||
Wire `twist` **or** `jog`, never both — they carry the same intent, and a
|
||||
consumer reading both would be driven twice.
|
||||
request:
|
||||
description: >-
|
||||
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
|
||||
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
|
||||
metadata.
|
||||
**Deprecated; use `command`.** The same verbs under the service names
|
||||
`dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`,
|
||||
`home` — with `request_id` in the metadata. `move_to` and `get_state` have
|
||||
no standard equivalent yet and go only here.
|
||||
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`.
|
||||
The console's telemetry snapshot, as a JSON string. Also carries error
|
||||
toasts. Every key in it is read by name in the console's page, so the two
|
||||
change together.
|
||||
|
||||
The browsers get this straight from the node's own server and do not read
|
||||
this port; it is published for anyone else watching — `dora topic echo`,
|
||||
a recorder, a second screen — and costs nothing when nobody is.
|
||||
|
||||
# Reserved, not implemented. A joint-space robot — a lerobot SO-101, say — takes
|
||||
# joint targets rather than a Cartesian velocity, and no amount of scaling turns
|
||||
# one into the other: it needs inverse kinematics, which is another node's job.
|
||||
# When such a consumer exists, this is the port it wires, mirroring ROS's
|
||||
# control_msgs/JointJog beside geometry_msgs/Twist:
|
||||
#
|
||||
# joint_jog:
|
||||
# description: >-
|
||||
# A joint-space velocity, as `{names: [str], velocities: [float]}`, the
|
||||
# joints named rather than positional so a consumer cannot mistake the
|
||||
# order.
|
||||
#
|
||||
# It is written down rather than built because a port nothing wires is worse
|
||||
# than no port: it reads as supported and has never once run.
|
||||
|
||||
env:
|
||||
SPEED_MM_S:
|
||||
@@ -124,10 +176,6 @@ env:
|
||||
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
|
||||
@@ -135,6 +183,24 @@ env:
|
||||
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.
|
||||
JOG_KEEPALIVE_S:
|
||||
type: float
|
||||
default: 0.2
|
||||
description: >-
|
||||
How often to repeat a velocity that has not changed. Must be **above 0 and
|
||||
below JOG_TTL_S**, which in turn sits below the arm's own
|
||||
VELOCITY_COMMAND_TTL_S — so the repeat outpaces the expiry it exists to
|
||||
beat. Raising it sends fewer messages and asks the controller to re-plan
|
||||
less; too high and the arm stops between repeats. A change is never
|
||||
delayed by it. Defaults to 0.2.
|
||||
JOY_TTL_S:
|
||||
type: float
|
||||
default: 0.2
|
||||
description: >-
|
||||
How long the `joy` stream may go quiet before the pad reads as gone and the
|
||||
arm is stopped. Must be **below** JOG_TTL_S. `dora-joy` publishes on every
|
||||
tick, so this is really "how many missed readings to tolerate" — at a 20 ms
|
||||
joy tick, the default is ten. Defaults to 0.2.
|
||||
GAMEPAD_IDLE_RELEASE_S:
|
||||
type: float
|
||||
default: 2.0
|
||||
@@ -147,23 +213,42 @@ env:
|
||||
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.
|
||||
WEB_HOST:
|
||||
type: string
|
||||
default: "0.0.0.0"
|
||||
description: >-
|
||||
The address the built-in web console binds to. Defaults to `0.0.0.0`,
|
||||
because the operator is rarely sitting at the machine wired to the arm.
|
||||
Set `127.0.0.1` where they are, or where the network is not yours.
|
||||
WEB_PORT:
|
||||
type: int
|
||||
default: 7000
|
||||
description: >-
|
||||
The port the console is served on — the page and the browsers' WebSocket
|
||||
both, so there is one URL and nothing to keep in step. **Set it to 0 to
|
||||
open no port at all**, which is what a pad-only dataflow wants. Defaults
|
||||
to 7000.
|
||||
|
||||
example: |
|
||||
- id: teleop
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
||||
tag: v0.1.0
|
||||
tag: v0.3.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
tick: dora/timer/millis/50
|
||||
ui_command: ws-transceiver/data_out
|
||||
clients: ws-transceiver/clients
|
||||
joy:
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
response: ulite6/response
|
||||
motion_state: ulite6/motion_state
|
||||
robot_state: ulite6/robot_state
|
||||
tcp_pose: ulite6/tcp_pose
|
||||
joint_pose: ulite6/joint_pose
|
||||
outputs:
|
||||
- twist
|
||||
- command
|
||||
- jog
|
||||
- request
|
||||
- ui_state
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
because the web console is built on it: its controls are 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.
|
||||
|
||||
|
||||
+76
-11
@@ -31,20 +31,66 @@ def env_int(name: str, default: int) -> int:
|
||||
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleConfig:
|
||||
"""Where the built-in web console listens.
|
||||
|
||||
The node serves the page and holds the browsers' sockets itself, so there is
|
||||
one address and one port rather than a bridge node and two. `0.0.0.0` is the
|
||||
default because the operator is rarely sitting at the machine driving the
|
||||
arm; bind it to `127.0.0.1` where that is not true.
|
||||
"""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7000
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether to open the port at all. Port 0 means a pad-only dataflow."""
|
||||
return self.port != 0
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ConsoleConfig":
|
||||
"""Build the console's address from the environment."""
|
||||
port = env_int("WEB_PORT", 7000)
|
||||
if not 0 <= port <= 65535:
|
||||
raise ValueError(f"WEB_PORT must be a port number, not {port}")
|
||||
return cls(host=os.environ.get("WEB_HOST", "").strip() or "0.0.0.0", port=port)
|
||||
|
||||
|
||||
@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.
|
||||
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
|
||||
worth acting on; `jog_keepalive_s` is how often an unchanged velocity is
|
||||
repeated; `jog_ttl_s` is how long this node keeps driving without hearing
|
||||
from the source; and behind all of them sits the arm node's own
|
||||
`VELOCITY_COMMAND_TTL_S`, enforced there whether or not this process is
|
||||
alive.
|
||||
|
||||
Keep them in that order — ours shorter than the controller's — or the
|
||||
Keep them in that order — each shorter than the one behind it — or a
|
||||
backstop fires before the thing it is backing up.
|
||||
"""
|
||||
|
||||
jog_ttl_s: float = 0.4
|
||||
# How often to repeat a velocity that has not changed. A velocity is a
|
||||
# set-point, not a sequence: the arm holds the last one until its own expiry
|
||||
# or a new one, so repeating it 20 times a second only makes the controller
|
||||
# re-plan the same motion 20 times, which an operator feels as stepping.
|
||||
# Low enough that the arm's VELOCITY_COMMAND_TTL_S never fires mid-motion,
|
||||
# and no lower. A change — a new direction, or the stop — never waits for it.
|
||||
#
|
||||
# It is checked on the tick, so the period actually achieved is this rounded
|
||||
# *up* to the next tick: 0.2 with a 50 ms tick repeats every 250 ms, because
|
||||
# the tick at 200 ms lands a hair early and does not qualify. Budget against
|
||||
# that number, not this one.
|
||||
jog_keepalive_s: float = 0.2
|
||||
# How long the `joy` stream may go quiet before the pad reads as gone. The
|
||||
# pad lives in another node now, and that node dying looks like silence
|
||||
# rather than like an unplugged pad, so silence has to mean the same thing.
|
||||
# `dora-joy` publishes every tick, so it means nothing else.
|
||||
joy_ttl_s: float = 0.2
|
||||
# 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
|
||||
@@ -57,6 +103,8 @@ class DeadmanConfig:
|
||||
"""Build the timings from the environment."""
|
||||
return cls(
|
||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
||||
jog_keepalive_s=env_float("JOG_KEEPALIVE_S", 0.2),
|
||||
joy_ttl_s=env_float("JOY_TTL_S", 0.2),
|
||||
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
||||
)
|
||||
@@ -66,9 +114,9 @@ class DeadmanConfig:
|
||||
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.
|
||||
# Full-deflection speeds. Both sources are analog — a gamepad stick and the
|
||||
# console's on-screen ones — so these are the maximum and anything less
|
||||
# moves proportionally slower.
|
||||
speed_mm_s: float
|
||||
speed_deg_s: float
|
||||
# Stick travel to ignore. A worn stick that does not centre would otherwise
|
||||
@@ -77,8 +125,8 @@ class Config:
|
||||
# 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)
|
||||
console: ConsoleConfig = field(default_factory=ConsoleConfig)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
@@ -89,11 +137,28 @@ class Config:
|
||||
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}")
|
||||
deadman = DeadmanConfig.from_env()
|
||||
# The layers have to fire outermost last, and each one is a backstop for
|
||||
# the one inside it. Behind both of these sits the arm node's own
|
||||
# VELOCITY_COMMAND_TTL_S, which no setting here can see.
|
||||
if deadman.joy_ttl_s >= deadman.jog_ttl_s:
|
||||
raise ValueError(
|
||||
f"JOY_TTL_S must be below JOG_TTL_S ({deadman.jog_ttl_s}), not {deadman.joy_ttl_s}"
|
||||
)
|
||||
# The repeat has to be quicker than the arm's own velocity expiry or the
|
||||
# arm stops between repeats. That expiry cannot be read from here, but
|
||||
# JOG_TTL_S is already required to sit below it, so staying under
|
||||
# JOG_TTL_S keeps the repeat under both.
|
||||
if not 0.0 < deadman.jog_keepalive_s < deadman.jog_ttl_s:
|
||||
raise ValueError(
|
||||
f"JOG_KEEPALIVE_S must be above 0 and below JOG_TTL_S ({deadman.jog_ttl_s}), "
|
||||
f"not {deadman.jog_keepalive_s}"
|
||||
)
|
||||
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(),
|
||||
deadman=deadman,
|
||||
console=ConsoleConfig.from_env(),
|
||||
)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""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,83 @@
|
||||
"""The gamepad, as it arrives from `dora-joy`.
|
||||
|
||||
The pad itself lives in another node now. What reaches this one is a plain
|
||||
reading on the `joy` input, and this module is the seam the loop reads it
|
||||
through: :class:`Sample` is what the rest of the node sees, exactly as it did
|
||||
when the pad was read in-process.
|
||||
|
||||
**The safety-critical part is :meth:`JoyInput.read`.** Moving the pad into
|
||||
another process added a way for it to go away that did not exist before — that
|
||||
process dying — and from here that looks like silence rather than like an
|
||||
unplugged pad. So silence past `JOY_TTL_S` reads as *absent*, which is the same
|
||||
answer, because a reading nobody is refreshing is not evidence of anything.
|
||||
`dora-joy` publishes on every tick whether or not the pad moved, so silence
|
||||
means one thing only.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
ABSENT = Sample()
|
||||
|
||||
|
||||
def decode(event: dict) -> Sample:
|
||||
"""Read one `joy` message into a reading.
|
||||
|
||||
A message this node cannot make sense of reads as absent rather than as
|
||||
centred, for the same reason an unplugged pad does.
|
||||
"""
|
||||
value = event["value"]
|
||||
if not len(value):
|
||||
return ABSENT
|
||||
row = value[0].as_py()
|
||||
return Sample(
|
||||
axes=list(row.get("axes") or []),
|
||||
buttons=[bool(down) for down in row.get("buttons") or []],
|
||||
present=bool(row.get("present")),
|
||||
)
|
||||
|
||||
|
||||
class JoyInput:
|
||||
"""The latest reading, and how long ago it arrived."""
|
||||
|
||||
def __init__(self, ttl_s: float):
|
||||
self.ttl_s = ttl_s
|
||||
self._sample = ABSENT
|
||||
self._seen_at = 0.0
|
||||
|
||||
def accept(self, sample: Sample, moment: float) -> None:
|
||||
"""Take one reading off the wire."""
|
||||
self._sample = sample
|
||||
self._seen_at = moment
|
||||
|
||||
def read(self, moment: float) -> Sample:
|
||||
"""Return the current reading, or absence if nobody is still sending one.
|
||||
|
||||
A stale reading is not a reading. `dora-joy` publishes every tick, so
|
||||
silence means it died, and a dead pad node is indistinguishable from an
|
||||
unplugged pad as far as the arm is concerned — which is the only safe
|
||||
way to read it.
|
||||
"""
|
||||
if moment - self._seen_at > self.ttl_s:
|
||||
return ABSENT
|
||||
return self._sample
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
|
||||
return time.monotonic()
|
||||
+210
-93
@@ -2,15 +2,22 @@
|
||||
|
||||
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.
|
||||
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
|
||||
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
|
||||
to, :mod:`dora_teleop.ui` speaks the console's language and
|
||||
:mod:`dora_teleop.server` serves it. **No hardware is touched here at all** —
|
||||
the pad is `dora-joy` and the arm is `dora-ulite6`.
|
||||
|
||||
**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.
|
||||
|
||||
What goes out is the *change*: a velocity is a set-point the arm holds, not a
|
||||
frame in a sequence, so an unchanged one is repeated only as often as the arm's
|
||||
own expiry demands. A change — a new direction, or the stop — is never made to
|
||||
wait for that timer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -20,15 +27,37 @@ import time
|
||||
import pyarrow as pa
|
||||
from dora import Node
|
||||
|
||||
from dora_teleop import mapping, ui
|
||||
from dora_teleop import joy, mapping, message, server, 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.joy import JoyInput, now
|
||||
from dora_teleop.log import log
|
||||
from dora_teleop.mapping import LAYOUT, pressed
|
||||
|
||||
REQUEST_ID = "request_id"
|
||||
STOP_VELOCITY = [0.0] * 6
|
||||
# Still, in the normalised units both sources speak.
|
||||
STOP_AXES = [0.0] * 6
|
||||
|
||||
# The verbs this node publishes on `command`. Robot-agnostic on purpose: a
|
||||
# consumer that grows a two-finger gripper where the last one had a vacuum
|
||||
# should need no change here, and neither should this node.
|
||||
GRIPPER_CLOSE = "gripper_close"
|
||||
GRIPPER_OPEN = "gripper_open"
|
||||
STOP = "stop"
|
||||
HOME = "home"
|
||||
CLEAR = "clear"
|
||||
FREEDRIVE = "freedrive"
|
||||
|
||||
# The same verbs as `dora-ulite6` names them, for the deprecated `request` port.
|
||||
# This table is the whole of what used to be spread through the loop, and it is
|
||||
# what a robot speaking the standard lets us delete.
|
||||
LEGACY_REQUEST = {
|
||||
GRIPPER_CLOSE: {"service": "grasp"},
|
||||
GRIPPER_OPEN: {"service": "release"},
|
||||
STOP: {"service": "stop"},
|
||||
CLEAR: {"service": "clear"},
|
||||
HOME: {"service": "home", "wait": False},
|
||||
}
|
||||
|
||||
|
||||
class Teleop:
|
||||
@@ -37,16 +66,27 @@ class Teleop:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
|
||||
self.joy = JoyInput(config.deadman.joy_ttl_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
|
||||
# What the browser last asked for, and when. A jog is a stream; what
|
||||
# matters is whether the stream is still arriving. Normalised, like the
|
||||
# pad's — the console does not know the robot's units.
|
||||
self.web_axes = STOP_AXES
|
||||
self.web_seen_at = 0.0
|
||||
# How many browsers are on the console right now, read from the server
|
||||
# every tick. The last one leaving has to stop the arm, and a socket
|
||||
# that went away sends nothing to say so.
|
||||
self.clients = 0
|
||||
self.console: server.Console | None = None
|
||||
# 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
|
||||
# The last velocity actually put on the wire, and when. `_drive` sends
|
||||
# only when what it wants differs from this, so the arm is told about
|
||||
# changes rather than told the same thing 20 times a second.
|
||||
self.sent_axes = STOP_AXES
|
||||
self.sent_at = 0.0
|
||||
self.buttons: list[bool] = []
|
||||
self.requests = 0
|
||||
self.polled_at = 0.0
|
||||
@@ -58,7 +98,7 @@ class Teleop:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Connect to the dataflow, start reading the pad, and run until dora stops us."""
|
||||
"""Connect to the dataflow and run until dora stops us."""
|
||||
args = _parse_args()
|
||||
config = Config.from_env()
|
||||
|
||||
@@ -66,8 +106,9 @@ def main() -> None:
|
||||
# dora spawns it, the id from the dataflow wins.
|
||||
node = Node(args.name)
|
||||
state = Teleop(config)
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
if config.console.enabled:
|
||||
state.console = server.Console(config.console.host, config.console.port)
|
||||
state.console.start()
|
||||
|
||||
try:
|
||||
for event in node:
|
||||
@@ -75,14 +116,15 @@ def main() -> None:
|
||||
raise RuntimeError(event["error"])
|
||||
if event["type"] != "INPUT":
|
||||
continue
|
||||
_handle(node, state, gamepad, event)
|
||||
_handle(node, state, 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()
|
||||
_send_motion(node, state, STOP_AXES)
|
||||
if state.console is not None:
|
||||
state.console.stop()
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
@@ -96,15 +138,13 @@ def _parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
||||
def _handle(node: Node, state: Teleop, 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)
|
||||
_tick(node, state)
|
||||
elif event_id == "joy":
|
||||
state.joy.accept(joy.decode(event), now())
|
||||
elif event_id == "robot_state":
|
||||
state.view.state = event["value"][0].as_py()
|
||||
elif event_id == "tcp_pose":
|
||||
@@ -122,21 +162,34 @@ def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None:
|
||||
# --- the tick, where the arm is actually driven ---
|
||||
|
||||
|
||||
def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None:
|
||||
def _tick(node: Node, state: Teleop) -> None:
|
||||
"""Decide what the arm should be doing, and say so."""
|
||||
moment = now()
|
||||
sample = gamepad.read()
|
||||
|
||||
_read_buttons(node, state, sample)
|
||||
# Before anything else: what the browser asked for since the last tick is
|
||||
# part of this tick's picture, and the client count it implies is what the
|
||||
# web deadman reads below.
|
||||
_drain_console(node, state)
|
||||
# Absent covers three things now: no pad plugged in, a pad unplugged
|
||||
# mid-jog, and `dora-joy` no longer sending. The arm cannot tell them apart
|
||||
# and does not need to.
|
||||
sample = state.joy.read(moment)
|
||||
|
||||
# Who owns the arm is settled before anything they pressed is acted on.
|
||||
# The other order drops the first press after the pad has been idle: the
|
||||
# lease is not held yet when the buttons are read, and by the next tick the
|
||||
# button is held rather than newly down, so the edge is gone for good.
|
||||
#
|
||||
# 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:
|
||||
active = mapping.is_active(sample)
|
||||
if active and state.arbiter.owner is None:
|
||||
state.arbiter.take(GAMEPAD, moment)
|
||||
if sample.active:
|
||||
if active:
|
||||
state.arbiter.note_activity(GAMEPAD, moment)
|
||||
state.arbiter.expire_idle_gamepad(moment)
|
||||
|
||||
_read_buttons(node, state, sample)
|
||||
|
||||
_drive(node, state, sample, moment)
|
||||
_poll_state(node, state, moment)
|
||||
_publish(node, state)
|
||||
@@ -145,50 +198,60 @@ def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None:
|
||||
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)
|
||||
Changes go out at once; an unchanged velocity is repeated only on
|
||||
`jog_keepalive_s`, which exists solely so the arm's own velocity expiry
|
||||
never fires mid-motion. Repeating it every tick instead made the controller
|
||||
re-plan the same motion twenty times a second, which an operator feels as
|
||||
the arm moving in steps rather than travelling.
|
||||
|
||||
if mapping.is_moving(velocity):
|
||||
state.jogging = True
|
||||
_send_jog(node, velocity)
|
||||
The stop is a change like any other, so releasing a button is acted on the
|
||||
tick it happens and never waits for the repeat.
|
||||
"""
|
||||
axes = _wanted_axes(state, sample, moment)
|
||||
|
||||
if axes != state.sent_axes:
|
||||
# Includes the stop: going to all-zero is a change, sent once, and the
|
||||
# `jogging` flag then keeps it from being repeated. A stream of zeroes
|
||||
# would hold the arm node's `robot_state` at `moving` forever.
|
||||
state.jogging = mapping.is_moving(axes)
|
||||
_send_motion(node, state, axes)
|
||||
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)
|
||||
if state.jogging and moment - state.sent_at >= state.config.deadman.jog_keepalive_s:
|
||||
_send_motion(node, state, axes)
|
||||
|
||||
|
||||
def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]:
|
||||
"""Return what the arm should be doing, which is usually nothing.
|
||||
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
|
||||
"""Return what the robot should be doing, normalised, 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.
|
||||
"""
|
||||
# First, and above the lease. A stop somebody had to reach for is not undone
|
||||
# by the next thumb movement: it holds until it is deliberately cleared.
|
||||
if state.view.estop:
|
||||
return STOP_AXES
|
||||
|
||||
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)
|
||||
return STOP_AXES
|
||||
return mapping.from_pad(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
|
||||
return STOP_AXES
|
||||
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
|
||||
return STOP_VELOCITY
|
||||
return state.web_velocity
|
||||
return STOP_AXES
|
||||
return state.web_axes
|
||||
|
||||
return STOP_VELOCITY
|
||||
return STOP_AXES
|
||||
|
||||
|
||||
def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||
@@ -204,20 +267,23 @@ def _read_buttons(node: Node, state: Teleop, sample) -> None:
|
||||
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.
|
||||
# Neither the stop nor its release is gated on the lease. Somebody reaching
|
||||
# for the stop is the one moment when asking who owns the arm is the wrong
|
||||
# question, and a latch that cannot be released without first taking the arm
|
||||
# back is a trap — an idle pad hands the lease away two seconds after the
|
||||
# stop it just made. The console's RESET is ungated for the same reason.
|
||||
if tapped(LAYOUT.estop):
|
||||
_estop(node, state)
|
||||
if tapped(LAYOUT.clear):
|
||||
_clear(node, state)
|
||||
if not state.arbiter.may_drive(GAMEPAD):
|
||||
return
|
||||
if tapped(LAYOUT.grasp):
|
||||
_request(node, state, {"service": "grasp"})
|
||||
_act(node, state, GRIPPER_CLOSE)
|
||||
if tapped(LAYOUT.release):
|
||||
_request(node, state, {"service": "release"})
|
||||
if tapped(LAYOUT.clear):
|
||||
_request(node, state, {"service": "clear"})
|
||||
_act(node, state, GRIPPER_OPEN)
|
||||
if tapped(LAYOUT.freedrive):
|
||||
_request(node, state, {"service": "freedrive", "enabled": not state.view.freedrive_on})
|
||||
_freedrive(node, state, not state.view.freedrive_on)
|
||||
|
||||
|
||||
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
||||
@@ -236,17 +302,17 @@ def _poll_state(node: Node, state: Teleop, moment: float) -> None:
|
||||
# --- 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")
|
||||
def _drain_console(node: Node, state: Teleop) -> None:
|
||||
"""Act on everything the browsers sent, then note how many are still there."""
|
||||
if state.console is None:
|
||||
return
|
||||
for payload in state.console.take():
|
||||
_accept_ui_command(node, state, payload)
|
||||
_set_clients(state, state.console.clients)
|
||||
|
||||
|
||||
def _accept_ui_command(node: Node, state: Teleop, payload: dict) -> None:
|
||||
"""Take one console message and do what it says."""
|
||||
command = ui.command_of(payload)
|
||||
if command is None:
|
||||
return
|
||||
@@ -259,30 +325,26 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
|
||||
_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"})
|
||||
_clear(node, state)
|
||||
return
|
||||
|
||||
if not state.arbiter.may_drive(WEB):
|
||||
_post_ui(node, ui.error("take control first"))
|
||||
_post_ui(node, state, ui.error("take control first"))
|
||||
return
|
||||
|
||||
if command.type == "jog":
|
||||
state.web_velocity = command.velocity
|
||||
state.web_axes = mapping.from_console(command.axes)
|
||||
state.web_seen_at = moment
|
||||
state.arbiter.note_activity(WEB, moment)
|
||||
elif command.type == "jog_stop":
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_axes = STOP_AXES
|
||||
state.web_seen_at = moment
|
||||
elif command.type == "home":
|
||||
_request(node, state, {"service": "home", "wait": False})
|
||||
_act(node, state, HOME)
|
||||
elif command.type == "gripper":
|
||||
_request(node, state, {"service": "grasp" if command.enabled else "release"})
|
||||
_act(node, state, GRIPPER_CLOSE if command.enabled else GRIPPER_OPEN)
|
||||
elif command.type == "freedrive":
|
||||
_request(node, state, {"service": "freedrive", "enabled": command.enabled})
|
||||
_freedrive(node, state, command.enabled)
|
||||
elif command.type == "move":
|
||||
_request(node, state, {"service": "move_to", "wait": False, **command.row})
|
||||
|
||||
@@ -291,19 +353,18 @@ 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
|
||||
state.web_axes = STOP_AXES
|
||||
return
|
||||
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
|
||||
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
|
||||
_post_ui(node, state, ui.error(f"the {state.arbiter.owner} has the arm"))
|
||||
|
||||
|
||||
def _accept_clients(state: Teleop, event: dict) -> None:
|
||||
def _set_clients(state: Teleop, count: int) -> 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.clients = count
|
||||
if count == 0:
|
||||
state.arbiter.release(WEB)
|
||||
state.web_velocity = STOP_VELOCITY
|
||||
state.web_axes = STOP_AXES
|
||||
|
||||
|
||||
# --- what the arm says back ---
|
||||
@@ -318,14 +379,14 @@ def _accept_response(node: Node, state: Teleop, event: dict) -> None:
|
||||
if not row.get("ok", True):
|
||||
message = row.get("message", "")
|
||||
log(f"{row.get('service')} failed: {message}")
|
||||
_post_ui(node, ui.error(message))
|
||||
_post_ui(node, state, 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")))
|
||||
_post_ui(node, state, ui.error(row.get("message", "the motion failed")))
|
||||
|
||||
|
||||
# --- talking to the dataflow ---
|
||||
@@ -336,14 +397,63 @@ def _estop(node: Node, state: Teleop) -> None:
|
||||
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"})
|
||||
state.web_axes = STOP_AXES
|
||||
_send_motion(node, state, STOP_AXES)
|
||||
_act(node, state, 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 _clear(node: Node, state: Teleop) -> None:
|
||||
"""Release the latch and clear the controller's error, in that order.
|
||||
|
||||
Both, or one of the two is still saying stop: the latch is this node's
|
||||
business and the error is the arm's. The console's RESET and the pad's
|
||||
`clear` button are the same act and go through here, so a pad-only
|
||||
dataflow is not left with a latch nothing can release.
|
||||
"""
|
||||
log("cleared")
|
||||
state.view.estop = False
|
||||
_act(node, state, CLEAR)
|
||||
|
||||
|
||||
def _send_motion(node: Node, state: Teleop, axes: list[float]) -> None:
|
||||
"""Send one motion command, on the standard port and the deprecated one.
|
||||
|
||||
There is no answer to either, ever — a jog is a stream that expires on the
|
||||
consumer's own TTL, so the sender keeps sending and nobody waits.
|
||||
|
||||
`twist` is the standard: SI, framed, what a robot node should take. `jog`
|
||||
is `dora-ulite6`'s mm/s and deg/s, published alongside until that node
|
||||
learns the standard. Both carry the same intent, so wiring either is safe;
|
||||
wiring both would drive the arm twice.
|
||||
|
||||
This always sends. The decision not to is `_drive`'s, so that the e-stop and
|
||||
the shutdown stop — neither of which goes through it — can never be
|
||||
suppressed by having sent the same thing already.
|
||||
"""
|
||||
config = state.config
|
||||
node.send_output("twist", message.pack_twist(mapping.twist(axes, config)))
|
||||
node.send_output("jog", message.pack_jog(mapping.scale(axes, config)))
|
||||
state.sent_axes = list(axes)
|
||||
state.sent_at = now()
|
||||
|
||||
|
||||
def _act(node: Node, state: Teleop, action: str, value: float | None = None) -> None:
|
||||
"""Ask for one discrete thing, on the standard port and the deprecated one.
|
||||
|
||||
The verb is robot-agnostic; `LEGACY_REQUEST` is the only place that knows
|
||||
what `dora-ulite6` calls it. A robot speaking the standard needs no entry
|
||||
there, which is how that table eventually goes away.
|
||||
"""
|
||||
node.send_output("command", message.pack_command(action, value))
|
||||
row = LEGACY_REQUEST.get(action)
|
||||
if row is not None:
|
||||
_request(node, state, dict(row))
|
||||
|
||||
|
||||
def _freedrive(node: Node, state: Teleop, enabled: bool) -> None:
|
||||
"""Toggle freedrive. Its argument rides as the verb's value."""
|
||||
_act(node, state, FREEDRIVE, value=float(enabled))
|
||||
_request(node, state, {"service": "freedrive", "enabled": enabled})
|
||||
|
||||
|
||||
def _request(node: Node, state: Teleop, row: dict) -> None:
|
||||
@@ -353,11 +463,18 @@ def _request(node: Node, state: Teleop, row: dict) -> None:
|
||||
|
||||
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()))
|
||||
_post_ui(node, state, 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."""
|
||||
def _post_ui(node: Node, state: Teleop, payload: dict) -> None:
|
||||
"""Send one object to every browser, and out on the wire for anyone watching.
|
||||
|
||||
`ui_state` carries no browser any more — the sockets are ours. It stays
|
||||
published because it is the one port that says what an operator is being
|
||||
shown, which is what `dora topic echo` and the bench read it for.
|
||||
"""
|
||||
if state.console is not None:
|
||||
state.console.broadcast(payload)
|
||||
node.send_output("ui_state", pa.array([json.dumps(payload)]), metadata={"encoding": "json"})
|
||||
|
||||
|
||||
|
||||
+116
-20
@@ -1,19 +1,34 @@
|
||||
"""Turning stick deflection into a velocity the arm understands.
|
||||
"""Turning a control input into motion, in one place for every source.
|
||||
|
||||
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.
|
||||
be right and the part that is cheapest to check.
|
||||
|
||||
Everything passes through a **normalised** six-vector first: `[-1, 1]` per
|
||||
degree of freedom, `[x, y, z, roll, pitch, yaw]`, in the robot's **base**
|
||||
frame. A gamepad reaches it through :func:`from_pad`, which shapes away a worn
|
||||
stick; the browser sends it already clean. Only then is it scaled, and that
|
||||
happens exactly once — which is what lets the same node drive an arm in mm/s
|
||||
and one in m/s without either source knowing the difference.
|
||||
|
||||
Two scalings exist. :func:`twist` is the standard one, SI, and is what a robot
|
||||
should consume. :func:`velocity` is mm/s and deg/s for `dora-ulite6`'s `jog`
|
||||
port, kept while that node learns the standard.
|
||||
|
||||
The screen convention comes from the old web console, so the two agree: stick
|
||||
up is +X, stick right is +Y.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The arm takes six numbers; these name them.
|
||||
# Six degrees of freedom; these name their places in the vector.
|
||||
VX, VY, VZ, WX, WY, WZ = range(6)
|
||||
|
||||
# Operator-facing settings are in mm/s and deg/s because that is what a person
|
||||
# reading a dataflow can judge — 60 mm/s means something, 0.06 m/s less so. The
|
||||
# wire is SI. These convert between the two, and are the only place that does.
|
||||
MM_PER_M = 1000.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
@@ -43,6 +58,17 @@ class Layout:
|
||||
|
||||
LAYOUT = Layout()
|
||||
|
||||
# The frame a velocity is expressed in. Base is what `tcp_pose` reports and what
|
||||
# the console's arrows mean; tool is reserved for a consumer that wants it.
|
||||
BASE_FRAME = "base"
|
||||
TOOL_FRAME = "tool"
|
||||
|
||||
# How far a control must travel before the pad counts as being used. Raw, with
|
||||
# no deadzone: this decides whether the pad is asking for the arm, and
|
||||
# `velocity` decides how far to move it. A tighter threshold here than there
|
||||
# would mean a stick could claim the arm and then not move it.
|
||||
ACTIVITY = 0.3
|
||||
|
||||
|
||||
def deadzone(value: float, width: float) -> float:
|
||||
"""Ignore travel near centre, and rescale what is left to the full range.
|
||||
@@ -89,13 +115,16 @@ def pressed(buttons: list[bool], index: int) -> bool:
|
||||
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.
|
||||
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading to the normalised six-vector.
|
||||
|
||||
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.
|
||||
is analog and blended, which is exactly what the console's ring sectors cannot do.
|
||||
|
||||
The deadzone and the response curve are applied here and nowhere else,
|
||||
because they exist to correct a physical stick. A browser has no worn
|
||||
centre, so :func:`from_console` does not use them.
|
||||
"""
|
||||
|
||||
def shaped(value: float) -> float:
|
||||
@@ -104,20 +133,87 @@ def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LA
|
||||
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
|
||||
out[VX] = shaped(-axis(axes, layout.left_y))
|
||||
out[VY] = shaped(axis(axes, layout.left_x))
|
||||
out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)))
|
||||
out[WX] = shaped(axis(axes, layout.right_x))
|
||||
out[WY] = shaped(-axis(axes, layout.right_y))
|
||||
out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left))
|
||||
return out
|
||||
|
||||
|
||||
def from_console(values) -> list[float]:
|
||||
"""Read the normalised six-vector a browser sent, clamped to what it may ask for.
|
||||
|
||||
The console speaks the same units as a stick — a fraction of full speed per
|
||||
axis — so that how fast anything goes is decided once, here, rather than
|
||||
once per source. A page that has never heard of the robot cannot get its
|
||||
units wrong.
|
||||
"""
|
||||
if not isinstance(values, list) or len(values) != 6:
|
||||
return [0.0] * 6
|
||||
out = []
|
||||
for value in values:
|
||||
if not isinstance(value, (int, float)):
|
||||
return [0.0] * 6
|
||||
out.append(max(-1.0, min(1.0, float(value))))
|
||||
return out
|
||||
|
||||
|
||||
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
|
||||
"""Map one gamepad reading straight to `dora-ulite6`'s mm/s and deg/s.
|
||||
|
||||
A convenience over `scale(from_pad(...))`, kept because the probe and the
|
||||
tests read better for it.
|
||||
"""
|
||||
return scale(from_pad(axes, buttons, config, layout), config)
|
||||
|
||||
|
||||
def scale(normalised: list[float], config) -> list[float]:
|
||||
"""Scale the normalised six-vector to mm/s and deg/s, base frame.
|
||||
|
||||
`dora-ulite6`'s `jog` units. Deprecated in favour of :func:`twist`, and kept
|
||||
only while that node learns to take the standard one.
|
||||
"""
|
||||
out = [value * config.speed_mm_s for value in normalised[:3]]
|
||||
out += [value * config.speed_deg_s for value in normalised[3:]]
|
||||
return out
|
||||
|
||||
|
||||
def twist(normalised: list[float], config, frame: str = BASE_FRAME) -> dict:
|
||||
"""Scale the normalised six-vector to a Twist, in SI.
|
||||
|
||||
Metres per second and radians per second, which is what a robot node should
|
||||
consume: unambiguous, and the same numbers a UR or MoveIt would take. A
|
||||
consumer wanting other units converts them itself, at one known boundary.
|
||||
"""
|
||||
linear = [value * config.speed_mm_s / MM_PER_M for value in normalised[:3]]
|
||||
angular = [math.radians(value * config.speed_deg_s) for value in normalised[3:]]
|
||||
return {"linear": linear, "angular": angular, "frame": frame}
|
||||
|
||||
|
||||
def is_active(sample, layout: Layout = LAYOUT) -> bool:
|
||||
"""Whether anything on the pad is being touched at all.
|
||||
|
||||
A trigger is not a stick. SDL rests a trigger at **-1.0**, so comparing its
|
||||
raw value against a threshold reports every connected pad as being used,
|
||||
for ever — which means the pad claims the arm the moment it is plugged in
|
||||
and `GAMEPAD_IDLE_RELEASE_S` never fires. Triggers go through
|
||||
:func:`trigger` here for the same reason they do in :func:`velocity`.
|
||||
|
||||
Axes the layout does not name are still counted: a pad that reports its
|
||||
d-pad as two extra axes is asking for the arm when somebody presses it.
|
||||
"""
|
||||
if not sample.present:
|
||||
return False
|
||||
if any(sample.buttons):
|
||||
return True
|
||||
triggers = (layout.left_trigger, layout.right_trigger)
|
||||
if any(trigger(axis(sample.axes, index)) > ACTIVITY for index in triggers):
|
||||
return True
|
||||
return any(abs(value) > ACTIVITY for index, value in enumerate(sample.axes) if index not in triggers)
|
||||
|
||||
|
||||
def is_moving(velocity_vector: list[float]) -> bool:
|
||||
"""Whether this velocity would actually drive the arm."""
|
||||
return any(velocity_vector)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The wire shapes this node publishes.
|
||||
|
||||
Declared, never inferred. pyarrow reads `{"value": None}` as `null` and
|
||||
`{"value": 1.0}` as `double`, so an inferred `command` would change the type of
|
||||
its port depending on which verb was pressed — `stop` and `freedrive` would not
|
||||
agree. The same hazard that makes an unplugged gamepad retype a `joy` port.
|
||||
|
||||
dora 1.0 has no standard type for either of these, so these structs and the
|
||||
manifest that describes them are the whole contract a consumer gets.
|
||||
"""
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
# A velocity, in SI. Metres per second and radians per second, in the frame
|
||||
# named by `frame` — the units a robot node should consume, and the same ones a
|
||||
# UR or MoveIt would take.
|
||||
TWIST = pa.struct(
|
||||
[
|
||||
("linear", pa.list_(pa.float64())),
|
||||
("angular", pa.list_(pa.float64())),
|
||||
("frame", pa.string()),
|
||||
]
|
||||
)
|
||||
|
||||
# One discrete thing to do. `action` is a robot-agnostic verb; `value` carries
|
||||
# its argument where it has one and is null otherwise.
|
||||
COMMAND = pa.struct([("action", pa.string()), ("value", pa.float64())])
|
||||
|
||||
# `dora-ulite6`'s velocity, in mm/s and deg/s. Deprecated along with the `jog`
|
||||
# port it goes out on.
|
||||
JOG = pa.struct([("velocity", pa.list_(pa.float64()))])
|
||||
|
||||
|
||||
def pack_twist(twist: dict) -> pa.Array:
|
||||
"""Build one `twist` message."""
|
||||
return pa.array([twist], type=TWIST)
|
||||
|
||||
|
||||
def pack_command(action: str, value: float | None) -> pa.Array:
|
||||
"""Build one `command` message."""
|
||||
return pa.array([{"action": action, "value": value}], type=COMMAND)
|
||||
|
||||
|
||||
def pack_jog(velocity: list[float]) -> pa.Array:
|
||||
"""Build one deprecated `jog` message."""
|
||||
return pa.array([{"velocity": [float(v) for v in velocity]}], type=JOG)
|
||||
+46
-27
@@ -1,24 +1,40 @@
|
||||
"""Show what the pad is doing, with no dataflow and no arm.
|
||||
"""Show what the pad is doing to the arm, without an arm.
|
||||
|
||||
`dora-teleop-probe` opens the gamepad and prints, live: the raw axes SDL
|
||||
reports, the buttons it sees pressed, and the velocity this node would send.
|
||||
`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the
|
||||
same `joy` stream this node reads, and prints: the raw axes, the buttons, and the
|
||||
velocity this node would send. **It cannot move anything.** It publishes no
|
||||
output and never talks to the arm; it only reads.
|
||||
|
||||
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
|
||||
SDL's documented layout rather than an observed one, and a pad that disagrees is
|
||||
otherwise discovered by watching a real arm move the wrong way. It goes through
|
||||
:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity`
|
||||
— the same code the node runs — so what it prints is what the arm would get.
|
||||
the same :func:`~dora_teleop.mapping.velocity` the node runs, on the same
|
||||
readings, so what it prints is what the arm would get — keep it that way, or it
|
||||
stops being evidence.
|
||||
|
||||
Nothing here can move anything. There is no dora node and no connection to the
|
||||
arm; this only reads.
|
||||
Because it reads `joy` rather than a pad, the dataflow has to declare it:
|
||||
|
||||
```yaml
|
||||
- id: teleop-probe
|
||||
path: dynamic
|
||||
inputs:
|
||||
joy: joy/joy
|
||||
```
|
||||
|
||||
`dataflow-gamepad.yml` in this repo already does. Start that dataflow under a
|
||||
coordinator (`dora start`, not `dora run`), then run `dora-teleop-probe` in
|
||||
another terminal.
|
||||
|
||||
For a pad whose layout is not known at all — which index is which — use
|
||||
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from dora_teleop import mapping
|
||||
from dora import Node
|
||||
|
||||
from dora_teleop import joy, mapping
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Gamepad
|
||||
from dora_teleop.mapping import LAYOUT
|
||||
|
||||
# What each velocity component is, in the order the arm takes them.
|
||||
@@ -79,10 +95,10 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
||||
|
||||
if not sample.present:
|
||||
lines += [
|
||||
" no gamepad found.",
|
||||
" no gamepad.",
|
||||
"",
|
||||
f" looking for joystick index {config.joystick_index};",
|
||||
" plug one in and this will pick it up.",
|
||||
" `dora-joy` is reporting no pad, or has stopped reporting at all.",
|
||||
" Either way the arm is being told to hold still.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -102,30 +118,33 @@ def render(sample, velocity: list[float], config: Config) -> str:
|
||||
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
|
||||
|
||||
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
|
||||
lines += ["", f" moving: {moving}"]
|
||||
lines += [
|
||||
"",
|
||||
f" moving: {moving}",
|
||||
f" asking for the arm: {'yes' if mapping.is_active(sample) else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Open the pad and print what it is doing until interrupted."""
|
||||
parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.")
|
||||
parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.")
|
||||
"""Attach to a running dataflow and print what the pad is doing."""
|
||||
parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.")
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
default="teleop-probe",
|
||||
help="The id this probe is declared under in the dataflow. Defaults to teleop-probe.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config.from_env()
|
||||
gamepad = Gamepad(config.joystick_index)
|
||||
gamepad.start()
|
||||
node = Node(args.name)
|
||||
|
||||
try:
|
||||
while True:
|
||||
sample = gamepad.read()
|
||||
for event in node:
|
||||
if event["type"] != "INPUT" or event["id"] != "joy":
|
||||
continue
|
||||
sample = joy.decode(event)
|
||||
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
|
||||
print(CLEAR + render(sample, velocity, config), flush=True)
|
||||
time.sleep(1.0 / args.hz)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
finally:
|
||||
gamepad.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""The console's web server, running inside this node.
|
||||
|
||||
The browser used to reach the node through `dora-websocket-transceiver`, which
|
||||
meant a second node, a second port and a round trip through the dataflow for
|
||||
every button press. This serves the page and holds the sockets directly, so the
|
||||
console is one URL and the operator wires nothing.
|
||||
|
||||
**Nothing here runs on the dora thread.** aiohttp wants an asyncio loop and the
|
||||
node's loop is a blocking `for event in node`, so the server owns a thread of
|
||||
its own and the two sides meet at two places only: :meth:`Console.take`, which
|
||||
the tick drains, and :meth:`Console.broadcast`, which hands a snapshot the other
|
||||
way. Both are non-blocking by construction — a browser that stops reading must
|
||||
never be able to stall the thread that stops the arm.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
|
||||
from dora_teleop.log import log
|
||||
|
||||
INDEX = Path(__file__).parent / "web" / "index.html"
|
||||
|
||||
# How many console messages may pile up between two ticks before we start
|
||||
# dropping them. The console sends a jog every 150 ms and the tick runs every
|
||||
# 50, so reaching this at all means something is flooding us; an unbounded
|
||||
# queue would answer that by growing until the node died.
|
||||
INBOX_LIMIT = 256
|
||||
|
||||
# How many snapshots may wait on one socket. Telemetry is a snapshot, not a
|
||||
# log, so a client too slow to keep up wants the newest one and not a backlog
|
||||
# of the arm's recent past.
|
||||
OUTBOX_LIMIT = 8
|
||||
|
||||
|
||||
class _Client:
|
||||
"""One connected browser, and the snapshots waiting to reach it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbox: asyncio.Queue[str] = asyncio.Queue(maxsize=OUTBOX_LIMIT)
|
||||
|
||||
def offer(self, text: str) -> None:
|
||||
"""Queue one snapshot, discarding the stalest if this client is behind."""
|
||||
if self.outbox.full():
|
||||
self.outbox.get_nowait()
|
||||
self.outbox.put_nowait(text)
|
||||
|
||||
|
||||
class Console:
|
||||
"""The page, the sockets, and the thread they live on."""
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
# Read every tick by the deadman: the last browser leaving has to stop
|
||||
# the arm, and a closed socket sends nothing to say so.
|
||||
self.clients = 0
|
||||
self._inbox: queue.Queue[dict] = queue.Queue(maxsize=INBOX_LIMIT)
|
||||
self._sockets: set[_Client] = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
# Whatever stopped the server coming up, kept so `start` can raise it on
|
||||
# the caller's thread instead of leaving a traceback on stderr and a
|
||||
# node that believes it is serving a console.
|
||||
self._failure: BaseException | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Bring the server up, or raise saying why it could not.
|
||||
|
||||
Refusing to start is the point. The port being taken used to leave a
|
||||
traceback on the server thread and a node that carried on announcing a
|
||||
console nobody could open — an operator typing the URL and getting
|
||||
nothing, with the log saying everything was fine.
|
||||
"""
|
||||
self._thread = threading.Thread(target=self._run, name="teleop-console", daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout=10.0):
|
||||
raise RuntimeError(f"the console did not come up on {self.host}:{self.port} within 10s")
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
log(f"console on http://{self.host}:{self.port}")
|
||||
|
||||
def take(self) -> list[dict]:
|
||||
"""Return every console message that arrived since the last call."""
|
||||
drained = []
|
||||
while True:
|
||||
try:
|
||||
drained.append(self._inbox.get_nowait())
|
||||
except queue.Empty:
|
||||
return drained
|
||||
|
||||
def broadcast(self, payload: dict) -> None:
|
||||
"""Hand one object to every connected browser."""
|
||||
if self._loop is None:
|
||||
return
|
||||
text = json.dumps(payload)
|
||||
self._loop.call_soon_threadsafe(self._offer, text)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Close the sockets and stop the thread."""
|
||||
if self._loop is None:
|
||||
return
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
# --- everything below runs on the server thread ---
|
||||
|
||||
def _run(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self._loop = loop
|
||||
try:
|
||||
runner = loop.run_until_complete(self._listen())
|
||||
except BaseException as error: # noqa: BLE001 — handed to `start` to raise
|
||||
self._failure = error
|
||||
self._loop = None
|
||||
loop.close()
|
||||
# Releases `start` at once rather than making it wait out its
|
||||
# timeout for a server that is never coming up.
|
||||
self._ready.set()
|
||||
return
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.run_until_complete(runner.cleanup())
|
||||
loop.close()
|
||||
|
||||
async def _listen(self) -> web.AppRunner:
|
||||
app = web.Application()
|
||||
app.router.add_get("/", self._page)
|
||||
app.router.add_get("/ws", self._socket)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, self.host, self.port).start()
|
||||
self._ready.set()
|
||||
return runner
|
||||
|
||||
async def _page(self, request: web.Request) -> web.FileResponse:
|
||||
"""Serve the console itself."""
|
||||
return web.FileResponse(INDEX)
|
||||
|
||||
async def _socket(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Hold one browser's socket for as long as it stays."""
|
||||
socket = web.WebSocketResponse(heartbeat=20)
|
||||
await socket.prepare(request)
|
||||
client = _Client()
|
||||
self._sockets.add(client)
|
||||
self.clients = len(self._sockets)
|
||||
writer = asyncio.create_task(self._write(socket, client))
|
||||
try:
|
||||
async for received in socket:
|
||||
if received.type == WSMsgType.TEXT:
|
||||
self._accept(received.data)
|
||||
finally:
|
||||
writer.cancel()
|
||||
self._sockets.discard(client)
|
||||
self.clients = len(self._sockets)
|
||||
return socket
|
||||
|
||||
def _accept(self, text: str) -> None:
|
||||
"""Park one message from a browser for the next tick to read."""
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
log("ignoring a console message that was not JSON")
|
||||
return
|
||||
try:
|
||||
self._inbox.put_nowait(payload)
|
||||
except queue.Full:
|
||||
log("console inbox full, dropping a message")
|
||||
|
||||
def _offer(self, text: str) -> None:
|
||||
for client in self._sockets:
|
||||
client.offer(text)
|
||||
|
||||
async def _write(self, socket: web.WebSocketResponse, client: _Client) -> None:
|
||||
while True:
|
||||
await socket.send_str(await client.outbox.get())
|
||||
+17
-9
@@ -49,7 +49,10 @@ class Command:
|
||||
"""One thing the console asked for."""
|
||||
|
||||
type: str
|
||||
velocity: list[float] = field(default_factory=list)
|
||||
# Normalised, `[-1, 1]` per degree of freedom — a fraction of full speed,
|
||||
# exactly what a stick reports. Not a velocity: the console does not know
|
||||
# the robot's units and must not have to.
|
||||
axes: list[float] = field(default_factory=list)
|
||||
action: str = ""
|
||||
preempt: bool = False
|
||||
enabled: bool = False
|
||||
@@ -70,10 +73,10 @@ def command_of(payload: dict) -> Command | None:
|
||||
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):
|
||||
axes = payload.get("axes") or []
|
||||
if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes):
|
||||
return None
|
||||
return Command(type="jog", velocity=[float(v) for v in velocity])
|
||||
return Command(type="jog", axes=[float(v) for v in axes])
|
||||
if kind in ("jog_stop", "estop", "reset", "home"):
|
||||
return Command(type=kind)
|
||||
if kind == "control":
|
||||
@@ -83,7 +86,7 @@ def command_of(payload: dict) -> Command | None:
|
||||
preempt=bool(payload.get("preempt", False)),
|
||||
)
|
||||
if kind == "gripper":
|
||||
return Command(type="gripper", enabled=bool(payload.get("vacuum", False)))
|
||||
return Command(type="gripper", enabled=bool(payload.get("closed", False)))
|
||||
if kind == "freedrive":
|
||||
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
|
||||
if kind == "move":
|
||||
@@ -146,9 +149,10 @@ def arm_state(view: ArmView, jogging: bool) -> str:
|
||||
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.
|
||||
Every key here is read by name in `app.js`, so this and the page change
|
||||
together. The names are the robot-agnostic ones: `gripper` rather than
|
||||
`vacuum`, because a page that says vacuum only fits a robot with one on it,
|
||||
and `units` so a page can label a pose without guessing.
|
||||
"""
|
||||
return {
|
||||
"type": "telemetry",
|
||||
@@ -158,10 +162,14 @@ def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float)
|
||||
"arm_state": arm_state(view, jogging),
|
||||
"owner": owner or "none",
|
||||
"estop": view.estop,
|
||||
"vacuum": view.tool_engaged,
|
||||
"gripper": 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),
|
||||
# What `position` is in, so the page labels it rather than assuming. The
|
||||
# telemetry ports carry whatever the robot publishes; only the command
|
||||
# path is normalised.
|
||||
"units": {"position": "mm", "rotation": "deg"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<title>teleop console</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101016;
|
||||
--card: #1a1a23;
|
||||
--edge: #2b2b38;
|
||||
--ink: #e8e8f0;
|
||||
--dim: #8a8a9e;
|
||||
--live: #7fd1e8;
|
||||
--warn: #e8a37f;
|
||||
--stop: #e2564a;
|
||||
--go: #43c98b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding-block: 16px;
|
||||
padding-inline: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; }
|
||||
|
||||
header {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
header h1 { font-size: 15px; margin: 0; margin-right: auto; letter-spacing: .04em; }
|
||||
.chip {
|
||||
padding: 5px 12px; border-radius: 999px; border: 1px solid var(--edge);
|
||||
background: var(--card); font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.chip b { color: var(--live); font-weight: 600; }
|
||||
.chip.estop { border-color: var(--stop); color: var(--stop); }
|
||||
.chip.mine { border-color: var(--go); }
|
||||
.chip.off { color: var(--dim); }
|
||||
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
|
||||
padding: 14px; margin-bottom: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
|
||||
color: var(--dim); margin: 0 0 12px; font-weight: 600;
|
||||
}
|
||||
|
||||
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
button {
|
||||
flex: 1 1 auto; min-height: 48px; min-width: 104px;
|
||||
background: #272733; color: var(--ink); border: 1px solid #383848;
|
||||
border-radius: 9px; padding: 10px 16px; font: inherit; cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
button:active:not(:disabled) { background: #33334a; }
|
||||
button:disabled { opacity: .3; cursor: not-allowed; }
|
||||
button.stop { background: #55202a; border-color: var(--stop); color: #ffd9d4; }
|
||||
button.on { background: #1f4a5a; border-color: var(--live); }
|
||||
|
||||
/* --- the jog rings --- */
|
||||
|
||||
.decks { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.deck {
|
||||
flex: 1 1 340px;
|
||||
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.deck h2 {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
|
||||
color: var(--dim); margin: 0 0 12px; font-weight: 600;
|
||||
}
|
||||
.pills { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
.ring { display: block; width: 100%; max-width: 330px; margin-inline: auto; touch-action: none; }
|
||||
.ring .sector {
|
||||
fill: #23232f; stroke: var(--edge); stroke-width: 1.5; cursor: pointer;
|
||||
}
|
||||
.ring .sector.holding { fill: var(--live); stroke: #bfe9f5; }
|
||||
.ring .label {
|
||||
fill: #a6a6bd; font-size: 15px; text-anchor: middle; dominant-baseline: middle;
|
||||
pointer-events: none; font-family: inherit;
|
||||
}
|
||||
.ring .sector.holding + .label { fill: #0b1013; font-weight: 700; }
|
||||
.ring .hub {
|
||||
fill: none; stroke: var(--edge); stroke-width: 1.5;
|
||||
}
|
||||
.ring .hubtext {
|
||||
fill: var(--dim); font-size: 13px; text-anchor: middle; dominant-baseline: middle;
|
||||
pointer-events: none; font-family: inherit; letter-spacing: .08em;
|
||||
}
|
||||
.ring[data-off="true"] { opacity: .32; }
|
||||
.ring[data-off="true"] .sector { cursor: not-allowed; }
|
||||
|
||||
label.speed { display: flex; align-items: center; gap: 12px; color: var(--dim); }
|
||||
label.speed input { flex: 1; height: 34px; }
|
||||
label.speed b { color: var(--live); min-width: 4ch; text-align: right; }
|
||||
|
||||
.readout { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 2px 18px; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
.row span:last-child { color: var(--live); }
|
||||
|
||||
#toast { color: var(--warn); min-height: 1.5em; padding: 4px 2px; }
|
||||
#link { color: var(--dim); font-size: 12px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { padding-inline: 10px; }
|
||||
.deck { padding: 10px; }
|
||||
button { min-width: 84px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>teleop console</h1>
|
||||
<span class="chip" id="chip-state">—</span>
|
||||
<span class="chip" id="chip-owner">—</span>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<div class="bar">
|
||||
<button id="take">Take control</button>
|
||||
<button id="release">Release</button>
|
||||
<button id="reset">Reset</button>
|
||||
<button class="stop" id="estop">E‑STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decks">
|
||||
<div class="deck">
|
||||
<h2>Translate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="2" data-sign="-1">−Z</button>
|
||||
<button data-axis="2" data-sign="1">+Z</button>
|
||||
</div>
|
||||
<svg class="ring" id="translate" viewBox="0 0 200 200" aria-label="translate">
|
||||
<g data-north="0,1" data-south="0,-1" data-west="1,1" data-east="1,-1"
|
||||
data-labels="+X,−X,Y+,Y−" data-hub="XYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="deck">
|
||||
<h2>Rotate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="5" data-sign="-1">−yaw</button>
|
||||
<button data-axis="5" data-sign="1">+yaw</button>
|
||||
</div>
|
||||
<svg class="ring" id="rotate" viewBox="0 0 200 200" aria-label="rotate">
|
||||
<g data-north="4,1" data-south="4,-1" data-west="3,-1" data-east="3,1"
|
||||
data-labels="+pitch,−pitch,−roll,+roll" data-hub="RXYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Speed</h2>
|
||||
<label class="speed">
|
||||
<input id="speed" type="range" min="10" max="100" step="5" value="60">
|
||||
<b id="speedout">60%</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Tool</h2>
|
||||
<div class="bar">
|
||||
<button id="close">Close gripper</button>
|
||||
<button id="open">Open gripper</button>
|
||||
<button id="freedrive">Freedrive</button>
|
||||
<button id="home">Home</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>State</h2>
|
||||
<div class="readout" id="state"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<div id="link"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// Read by name out of the node's telemetry snapshot. Robot-agnostic: `gripper`,
|
||||
// not `vacuum`, because a page that says vacuum only fits a robot with one.
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "gripper", "freedrive"];
|
||||
|
||||
// The node stops the robot if the jog stream dries up (JOG_TTL_S, 0.4 s).
|
||||
// Resending faster than that is what makes letting go a stop, with nothing
|
||||
// needing to be sent to say so.
|
||||
const REFRESH_MS = 150;
|
||||
|
||||
// The ring, laid out like the arm's own pendant so the two do not disagree:
|
||||
// a quarter each, north at the top, and a hub that is label rather than button.
|
||||
const GAP_DEG = 3;
|
||||
const OUTER = 95;
|
||||
const INNER = 46;
|
||||
const LABEL_R = 71;
|
||||
const COMPASS = [
|
||||
["north", 225, 315],
|
||||
["east", 315, 405],
|
||||
["south", 45, 135],
|
||||
["west", 135, 225],
|
||||
];
|
||||
|
||||
let socket = null;
|
||||
let owner = "none";
|
||||
let current = {};
|
||||
let axes = [0, 0, 0, 0, 0, 0];
|
||||
let refresh = null;
|
||||
|
||||
// Every direction currently held, as element -> {axis, sign}. A map rather than
|
||||
// one value because two fingers may hold two directions, and lifting one must
|
||||
// not cancel the other.
|
||||
const held = new Map();
|
||||
// Which element each finger is on, so a finger that slides off still releases
|
||||
// the direction it started on.
|
||||
const fingers = new Map();
|
||||
|
||||
function send(payload) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
function scale() {
|
||||
return Number(document.getElementById("speed").value) / 100;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
axes = [0, 0, 0, 0, 0, 0];
|
||||
const dial = scale();
|
||||
for (const [, { axis, sign }] of held) axes[axis] += sign * dial;
|
||||
axes = axes.map((v) => Math.max(-1, Math.min(1, v)));
|
||||
}
|
||||
|
||||
function moving() {
|
||||
return axes.some((value) => value !== 0);
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (moving()) send({ type: "jog", axes });
|
||||
}
|
||||
|
||||
// --- drawing the ring ---
|
||||
|
||||
function onCircle(radius, degrees) {
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return [100 + radius * Math.cos(radians), 100 + radius * Math.sin(radians)];
|
||||
}
|
||||
|
||||
function sectorPath(from, to) {
|
||||
const [ax, ay] = onCircle(OUTER, from);
|
||||
const [bx, by] = onCircle(OUTER, to);
|
||||
const [cx, cy] = onCircle(INNER, to);
|
||||
const [dx, dy] = onCircle(INNER, from);
|
||||
return `M${ax} ${ay}A${OUTER} ${OUTER} 0 0 1 ${bx} ${by}`
|
||||
+ `L${cx} ${cy}A${INNER} ${INNER} 0 0 0 ${dx} ${dy}Z`;
|
||||
}
|
||||
|
||||
function svg(name, attributes) {
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
|
||||
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildRing(ring) {
|
||||
const group = ring.querySelector("g");
|
||||
const labels = group.dataset.labels.split(",");
|
||||
// Labelled in compass order so the markup reads the way the ring looks:
|
||||
// north, south, west, east.
|
||||
const naming = { north: labels[0], south: labels[1], west: labels[2], east: labels[3] };
|
||||
|
||||
for (const [point, from, to] of COMPASS) {
|
||||
const [axis, sign] = group.dataset[point].split(",").map(Number);
|
||||
const sector = svg("path", {
|
||||
d: sectorPath(from + GAP_DEG, to - GAP_DEG),
|
||||
class: "sector",
|
||||
});
|
||||
sector.dataset.axis = axis;
|
||||
sector.dataset.sign = sign;
|
||||
group.appendChild(sector);
|
||||
|
||||
const middle = (from + to) / 2;
|
||||
const [lx, ly] = onCircle(LABEL_R, middle);
|
||||
const text = svg("text", { x: lx, y: ly, class: "label" });
|
||||
text.textContent = naming[point];
|
||||
group.appendChild(text);
|
||||
}
|
||||
|
||||
group.appendChild(svg("circle", { cx: 100, cy: 100, r: INNER - 6, class: "hub" }));
|
||||
const hub = svg("text", { x: 100, y: 100, class: "hubtext" });
|
||||
hub.textContent = group.dataset.hub;
|
||||
group.appendChild(hub);
|
||||
}
|
||||
|
||||
// --- holding a direction ---
|
||||
|
||||
function grab(element) {
|
||||
if (held.has(element) || element.closest("[data-off=\"true\"]")) return;
|
||||
held.set(element, { axis: Number(element.dataset.axis), sign: Number(element.dataset.sign) });
|
||||
element.classList.add("holding");
|
||||
recompute();
|
||||
pump();
|
||||
// Resend while held. The node's deadman expects a stream, not one message.
|
||||
if (refresh === null) refresh = setInterval(pump, REFRESH_MS);
|
||||
}
|
||||
|
||||
function letGo(element) {
|
||||
if (!held.delete(element)) return;
|
||||
element.classList.remove("holding");
|
||||
recompute();
|
||||
if (moving()) return;
|
||||
// Say so rather than going quiet: the deadman would stop it either way, but a
|
||||
// direction released is an intention and 400 ms of travel is not.
|
||||
send({ type: "jog_stop" });
|
||||
clearInterval(refresh);
|
||||
refresh = null;
|
||||
}
|
||||
|
||||
function releaseEverything() {
|
||||
for (const element of [...held.keys()]) letGo(element);
|
||||
fingers.clear();
|
||||
}
|
||||
|
||||
function wireDirection(element) {
|
||||
element.addEventListener("pointerdown", (event) => {
|
||||
// Keeps this finger's events coming even once it slides off, so letting go
|
||||
// always reaches letGo().
|
||||
element.setPointerCapture(event.pointerId);
|
||||
fingers.set(event.pointerId, element);
|
||||
grab(element);
|
||||
});
|
||||
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
||||
element.addEventListener(name, (event) => {
|
||||
fingers.delete(event.pointerId);
|
||||
letGo(element);
|
||||
});
|
||||
}
|
||||
element.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
function wire() {
|
||||
for (const ring of document.querySelectorAll(".ring")) buildRing(ring);
|
||||
for (const element of document.querySelectorAll(".sector, .pills button[data-axis]")) {
|
||||
wireDirection(element);
|
||||
}
|
||||
|
||||
// A tab that goes away mid-jog must not leave the robot travelling. The node
|
||||
// covers this too; doing it here costs one line.
|
||||
window.addEventListener("blur", releaseEverything);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) releaseEverything();
|
||||
});
|
||||
|
||||
const speed = document.getElementById("speed");
|
||||
speed.addEventListener("input", () => {
|
||||
document.getElementById("speedout").textContent = `${speed.value}%`;
|
||||
recompute();
|
||||
});
|
||||
|
||||
const click = {
|
||||
take: { type: "control", action: "take", preempt: true },
|
||||
release: { type: "control", action: "release" },
|
||||
estop: { type: "estop" },
|
||||
reset: { type: "reset" },
|
||||
close: { type: "gripper", closed: true },
|
||||
open: { type: "gripper", closed: false },
|
||||
home: { type: "home" },
|
||||
};
|
||||
for (const [id, payload] of Object.entries(click)) {
|
||||
document.getElementById(id).addEventListener("click", () => send(payload));
|
||||
}
|
||||
document.getElementById("freedrive").addEventListener("click", () => {
|
||||
send({ type: "freedrive", enabled: !current.freedrive });
|
||||
});
|
||||
}
|
||||
|
||||
function render(telemetry) {
|
||||
current = telemetry;
|
||||
const rows = KEYS.map((key) =>
|
||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||
const pose = telemetry.position;
|
||||
if (pose) {
|
||||
// The node says what the pose is in rather than the page assuming it.
|
||||
const unit = (telemetry.units && telemetry.units.position) || "";
|
||||
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
|
||||
rows.push(`<div class="row"><span>position</span><span>${shown} ${unit}</span></div>`);
|
||||
}
|
||||
document.getElementById("state").innerHTML = rows.join("");
|
||||
|
||||
const state = document.getElementById("chip-state");
|
||||
state.innerHTML = `arm <b>${telemetry.arm_state}</b>`;
|
||||
state.className = `chip${telemetry.estop ? " estop" : ""}`;
|
||||
|
||||
owner = telemetry.owner;
|
||||
const mine = owner === "web";
|
||||
const chip = document.getElementById("chip-owner");
|
||||
chip.innerHTML = mine ? "<b>you have the arm</b>" : `held by <b>${owner}</b>`;
|
||||
chip.className = `chip ${mine ? "mine" : "off"}`;
|
||||
|
||||
document.getElementById("freedrive").classList.toggle("on", !!telemetry.freedrive);
|
||||
document.getElementById("close").classList.toggle("on", !!telemetry.gripper);
|
||||
|
||||
// Every control is gated on holding the lease, so one that would be refused
|
||||
// looks refused. E-STOP and Reset never are.
|
||||
for (const id of ["close", "open", "freedrive", "home"]) {
|
||||
document.getElementById(id).disabled = !mine;
|
||||
}
|
||||
for (const ring of document.querySelectorAll(".ring")) {
|
||||
ring.dataset.off = String(!mine);
|
||||
}
|
||||
for (const pill of document.querySelectorAll(".pills button[data-axis]")) {
|
||||
pill.disabled = !mine;
|
||||
}
|
||||
if (!mine) releaseEverything();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
// The node serves this page and holds the socket, so the socket is on this
|
||||
// page's own origin — nothing to configure and nothing to keep in step.
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${scheme}//${location.host}/ws`;
|
||||
document.getElementById("link").textContent = `socket: ${url}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "telemetry") render(message);
|
||||
if (message.type === "error") {
|
||||
document.getElementById("toast").textContent = message.detail;
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
releaseEverything();
|
||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
wire();
|
||||
connect();
|
||||
</script>
|
||||
@@ -42,9 +42,6 @@ def main() -> None:
|
||||
node.send_output("robot_state", pa.array(["ready"]))
|
||||
node.send_output("tcp_pose", pa.array(POSE, type=pa.float32()), {"units_position": "mm"})
|
||||
node.send_output("joint_pose", pa.array(JOINTS, type=pa.float32()), {"units": "deg"})
|
||||
# No browser is attached to this dataflow, and saying so is what
|
||||
# keeps teleop from believing one went away mid-jog.
|
||||
node.send_output("clients", pa.array([{"connected": 0, "event": "close"}]))
|
||||
|
||||
elif event["id"] == "jog":
|
||||
velocity = event["value"][0].as_py()["velocity"]
|
||||
|
||||
@@ -23,8 +23,9 @@ inputs:
|
||||
description: A service call. Every one is answered ok.
|
||||
ui_state:
|
||||
description: >-
|
||||
The console snapshot teleop publishes. Accepted and dropped: no browser is
|
||||
attached to this dataflow.
|
||||
The console snapshot teleop publishes. Printed on change, which is how the
|
||||
lease moving and the arm starting and stopping can be watched without a
|
||||
browser.
|
||||
|
||||
outputs:
|
||||
response:
|
||||
@@ -38,5 +39,3 @@ outputs:
|
||||
joint_pose:
|
||||
description: Fixed joint angles, for the same reason.
|
||||
type: std/core/v1/Float32
|
||||
clients:
|
||||
description: Always zero connected, since the WebSocket bridge is not in this dataflow.
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dora-teleop"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = "Dora node giving a human the arm, from a gamepad or a browser"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -9,13 +9,14 @@ authors = [{ name = "Liria" }]
|
||||
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
|
||||
requires-python = ">=3.11"
|
||||
|
||||
# No hardware dependency. The pad is `dora-joy` and the arm is `dora-ulite6`;
|
||||
# this node decides, and serves the console that asks it to.
|
||||
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",
|
||||
# The web console: the page and the browsers' sockets on one port. Replaces
|
||||
# `dora-websocket-transceiver`, which was a second node for the same job.
|
||||
"aiohttp >= 3.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+40
-17
@@ -2,10 +2,22 @@
|
||||
|
||||
import json
|
||||
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from dora_teleop.config import Config
|
||||
from dora_teleop.gamepad import Sample
|
||||
from dora_teleop.joy import Sample
|
||||
|
||||
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
|
||||
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
|
||||
# so a test for an absent pad would otherwise build a message no consumer sees.
|
||||
JOY = pa.struct(
|
||||
[
|
||||
("axes", pa.list_(pa.float32())),
|
||||
("buttons", pa.list_(pa.bool_())),
|
||||
("present", pa.bool_()),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class RecordingNode:
|
||||
@@ -26,9 +38,17 @@ class RecordingNode:
|
||||
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 every velocity sent on the deprecated `jog` port, in order."""
|
||||
return [row["velocity"] for row, _ in self.outputs("jog")]
|
||||
|
||||
def twists(self):
|
||||
"""Return every Twist sent on the standard port, in order."""
|
||||
return [row for row, _ in self.outputs("twist")]
|
||||
|
||||
def actions(self):
|
||||
"""Return the verb of every standard `command`, in order."""
|
||||
return [row["action"] for row, _ in self.outputs("command")]
|
||||
|
||||
def services(self):
|
||||
"""Return the name of every service called, in order."""
|
||||
return [row["service"] for row, _ in self.outputs("request")]
|
||||
@@ -41,20 +61,6 @@ class RecordingNode:
|
||||
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 [])
|
||||
@@ -67,6 +73,20 @@ def stick(axes=None, buttons=None, present=True):
|
||||
return Sample(axes=values, buttons=list(buttons or []), present=present)
|
||||
|
||||
|
||||
def joy_message(sample):
|
||||
"""Build the Arrow row `dora-joy` would publish for one reading."""
|
||||
return pa.array(
|
||||
[
|
||||
{
|
||||
"axes": [float(value) for value in sample.axes],
|
||||
"buttons": [bool(down) for down in sample.buttons],
|
||||
"present": sample.present,
|
||||
}
|
||||
],
|
||||
type=JOY,
|
||||
)
|
||||
|
||||
|
||||
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 {})}
|
||||
@@ -80,10 +100,13 @@ def clean_env(monkeypatch):
|
||||
"SPEED_DEG_S",
|
||||
"DEADZONE",
|
||||
"EXPO",
|
||||
"JOYSTICK_INDEX",
|
||||
"JOG_TTL_S",
|
||||
"JOG_KEEPALIVE_S",
|
||||
"JOY_TTL_S",
|
||||
"GAMEPAD_IDLE_RELEASE_S",
|
||||
"STATE_POLL_S",
|
||||
"WEB_HOST",
|
||||
"WEB_PORT",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
+34
-4
@@ -10,7 +10,6 @@ def test_the_defaults_need_no_environment():
|
||||
|
||||
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():
|
||||
@@ -19,6 +18,13 @@ def test_the_deadman_is_shorter_than_the_arms_own_expiry():
|
||||
assert Config.from_env().deadman.jog_ttl_s < 0.5
|
||||
|
||||
|
||||
def test_the_layers_fire_innermost_first():
|
||||
# A stale pad reading has to be noticed before the jog deadline it feeds.
|
||||
deadman = Config.from_env().deadman
|
||||
|
||||
assert deadman.joy_ttl_s < deadman.jog_ttl_s
|
||||
|
||||
|
||||
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
|
||||
monkeypatch.setenv("SPEED_MM_S", " ")
|
||||
|
||||
@@ -32,10 +38,32 @@ def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch):
|
||||
monkeypatch.setenv("JOYSTICK_INDEX", "1.5")
|
||||
def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
|
||||
# Layers out of order means a backstop firing before the thing it backs up,
|
||||
# which is invisible until the day something actually goes wrong.
|
||||
monkeypatch.setenv("JOY_TTL_S", "0.4")
|
||||
|
||||
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
|
||||
with pytest.raises(ValueError, match="JOY_TTL_S"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_a_keepalive_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
|
||||
# The repeat has to outpace the arm's own velocity expiry. It cannot be read
|
||||
# from here, but JOG_TTL_S already sits below it, so a repeat slower than
|
||||
# JOG_TTL_S is a repeat the arm stops between — an arm that stutters under a
|
||||
# held stick, with nothing in the logs to say why.
|
||||
monkeypatch.setenv("JOG_KEEPALIVE_S", "0.4")
|
||||
|
||||
with pytest.raises(ValueError, match="JOG_KEEPALIVE_S"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_a_keepalive_of_zero_is_refused_at_startup(monkeypatch):
|
||||
# Zero would mean repeating every tick, which is the behaviour the keepalive
|
||||
# exists to replace.
|
||||
monkeypatch.setenv("JOG_KEEPALIVE_S", "0")
|
||||
|
||||
with pytest.raises(ValueError, match="JOG_KEEPALIVE_S"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
@@ -59,9 +87,11 @@ def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value):
|
||||
|
||||
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
|
||||
monkeypatch.setenv("JOG_TTL_S", "0.25")
|
||||
monkeypatch.setenv("JOY_TTL_S", "0.1")
|
||||
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.deadman.jog_ttl_s == 0.25
|
||||
assert config.deadman.joy_ttl_s == 0.1
|
||||
assert config.deadman.gamepad_idle_release_s == 5.0
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""The page and the node are one contract, checked here rather than by eye.
|
||||
|
||||
`ui.py` translates for a console it cannot import, and the page reads telemetry
|
||||
by key. Nothing in either language catches a rename, so a typo would surface as
|
||||
a dead button on a machine that can move — which is the wrong place to find it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from dora_teleop import ui
|
||||
|
||||
PAGE = Path(__file__).resolve().parent.parent / "dora_teleop" / "web" / "index.html"
|
||||
|
||||
|
||||
def page():
|
||||
return PAGE.read_text()
|
||||
|
||||
|
||||
def sent_types():
|
||||
"""Return every message type the page can send."""
|
||||
return set(re.findall(r'type:\s*"([a-z_]+)"', page()))
|
||||
|
||||
|
||||
def read_keys():
|
||||
"""Return the telemetry keys the page renders by name."""
|
||||
listed = re.search(r"const KEYS = \[(.*?)\]", page(), re.DOTALL).group(1)
|
||||
return set(re.findall(r'"([a-z_]+)"', listed))
|
||||
|
||||
|
||||
def snapshot_keys():
|
||||
view = ui.ArmView()
|
||||
view.pose = [1.0] * 6
|
||||
return set(ui.snapshot(view, "web", False, 0.0))
|
||||
|
||||
|
||||
def test_every_message_the_page_sends_is_one_the_node_serves():
|
||||
# `data` is the bridge's own envelope, not a console command.
|
||||
for kind in sent_types() - {"data"}:
|
||||
assert ui.command_of({"type": kind}) is not None or kind == "jog", kind
|
||||
|
||||
|
||||
def test_every_telemetry_key_the_page_reads_is_one_the_node_sends():
|
||||
missing = read_keys() - snapshot_keys()
|
||||
|
||||
assert not missing, f"the page reads keys the node does not send: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_the_page_reads_the_pose_units_from_the_node():
|
||||
# Rather than assuming mm, which is only true of one robot.
|
||||
assert "units.position" in page().replace("telemetry.units && telemetry.units.position", "units.position")
|
||||
assert "units" in snapshot_keys()
|
||||
|
||||
|
||||
def test_the_jog_the_page_sends_is_normalised_and_accepted():
|
||||
# Six axes, each a fraction of full deflection. If the page ever sent a
|
||||
# velocity instead, it would have to know the robot's units.
|
||||
command = ui.command_of(json.loads('{"type": "jog", "axes": [0, -0.6, 0, 0, 0, 0]}'))
|
||||
|
||||
assert command is not None
|
||||
assert command.axes == [0.0, -0.6, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_the_page_can_ask_for_everything_the_node_serves():
|
||||
# `move` is the one command with no button: it is a pose in the robot's own
|
||||
# units, which is exactly what this page is built not to know.
|
||||
served = {"jog", "jog_stop", "estop", "reset", "home", "control", "gripper", "freedrive"}
|
||||
|
||||
assert served <= sent_types()
|
||||
|
||||
|
||||
def test_the_page_gates_its_jog_pad_on_holding_the_lease():
|
||||
# The node refuses a jog from a non-owner and answers with a toast; a pad
|
||||
# that looks live but is refused teaches an operator to distrust the screen.
|
||||
assert 'owner === "web"' in page()
|
||||
|
||||
|
||||
def test_the_published_schemas_do_not_depend_on_the_value_sent():
|
||||
# pyarrow reads a null `value` as its own type, so an inferred schema would
|
||||
# change the type of the `command` port between one verb and the next.
|
||||
from dora_teleop import message
|
||||
|
||||
assert message.pack_command("stop", None).type == message.COMMAND
|
||||
assert message.pack_command("freedrive", 1.0).type == message.COMMAND
|
||||
assert (
|
||||
message.pack_twist({"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}).type == message.TWIST
|
||||
)
|
||||
assert message.pack_jog([0.0] * 6).type == message.JOG
|
||||
+283
-67
@@ -1,15 +1,12 @@
|
||||
"""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
|
||||
from dora_teleop.joy import Sample
|
||||
from tests.conftest import RecordingNode, input_event, joy_message, stick
|
||||
|
||||
STOP = [0.0] * 6
|
||||
PUSHED = [0.0, -1.0]
|
||||
@@ -34,20 +31,32 @@ def clock(monkeypatch):
|
||||
def build():
|
||||
config = Config.from_env()
|
||||
config.expo = 0.0
|
||||
return RecordingNode(), loop.Teleop(config), FakeGamepad()
|
||||
return RecordingNode(), loop.Teleop(config)
|
||||
|
||||
|
||||
def jog_from(node, state, gamepad, count=1):
|
||||
"""Run `count` ticks and return every velocity that reached the arm."""
|
||||
def send_joy(node, state, sample):
|
||||
"""Deliver one reading the way `dora-joy` publishes it, decoding included."""
|
||||
loop._handle(node, state, input_event("joy", joy_message(sample)))
|
||||
|
||||
|
||||
def ticks(node, state, count=1):
|
||||
"""Run `count` ticks with nothing new arriving, and return every velocity sent."""
|
||||
for _ in range(count):
|
||||
loop._tick(node, state, gamepad)
|
||||
loop._tick(node, state)
|
||||
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])))
|
||||
def drive(node, state, sample, count=1):
|
||||
"""Hold one reading for `count` ticks, the way a real pad and joy node would."""
|
||||
for _ in range(count):
|
||||
send_joy(node, state, sample)
|
||||
loop._tick(node, state)
|
||||
return node.jogs()
|
||||
|
||||
|
||||
def web_jog(node, state, axes):
|
||||
"""Deliver one console jog message, in the normalised units the page speaks."""
|
||||
loop._accept_ui_command(node, state, {"type": "jog", "axes": axes})
|
||||
|
||||
|
||||
# --- the gamepad ---
|
||||
@@ -55,10 +64,9 @@ def web_jog(node, state, velocity):
|
||||
|
||||
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)
|
||||
node, state = build()
|
||||
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert state.arbiter.owner == GAMEPAD
|
||||
assert jogs[-1][0] > 0
|
||||
@@ -67,25 +75,49 @@ def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
|
||||
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, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = Sample(present=False)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = drive(node, state, Sample(present=False))
|
||||
|
||||
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)
|
||||
def test_the_joy_node_dying_stops_the_arm(clock):
|
||||
# The pad is another process now, and that process dying looks like silence
|
||||
# rather than like an unplugged pad. It has to mean the same thing: a
|
||||
# reading nobody is refreshing is not evidence that the stick is still held.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
clock.advance(state.config.deadman.joy_ttl_s + 0.01)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_joy_node_that_keeps_publishing_keeps_the_arm_moving(clock):
|
||||
# The mirror of the test above: time passing is not by itself a reason to
|
||||
# stop, or a held stick would stutter.
|
||||
node, state = build()
|
||||
|
||||
sent = []
|
||||
for _ in range(4):
|
||||
clock.advance(0.05)
|
||||
sent = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert sent
|
||||
assert all(jog[0] > 0 for jog in sent)
|
||||
|
||||
|
||||
def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
jogs = drive(node, state, stick())
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
@@ -93,27 +125,65 @@ def test_a_stick_returning_to_centre_stops_the_arm(clock):
|
||||
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, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
gamepad.sample = stick()
|
||||
jogs = jog_from(node, state, gamepad, count=5)
|
||||
jogs = drive(node, state, stick(), 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)
|
||||
def test_a_held_stick_is_not_resent_every_tick(clock):
|
||||
# A velocity is a set-point the arm holds, not a frame in a sequence.
|
||||
# Repeating it every tick made the controller re-plan the same motion twenty
|
||||
# times a second, which an operator feels as the arm stepping rather than
|
||||
# travelling.
|
||||
node, state = build()
|
||||
|
||||
jogs = jog_from(node, state, gamepad, count=4)
|
||||
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||
|
||||
assert len(jogs) == 4
|
||||
assert all(jog[0] > 0 for jog in jogs)
|
||||
assert len(jogs) == 1
|
||||
assert jogs[0][0] > 0
|
||||
|
||||
|
||||
def test_a_held_stick_is_repeated_before_the_arm_would_expire_it(clock):
|
||||
# The repeat exists for one reason: the arm's controller drops a velocity on
|
||||
# its own, and the repeat is what keeps that backstop from firing mid-motion.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
clock.advance(state.config.deadman.jog_keepalive_s)
|
||||
jogs = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert len(jogs) == 1
|
||||
assert jogs[0][0] > 0
|
||||
|
||||
|
||||
def test_a_change_of_direction_never_waits_for_the_repeat(clock):
|
||||
# Sending only on change is worth nothing if the change itself is delayed:
|
||||
# a press or a release has to be acted on the tick it happens, not up to a
|
||||
# keepalive later.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
jogs = drive(node, state, stick(axes=[0.0, 1.0]))
|
||||
|
||||
assert len(jogs) == 1
|
||||
assert jogs[0][0] < 0
|
||||
|
||||
|
||||
def test_a_pad_that_was_never_wired_up_leaves_the_arm_alone(clock):
|
||||
# `joy` is optional: a browser-only console wires no pad at all, and the
|
||||
# node must not read that as a centred stick asking for the arm.
|
||||
node, state = build()
|
||||
|
||||
jogs = ticks(node, state, count=3)
|
||||
|
||||
assert jogs == []
|
||||
assert state.arbiter.owner is None
|
||||
|
||||
|
||||
# --- the browser ---
|
||||
@@ -121,15 +191,15 @@ def test_a_held_stick_is_resent_every_tick(clock):
|
||||
|
||||
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()
|
||||
node, state = 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)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
node.clear()
|
||||
|
||||
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
loop._set_clients(state, 0)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
assert state.arbiter.owner is None
|
||||
@@ -137,41 +207,41 @@ def test_the_last_browser_leaving_stops_the_arm(clock):
|
||||
|
||||
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()
|
||||
node, state = 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)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
node.clear()
|
||||
|
||||
clock.advance(0.5)
|
||||
jogs = jog_from(node, state, gamepad)
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == [STOP]
|
||||
|
||||
|
||||
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
|
||||
node, state, gamepad = build()
|
||||
node, state = 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)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state)
|
||||
sent = node.jogs()
|
||||
|
||||
assert all(jog[0] == pytest.approx(10.0) for jog in sent)
|
||||
assert all(jog[0] == pytest.approx(state.config.speed_mm_s) for jog in sent)
|
||||
|
||||
|
||||
def test_a_non_owner_jog_is_ignored(clock):
|
||||
node, state, gamepad = build()
|
||||
node, state = 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)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
loop._tick(node, state)
|
||||
|
||||
assert node.jogs() == []
|
||||
assert any(message.get("type") == "error" for message in node.ui())
|
||||
@@ -183,33 +253,179 @@ def test_a_non_owner_jog_is_ignored(clock):
|
||||
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()
|
||||
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])))
|
||||
loop._accept_ui_command(node, state, {"type": "estop"})
|
||||
|
||||
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])
|
||||
def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
|
||||
# A stop somebody had to reach for is not undone by the next thumb
|
||||
# movement. Without this the pad stops the arm for one tick and drives it
|
||||
# again on the next, which is not a stop at all.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
loop._tick(node, state, gamepad)
|
||||
drive(node, state, stick(axes=PUSHED, buttons=[False] * 6 + [True]))
|
||||
node.clear()
|
||||
jogs = drive(node, state, stick(axes=PUSHED), count=4)
|
||||
|
||||
assert jogs == []
|
||||
|
||||
|
||||
def test_the_pad_can_release_the_latch_it_set(clock):
|
||||
# A pad-only dataflow has no console RESET, so a latch nothing on the pad
|
||||
# can release would strand the arm until the process was restarted.
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
assert state.view.estop is True
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert state.view.estop is False
|
||||
assert "clear" in node.services()
|
||||
|
||||
|
||||
def test_the_latch_is_released_without_holding_the_arm(clock):
|
||||
# An idle pad hands the lease back two seconds after the stop it just made,
|
||||
# so gating the release on the lease would be a trap.
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
state.arbiter.release(GAMEPAD)
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert state.view.estop is False
|
||||
|
||||
|
||||
def test_the_arm_drives_again_once_the_latch_is_released(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
node.clear()
|
||||
|
||||
jogs = drive(node, state, stick(axes=PUSHED))
|
||||
|
||||
assert jogs[-1][0] > 0
|
||||
|
||||
|
||||
def test_a_latched_estop_stops_the_browser_too(clock):
|
||||
# The latch is the node's, not the pad's. A console jog must not walk
|
||||
# through a stop somebody made on the gamepad.
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
state.arbiter.release(GAMEPAD)
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
node.clear()
|
||||
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
jogs = ticks(node, state)
|
||||
|
||||
assert jogs == []
|
||||
|
||||
|
||||
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
|
||||
node, state = build()
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
|
||||
assert "stop" in node.services()
|
||||
|
||||
|
||||
def test_a_latched_estop_is_cleared_by_reset(clock):
|
||||
node, state, _ = build()
|
||||
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])))
|
||||
loop._accept_ui_command(node, state, {"type": "reset"})
|
||||
|
||||
assert state.view.estop is False
|
||||
assert "clear" in node.services()
|
||||
|
||||
|
||||
# --- the standard ports ---
|
||||
|
||||
|
||||
def test_the_same_deflection_from_either_source_asks_for_the_same_motion(clock):
|
||||
# The whole reason both sources are normalised before they are scaled. If
|
||||
# these ever differ, one path has grown a conversion the other has not —
|
||||
# which is how a console ends up in the robot's units.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||
from_pad = node.twists()[-1]
|
||||
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, clock.t)
|
||||
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
|
||||
ticks(node, state)
|
||||
from_web = node.twists()[-1]
|
||||
|
||||
assert from_pad == from_web
|
||||
|
||||
|
||||
def test_the_twist_is_si_and_the_legacy_jog_is_not(clock):
|
||||
# 60 mm/s is 0.06 m/s. A consumer reading the wrong port at the wrong scale
|
||||
# is a thousandfold error, so this is worth pinning.
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=[0.0, -1.0]))
|
||||
|
||||
twist, jog = node.twists()[-1], node.jogs()[-1]
|
||||
|
||||
assert twist["linear"][0] == pytest.approx(state.config.speed_mm_s / 1000.0)
|
||||
assert jog[0] == pytest.approx(state.config.speed_mm_s)
|
||||
assert twist["frame"] == "base"
|
||||
|
||||
|
||||
def test_a_stop_reaches_both_ports(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(axes=PUSHED))
|
||||
node.clear()
|
||||
|
||||
drive(node, state, stick())
|
||||
|
||||
assert node.jogs() == [STOP]
|
||||
assert node.twists() == [{"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}]
|
||||
|
||||
|
||||
def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
|
||||
# The verb is what a robot speaking the standard reads; the service name is
|
||||
# what dora-ulite6 reads until it does.
|
||||
node, state = build()
|
||||
|
||||
drive(node, state, stick(buttons=[True]))
|
||||
|
||||
assert node.actions() == ["gripper_close"]
|
||||
assert "grasp" in node.services()
|
||||
|
||||
|
||||
def test_every_verb_the_pad_can_ask_for_is_robot_agnostic(clock):
|
||||
node, state = build()
|
||||
drive(node, state, stick(buttons=[False, True]))
|
||||
drive(node, state, stick(buttons=[False] * 3 + [True]))
|
||||
drive(node, state, stick(buttons=[False] * 6 + [True]))
|
||||
drive(node, state, stick(buttons=[False, False, True]))
|
||||
|
||||
assert node.actions() == ["gripper_open", "freedrive", "stop", "clear"]
|
||||
|
||||
|
||||
def test_the_first_press_after_the_pad_has_been_idle_is_not_dropped(clock):
|
||||
# The lease has to be settled before the buttons are read. Read them first
|
||||
# and the press arrives while nobody owns the arm, and by the next tick the
|
||||
# button is held rather than newly down — so the edge is gone for good and
|
||||
# the operator presses A and watches nothing happen.
|
||||
node, state = build()
|
||||
assert state.arbiter.owner is None
|
||||
|
||||
drive(node, state, stick(buttons=[True]), count=3)
|
||||
|
||||
assert node.actions() == ["gripper_close"]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The pad as it arrives on the wire, and what silence on that wire means."""
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from dora_teleop.joy import ABSENT, JoyInput, Sample, decode
|
||||
from tests.conftest import input_event, joy_message, stick
|
||||
|
||||
|
||||
def joy_event(sample):
|
||||
return input_event("joy", joy_message(sample))
|
||||
|
||||
|
||||
def test_a_reading_survives_the_round_trip():
|
||||
sample = decode(joy_event(stick(axes=[0.5, -0.5], buttons=[False, True])))
|
||||
|
||||
assert sample.axes[:2] == [0.5, -0.5]
|
||||
assert sample.buttons == [False, True]
|
||||
assert sample.present is True
|
||||
|
||||
|
||||
def test_an_absent_pad_decodes_as_absent_not_as_centred():
|
||||
sample = decode(joy_event(ABSENT))
|
||||
|
||||
assert sample.present is False
|
||||
assert sample.axes == []
|
||||
|
||||
|
||||
def test_a_pad_with_fewer_axes_than_the_layout_decodes_without_complaint():
|
||||
# A three-axis flight stick is a strange thing to drive an arm with, but it
|
||||
# must not take the node down; `mapping.axis` treats a missing one as centred.
|
||||
sample = decode(joy_event(Sample(axes=[0.0, 0.0, 0.0], buttons=[False], present=True)))
|
||||
|
||||
assert len(sample.axes) == 3
|
||||
|
||||
|
||||
def test_a_message_with_no_rows_reads_as_absent():
|
||||
empty = input_event("joy", pa.array([], type=joy_message(ABSENT).type))
|
||||
|
||||
assert decode(empty) is ABSENT
|
||||
|
||||
|
||||
def test_a_fresh_reading_is_handed_back():
|
||||
joy = JoyInput(ttl_s=0.2)
|
||||
sample = stick(axes=[0.5])
|
||||
joy.accept(sample, 100.0)
|
||||
|
||||
assert joy.read(100.1) is sample
|
||||
|
||||
|
||||
def test_a_reading_nobody_is_refreshing_reads_as_absent():
|
||||
# dora-joy publishes every tick, so silence means it died — and a dead pad
|
||||
# node is indistinguishable from an unplugged pad as far as the arm cares.
|
||||
joy = JoyInput(ttl_s=0.2)
|
||||
joy.accept(stick(axes=[0.5]), 100.0)
|
||||
|
||||
assert joy.read(100.5) is ABSENT
|
||||
|
||||
|
||||
def test_nothing_ever_arriving_reads_as_absent():
|
||||
# A dataflow with no `joy` wired at all, which is a browser-only console.
|
||||
assert JoyInput(ttl_s=0.2).read(100.0) is ABSENT
|
||||
@@ -108,3 +108,35 @@ def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config):
|
||||
|
||||
assert velocity[VX] == 0.0
|
||||
assert velocity[WZ] == 0.0
|
||||
|
||||
|
||||
# --- is the pad being used at all ---
|
||||
|
||||
|
||||
def test_a_pad_at_rest_is_not_asking_for_the_arm():
|
||||
# SDL rests a trigger at -1.0, so a raw threshold reports every connected
|
||||
# pad as in use for ever: the pad claims the arm the moment it is plugged
|
||||
# in and GAMEPAD_IDLE_RELEASE_S never fires.
|
||||
assert mapping.is_active(stick()) is False
|
||||
|
||||
|
||||
def test_an_absent_pad_is_not_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(present=False)) is False
|
||||
|
||||
|
||||
def test_a_nudged_stick_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(axes=[0.5])) is True
|
||||
|
||||
|
||||
def test_a_held_trigger_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(axes=[0.0, 0.0, 1.0])) is True
|
||||
|
||||
|
||||
def test_a_held_button_is_asking_for_the_arm():
|
||||
assert mapping.is_active(stick(buttons=[True])) is True
|
||||
|
||||
|
||||
def test_an_axis_the_layout_does_not_use_still_counts():
|
||||
# A pad reporting its d-pad as two extra axes is asking for the arm when
|
||||
# somebody presses it, whether or not this node maps it to anything.
|
||||
assert mapping.is_active(stick(axes=[0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 1.0])) is True
|
||||
|
||||
+13
-6
@@ -1,7 +1,7 @@
|
||||
"""The probe: what it draws, without a pad or a terminal."""
|
||||
|
||||
from dora_teleop import mapping, probe
|
||||
from dora_teleop.gamepad import Sample
|
||||
from dora_teleop.joy import Sample
|
||||
from tests.conftest import stick
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ def test_a_reading_beyond_full_deflection_stays_on_the_bar():
|
||||
assert "#" in probe.bar(4.0)
|
||||
|
||||
|
||||
def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config):
|
||||
config.joystick_index = 2
|
||||
|
||||
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
|
||||
# Absence covers both "no pad" and "dora-joy stopped sending", and the probe
|
||||
# must not imply the arm is waiting on something it is not.
|
||||
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
|
||||
|
||||
assert "no gamepad found" in drawn
|
||||
assert "index 2" in drawn
|
||||
assert "no gamepad" in drawn
|
||||
assert "hold still" in drawn
|
||||
|
||||
|
||||
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
|
||||
@@ -79,3 +79,10 @@ def test_a_moving_arm_is_reported_as_moving(config):
|
||||
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
|
||||
|
||||
assert "moving: yes" in drawn
|
||||
|
||||
|
||||
def test_whether_the_pad_is_asking_for_the_arm_is_shown(config):
|
||||
# The probe is where a resting trigger claiming the arm would have been
|
||||
# visible, so it says what `is_active` thinks rather than leaving it implied.
|
||||
assert "asking for the arm: no" in probe.render(stick(), [0.0] * 6, config)
|
||||
assert "asking for the arm: yes" in probe.render(stick(axes=[0.0, -1.0]), [0.0] * 6, config)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""The embedded console server: a real socket, and the deadman it feeds.
|
||||
|
||||
These start the server for real rather than faking it. The thing worth checking
|
||||
is the boundary between the two threads — a browser's message reaching the tick,
|
||||
and a browser going away reaching the deadman — and a fake on either side of
|
||||
that boundary would check nothing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from dora_teleop import main as loop
|
||||
from dora_teleop.arbiter import WEB
|
||||
from dora_teleop.config import Config, ConsoleConfig
|
||||
from dora_teleop.server import Console
|
||||
from tests.conftest import RecordingNode
|
||||
|
||||
|
||||
def free_port():
|
||||
"""Ask the OS for a port nobody is using."""
|
||||
with socket.socket() as held:
|
||||
held.bind(("127.0.0.1", 0))
|
||||
return held.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console():
|
||||
"""Run a real server on a real port, and stop it however the test ends."""
|
||||
running = Console("127.0.0.1", free_port())
|
||||
running.start()
|
||||
try:
|
||||
yield running
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
def visit(console, exchange):
|
||||
"""Open one browser's socket against the server and run `exchange` on it."""
|
||||
|
||||
async def session():
|
||||
async with aiohttp.ClientSession() as client:
|
||||
url = f"http://{console.host}:{console.port}"
|
||||
async with client.ws_connect(f"{url}/ws") as socket_:
|
||||
return await exchange(client, url, socket_)
|
||||
|
||||
return asyncio.run(session())
|
||||
|
||||
|
||||
def wait_for(predicate):
|
||||
"""Block until the server thread catches up, rather than guessing at a sleep."""
|
||||
for _ in range(200):
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("the server never got there")
|
||||
|
||||
|
||||
def test_the_page_is_served_at_the_root(console):
|
||||
async def exchange(client, url, socket_):
|
||||
async with client.get(url) as answer:
|
||||
return answer.status, await answer.text()
|
||||
|
||||
status, body = visit(console, exchange)
|
||||
|
||||
assert status == 200
|
||||
assert "teleop console" in body
|
||||
|
||||
|
||||
def test_a_message_from_a_browser_reaches_the_tick(console):
|
||||
# The console's half of the boundary: what the page sends has to be waiting
|
||||
# on the queue the next tick drains, in the shape the loop reads.
|
||||
async def exchange(client, url, socket_):
|
||||
await socket_.send_str(json.dumps({"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
visit(console, exchange)
|
||||
|
||||
assert console.take() == [{"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}]
|
||||
assert console.take() == []
|
||||
|
||||
|
||||
def test_a_snapshot_reaches_the_browser(console):
|
||||
# The other half: a telemetry object handed over from the dora thread comes
|
||||
# out of the socket as the page's own JSON, with no envelope around it.
|
||||
async def exchange(client, url, socket_):
|
||||
console.broadcast({"type": "telemetry", "arm_state": "idle"})
|
||||
return json.loads(await socket_.receive_str())
|
||||
|
||||
assert visit(console, exchange) == {"type": "telemetry", "arm_state": "idle"}
|
||||
|
||||
|
||||
def test_a_browser_that_is_not_talking_json_is_ignored(console):
|
||||
# A page from a newer build saying something we cannot parse must not take
|
||||
# the arm down, and must not stop the messages after it being read.
|
||||
async def exchange(client, url, socket_):
|
||||
await socket_.send_str("{not json")
|
||||
await socket_.send_str(json.dumps({"type": "estop"}))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
visit(console, exchange)
|
||||
|
||||
assert console.take() == [{"type": "estop"}]
|
||||
|
||||
|
||||
def test_the_server_counts_the_browsers_on_it(console):
|
||||
assert console.clients == 0
|
||||
seen = []
|
||||
|
||||
async def exchange(client, url, socket_):
|
||||
await asyncio.sleep(0.05)
|
||||
seen.append(console.clients)
|
||||
|
||||
visit(console, exchange)
|
||||
|
||||
assert seen == [1]
|
||||
|
||||
# And the socket closing is what the deadman below reads.
|
||||
wait_for(lambda: console.clients == 0)
|
||||
|
||||
|
||||
def test_a_flood_from_one_browser_cannot_grow_without_bound(console):
|
||||
# An unbounded queue would answer a flood by growing until the node died,
|
||||
# and the node dying is the one thing that must not stop the arm from being
|
||||
# stopped.
|
||||
async def exchange(client, url, socket_):
|
||||
for index in range(600):
|
||||
await socket_.send_str(json.dumps({"type": "jog_stop", "n": index}))
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
visit(console, exchange)
|
||||
|
||||
assert 0 < len(console.take()) <= 256
|
||||
|
||||
|
||||
# --- where the server meets the deadman ---
|
||||
|
||||
|
||||
def build():
|
||||
config = Config.from_env()
|
||||
config.expo = 0.0
|
||||
return RecordingNode(), loop.Teleop(config)
|
||||
|
||||
|
||||
def test_the_last_browser_leaving_takes_the_arm_back(console):
|
||||
# The web deadman used to arrive as a `clients` message from the bridge. It
|
||||
# is now read straight off the server, and it has to mean the same thing.
|
||||
node, state = build()
|
||||
state.console = console
|
||||
state.arbiter.take(WEB, 1000.0)
|
||||
|
||||
loop._drain_console(node, state)
|
||||
|
||||
assert state.clients == 0
|
||||
assert state.arbiter.owner is None
|
||||
assert state.web_axes == [0.0] * 6
|
||||
|
||||
|
||||
def test_a_node_with_no_console_is_left_alone():
|
||||
# A pad-only dataflow opens no port, and the tick must not read a lease
|
||||
# release out of a server that was never started.
|
||||
node, state = build()
|
||||
state.clients = 1
|
||||
state.arbiter.take(WEB, 1000.0)
|
||||
|
||||
loop._drain_console(node, state)
|
||||
|
||||
assert state.arbiter.owner == WEB
|
||||
|
||||
|
||||
# --- the port itself ---
|
||||
|
||||
|
||||
def test_the_console_is_on_by_default(monkeypatch):
|
||||
monkeypatch.delenv("WEB_PORT", raising=False)
|
||||
settings = ConsoleConfig.from_env()
|
||||
|
||||
assert settings.enabled is True
|
||||
# Pinned because it is the number in the README, the manifest and every
|
||||
# dataflow: a default that drifts from the docs sends an operator to a port
|
||||
# nothing is listening on.
|
||||
assert settings.port == 7000
|
||||
|
||||
|
||||
def test_port_zero_means_no_port_is_opened(monkeypatch):
|
||||
# How `dataflow-gamepad.yml` asks for a pad and nothing else.
|
||||
monkeypatch.setenv("WEB_PORT", "0")
|
||||
assert ConsoleConfig.from_env().enabled is False
|
||||
|
||||
|
||||
def test_a_port_that_is_not_a_port_stops_the_node(monkeypatch):
|
||||
monkeypatch.setenv("WEB_PORT", "99999")
|
||||
with pytest.raises(ValueError, match="WEB_PORT"):
|
||||
ConsoleConfig.from_env()
|
||||
|
||||
|
||||
def test_a_port_that_is_not_a_number_stops_the_node(monkeypatch):
|
||||
monkeypatch.setenv("WEB_PORT", "eight-thousand")
|
||||
with pytest.raises(ValueError, match="WEB_PORT"):
|
||||
ConsoleConfig.from_env()
|
||||
|
||||
|
||||
def test_a_port_already_in_use_stops_the_node(console):
|
||||
# Found the hard way: the bind failed on the server thread, `start` waited
|
||||
# out its timeout and then announced a console anyway, and the node ran on
|
||||
# serving nothing. An operator opening the URL got silence while the log
|
||||
# said it was up.
|
||||
second = Console(console.host, console.port)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
second.start()
|
||||
|
||||
second.stop()
|
||||
|
||||
|
||||
def test_stopping_a_console_that_never_started_is_harmless():
|
||||
# `main` stops the console in a `finally`, which runs whether or not the
|
||||
# server ever came up.
|
||||
Console("127.0.0.1", 0).stop()
|
||||
+8
-7
@@ -7,18 +7,18 @@ from dora_teleop.arbiter import GAMEPAD, WEB
|
||||
|
||||
|
||||
def test_a_jog_carries_its_six_numbers():
|
||||
command = ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, 6]})
|
||||
command = ui.command_of({"type": "jog", "axes": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
|
||||
|
||||
assert command.type == "jog"
|
||||
assert command.velocity == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||
assert command.axes == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
|
||||
|
||||
|
||||
def test_a_jog_of_the_wrong_length_is_not_a_jog():
|
||||
assert ui.command_of({"type": "jog", "velocity": [1, 2, 3]}) is None
|
||||
assert ui.command_of({"type": "jog", "axes": [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
|
||||
assert ui.command_of({"type": "jog", "axes": [1, 2, 3, 4, 5, "fast"]}) is None
|
||||
|
||||
|
||||
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
|
||||
@@ -36,8 +36,8 @@ def test_taking_control_carries_whether_it_may_preempt():
|
||||
|
||||
|
||||
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
|
||||
assert ui.command_of({"type": "gripper", "closed": True}).enabled is True
|
||||
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
|
||||
|
||||
|
||||
def test_a_move_needs_a_position():
|
||||
@@ -109,7 +109,8 @@ def test_the_snapshot_carries_every_key_the_page_reads_by_name():
|
||||
"arm_state",
|
||||
"owner",
|
||||
"estop",
|
||||
"vacuum",
|
||||
"gripper",
|
||||
"units",
|
||||
"freedrive",
|
||||
"position",
|
||||
"joints",
|
||||
|
||||
@@ -6,6 +6,155 @@ resolution-markers = [
|
||||
"python_full_version < '3.12'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
{ name = "aiosignal" },
|
||||
{ name = "attrs" },
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -41,12 +190,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dora-teleop"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "dora-rs" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pygame-ce" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -57,9 +206,9 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.9" },
|
||||
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||
{ name = "pyarrow", specifier = ">=17.0" },
|
||||
{ name = "pygame-ce", specifier = ">=2.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -68,6 +217,120 @@ dev = [
|
||||
{ name = "ruff", specifier = ">=0.9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -77,6 +340,164 @@ 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 = "multidict"
|
||||
version = "6.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/90216392620b6ef8704eb0bc055141745de396121067e98f1f72bdac33c3/multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794", size = 85033, upload-time = "2026-09-09T13:53:18.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bb/e01b8cf906479b2fa046e992b84a9d9f39c1ed4058acc353004cd9a04df9/multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6", size = 51008, upload-time = "2026-09-09T13:53:20.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/da/35d70c920812d9ddc6f295f6426457665194335fbc35aa0b96716aba219f/multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712", size = 50232, upload-time = "2026-09-09T13:53:21.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/6b/4c988a7c0daa4fbffc6080ed3c37b3a67cf225ba1de69d10a19ca1dd8d0d/multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd", size = 271678, upload-time = "2026-09-09T13:53:22.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/2b/22c7de8a72fc5c36390e8049d86d842b032ac7c87ade035a3dafb7df4ffc/multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac", size = 270283, upload-time = "2026-09-09T13:53:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/c1428318f945c57c016ba690338af41f87f18a7d3a7ef3227b1440a2c169/multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f", size = 245306, upload-time = "2026-09-09T13:53:25.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/92/a37f7519fb32b0bf43b0540292effe60edaf0691959214b227795bd3d56a/multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891", size = 279567, upload-time = "2026-09-09T13:53:27.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/fe/a93c2ce417401863cc88ecf6561577625c140990d412e37f347a1c03a144/multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f", size = 282526, upload-time = "2026-09-09T13:53:28.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9d/6bb4f84fdd82acfa09dc312ac133e7f76cbf2370004447f2a90e65e5d63f/multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca", size = 272604, upload-time = "2026-09-09T13:53:30.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/97/df0a30a4d786d313f24b39cb96edaaa3bbfe83a0b309577c81a797783ec5/multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5", size = 250786, upload-time = "2026-09-09T13:53:32.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/72/e59a917680d00214ba41f9fec19a8bec48f3bdf62656bc4377f37ae30947/multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4", size = 265419, upload-time = "2026-09-09T13:53:33.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/21/0eb8868982ff07c1a2faaef7502ee0be32ef247dc1bf27881c51e7f4b20d/multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15", size = 258934, upload-time = "2026-09-09T13:53:35.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a6/0586396716faf950c10ffbe733e4a57b4eeda9f7073e60c09bd4a05a766e/multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2", size = 273168, upload-time = "2026-09-09T13:53:37.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/79/7197af20190d0d832be3b18582be46c224f64f5ba1cd35d32068d6af31ed/multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6", size = 275884, upload-time = "2026-09-09T13:53:38.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/f7/af60573e25ffecc09e805464580d579338cf1a44332ab52757038435ed60/multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec", size = 246967, upload-time = "2026-09-09T13:53:40.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/02/4459f8c5025ab034d3d9af9a34bbde11319016cff2f327362c8ec43a80a1/multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b", size = 272358, upload-time = "2026-09-09T13:53:41.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/99/680d3522ab51a77094d31d7958c9f5989499a01ffbe5aebe11d25212b385/multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee", size = 267450, upload-time = "2026-09-09T13:53:43.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/04/d0c773805b0aea171287b01a82e4c28c59ef2c2d93e8047394765181363f/multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83", size = 46847, upload-time = "2026-09-09T13:53:45.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f8/1a959771a4dcd3224bd7bb40054f66b98ba5b20d6b74fd273f548f887e0a/multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463", size = 51549, upload-time = "2026-09-09T13:53:46.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7c/3a74b11599a9d8f3cfbb78b9c5cac3ff3cdc17278e4c00329c7c18dcaff9/multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035", size = 48020, upload-time = "2026-09-09T13:53:47.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8a/8774f5b3f6d5266ecd1117876e04b405f0f1ce19aa750b35a826efe6cfe4/multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5", size = 278619, upload-time = "2026-09-09T13:53:53.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/47/736080fec911ed9f2dd57ccab5a8145e4f17c4987de0bfc27bee20e4d170/multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a", size = 283771, upload-time = "2026-09-09T13:53:55.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d5/b7f41f59b0583f092602308a5e7c16ec5efd00d60214b22511e89a38dd19/multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40", size = 262108, upload-time = "2026-09-09T13:53:56.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b2/a52dc06c6e2598672308e3d392fd85b837b23c25dda459bedaea84985080/multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d", size = 289899, upload-time = "2026-09-09T13:53:58.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/00cda7983f37d119b86f1f89d5b4cf771ecb6d0fedeb9a0971758d6d6d4a/multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874", size = 293025, upload-time = "2026-09-09T13:53:59.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/c7/4544cc02e45bbfac4d8788b05379bb360021fd8c53fa74b0f624126ac188/multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b", size = 287410, upload-time = "2026-09-09T13:54:01.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1a/7abed90b8eba381842235bfa6f4d730204fd7deb374fc87e3ec9b2c2b4ac/multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c", size = 255878, upload-time = "2026-09-09T13:54:03.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3e/73fae10e15fc4d711975337caff7e494c87de5d0189afe3518b21b945326/multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081", size = 277831, upload-time = "2026-09-09T13:54:04.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/cf/01cfc81492933331147004861bdff201d8adeba8485ecd8f490e755fe7e8/multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f", size = 275096, upload-time = "2026-09-09T13:54:06.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/59/e9a3773b17297fa1e38fd4b3c6f5f2f458380796be62eca7d0d77c250618/multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b", size = 279803, upload-time = "2026-09-09T13:54:08.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/9d/2d712a2605b3971908e3b4f5eb6f98c353d9991e106f684d0e08ae581814/multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742", size = 284595, upload-time = "2026-09-09T13:54:10.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6c/21aded8586e552b29892268c576e5745d1a894c5451c9866ca3c06b7ec50/multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39", size = 252641, upload-time = "2026-09-09T13:54:11.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/70/56a415ae0a45e5eae2ec817d46aeb72a1ae777863621c85f1f39d329275b/multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0", size = 283369, upload-time = "2026-09-09T13:54:13.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7e/7b7cd611fd94bf2f6bd16244c50495867ba394d5baaf8e6e487d39494ab3/multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb", size = 281653, upload-time = "2026-09-09T13:54:15.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/4a/b19a5892ef2ef6c68ae278b4f1504b82e01037baedd92c55d37e55ecad00/multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90", size = 47936, upload-time = "2026-09-09T13:54:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/00/1952f9f282aa71e7c3db3a6b47afb689d0ddf283dbded7e6326a91d421c9/multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630", size = 51723, upload-time = "2026-09-09T13:54:18.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b5/c9d57dbafe25b8f3460ce2961c968539a81ff7a70160c44dcfd4255cbcd1/multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395", size = 48492, upload-time = "2026-09-09T13:54:19.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/1f/d7112c2dd7db02677097be72fb65542f51a5aa73cb472b87ec211ba9e0dd/multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f", size = 54197, upload-time = "2026-09-09T13:54:20.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/876015abbcb4a179d946579eb77b778eb5a948fc8381bc7928ba895bc051/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943", size = 47787, upload-time = "2026-09-09T13:54:22.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/ceb7d25f8a567599db2eb19b08cac58d67ff553cff42dcadbea9aba56a20/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9", size = 48815, upload-time = "2026-09-09T13:54:23.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e3/e1c6e9c3818c34b782f23ce5fdba3eaa34ec6750dc53078dfac80fa59be7/multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916", size = 83484, upload-time = "2026-09-09T13:54:25.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a0/c23f78a4badee9a5b3e760495c661c62a92c340a1dfd00f829cd16e256bb/multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435", size = 50763, upload-time = "2026-09-09T13:54:27.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/fe/db552d402a3f6b650f5d3ae11b82b93833836aebb51bcda22d8691121129/multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da", size = 49029, upload-time = "2026-09-09T13:54:28.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b4/546853fba19dcef77cdf91fc173faf0b02284a49106cf250511166b4ec5c/multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8", size = 278863, upload-time = "2026-09-09T13:54:30.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/3f/4b52dac7db547936eb762123ac1d99df23f92fdb358bae600e322f611247/multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33", size = 283915, upload-time = "2026-09-09T13:54:31.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/6e/c0dfbf170e49a91bcb9ce850d51cb98357f3033c5227529200ca7625853e/multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e", size = 260704, upload-time = "2026-09-09T13:54:33.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/02/56973a060ab8dfc2e80bb6797682f6577aff7123cdb1de1a568670ae3499/multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f", size = 290243, upload-time = "2026-09-09T13:54:35.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/67/69112989f131bdea4a87b74e82cb0a2daf37880cd92b0e6f0420020adceb/multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735", size = 291131, upload-time = "2026-09-09T13:54:37.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/75/9435f68b0cfc442d4917de85c26f2b2e1292630883414a25576083fa2469/multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384", size = 287551, upload-time = "2026-09-09T13:54:38.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/08/2ee4838081d6587849611aa7ec722c4cb2469e912fd0eaee980e7bac064c/multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18", size = 254591, upload-time = "2026-09-09T13:54:40.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f1/05673b51191f77f4198b8e4b35f16ea71c0300c72ca8aa027a66a61b6edc/multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238", size = 278204, upload-time = "2026-09-09T13:54:42.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4f/b6cf74322b3fbd3e011a1e903730191922291a7779f6d404114c2189b806/multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e", size = 275600, upload-time = "2026-09-09T13:54:44.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ab/958bbb04377159ff03c7314cd9d8a48dd6fc4f78c840589c22ab155ee9c7/multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e", size = 279793, upload-time = "2026-09-09T13:54:46.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/3a/706605ab0dfc4179748ee7949829e63c6f14ae28667aceeefaf2c701807f/multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c", size = 284751, upload-time = "2026-09-09T13:54:47.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a5/567e36c013ad023546de633079c6b22101dd43226b193cba00e6399703be/multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc", size = 250812, upload-time = "2026-09-09T13:54:49.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5f/6b0b64aa0cd346b07831dabaa6ccda0e73014c5df044b68baa763f0f0552/multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc", size = 281606, upload-time = "2026-09-09T13:54:51.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/8c/b846b6796f26d496efb07fedef2b69f6de533da32a56f12d236722a96157/multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5", size = 281733, upload-time = "2026-09-09T13:54:53.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f3/bf14a39d4af5697fd9404baaf70a0aeeb82d258b95de5cb16b1a7f98ae6f/multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20", size = 47738, upload-time = "2026-09-09T13:54:54.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0a/598511a5741a3cb374971b3b02eda8a09896118ba528a54795f7e7e8bfb4/multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706", size = 51609, upload-time = "2026-09-09T13:54:56.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/b7/6f5c1bd4ffe42d4a6db0f2f65491d4088e9c25c990358fb31a614621d664/multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316", size = 48280, upload-time = "2026-09-09T13:54:58.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/85/153341590e233a967c1d6791a83402d01693dec0f4c1f695606ef16c7ed2/multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc", size = 53758, upload-time = "2026-09-09T13:54:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ff/44f72d516ece0398683ef52061797d83a74b16b8c1e4587408e97959d783/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab", size = 47495, upload-time = "2026-09-09T13:55:01.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/5f/6e118f761b024dd35d26c2fe7ba41572bb0e8ac5f8cfccbbcbc2ff76da4e/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d", size = 48540, upload-time = "2026-09-09T13:55:02.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/4b/3eed744491b32f0e318e7db89dc06858732362f706e8d045fa9ab51a343a/multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38", size = 83130, upload-time = "2026-09-09T13:55:04.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/de/95c2c0ddcccb9a41ffbaa5df8ea059a8ff81916b7617a8847ecd89ed8061/multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11", size = 50574, upload-time = "2026-09-09T13:55:06.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/b7/f4f4989594f99bc121ad9277090c4e49819b08ab1a96e132b628a9e10b7d/multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d", size = 48786, upload-time = "2026-09-09T13:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/86/f1d86a0222f31fb3df8eef3d6c9abf7e8d65d49edd8d0d7e7afaf23d23cc/multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc", size = 276670, upload-time = "2026-09-09T13:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/50/6945c50f86a978b2bcace9ca344165ff80883be47d984489bbba8fa0ab20/multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef", size = 279339, upload-time = "2026-09-09T13:55:11.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/2c/e649889ba23fd1f4442a85427b99d9e6261226b2ac31914aa7f5b241d947/multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602", size = 252549, upload-time = "2026-09-09T13:55:13.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f8/1023b66e011b1395fb160dabb0f0608ef67e569f0bdb2c1d5ac9b2f2adc6/multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c", size = 286203, upload-time = "2026-09-09T13:55:15.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/6c/48aea545cbda6d0444848ec23d988c13b86538a00a1b7d3868cc2382ff94/multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a", size = 285039, upload-time = "2026-09-09T13:55:16.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/2a/066123b17291671bf67d2a5c65ee81a48de53913bd1b1578791519eacdb0/multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7", size = 281075, upload-time = "2026-09-09T13:55:19.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/20/4f0b2c485da2e8a659cc677717a3745872918c9c85064491a1ef75d7a3bf/multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af", size = 250431, upload-time = "2026-09-09T13:55:21.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c7/b9a288901577aa0b82c33c64d52246c88076d260ad7b6c16b021ca0f8e99/multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee", size = 273891, upload-time = "2026-09-09T13:55:22.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/51/0ba50cab2cfd067988de2abb73f23076ac727fe18d03f1368a59def64727/multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364", size = 265262, upload-time = "2026-09-09T13:55:24.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/d6/e5be1117dbca6eb9ce231142b7e20599418bb3500147db51bf844ce8afcb/multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c", size = 278033, upload-time = "2026-09-09T13:55:26.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/28/cad0afaec3caa56ea2c1ceed43c164d62ad3e83e950daf0d0c87bcf9dca7/multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd", size = 281717, upload-time = "2026-09-09T13:55:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d2/025702df0b69b856db70a4d66f77622f51c3d99771ec9a07f3ca80f7e098/multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891", size = 247124, upload-time = "2026-09-09T13:55:30.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/96/9dddca563f06a921956389c0bc9b894355b98b0bdf62299e2560c50afb6d/multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d", size = 275954, upload-time = "2026-09-09T13:55:32.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/91/8b2f1f2a774a955665f268340a2b59db7020c5f12baac02ae9ef1b1660cf/multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb", size = 275508, upload-time = "2026-09-09T13:55:34.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/1a/e2cabdfc0880a61a99d2b8bc361035036fb5a2c6af31ea3fa054ba1065c5/multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52", size = 46938, upload-time = "2026-09-09T13:55:36.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/7c/11234bcba62c22a58f2ba168499cfe3531f49de3edd5090d04a8c6cdc936/multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a", size = 50291, upload-time = "2026-09-09T13:55:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/61/793668439df924752a8137d6db0de97ed1add494779b01e4764dfc60571b/multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f", size = 47622, upload-time = "2026-09-09T13:55:39.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b8/3c091b929e6b5b2f6e0eba2232178e76d4503c8b96b92dfc281ff1d823be/multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04", size = 88789, upload-time = "2026-09-09T13:55:41.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d7/3df83fab22dd64615db71e3b3cc1346b581d1459719637ce52144f9f6558/multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab", size = 53399, upload-time = "2026-09-09T13:55:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/78/41bd04c04b0aed16540c4856c9e012afc1c254298da154398308df05e26a/multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9", size = 51597, upload-time = "2026-09-09T13:55:44.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6b/7bc4cdddf624e1e7e0231734b1331729ea46df10d7c8fd3fce79756e7d0e/multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e", size = 264391, upload-time = "2026-09-09T13:55:46.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/d2a946e5938771e92c39354563e535ef6bc6dfe399dd4307c6df8dfea183/multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58", size = 264680, upload-time = "2026-09-09T13:55:49.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/3f9e981c42e7eb9329918523f0f9362ceb0ac3ee0ee1165c28f674249d75/multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91", size = 235420, upload-time = "2026-09-09T13:55:51.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e1/a3a33a039fb6d381800ae5d1d587b697b8c27fcdfe48819420f08703acba/multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4", size = 270309, upload-time = "2026-09-09T13:55:54.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5d/8b06724a957f2e480f159b9550988a67810fbe9555a09c5f6a2a4b829607/multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad", size = 275169, upload-time = "2026-09-09T13:55:55.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/32/8f3dfe2ffa5d0df2a95f71e63c2f11fe3b5e1771f26ef73bb1af84de83f8/multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385", size = 264900, upload-time = "2026-09-09T13:55:57.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/73/d5829fc00a055d6ab445e0876346ee9cdee670766cd4190dc0a496188c0f/multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4", size = 242486, upload-time = "2026-09-09T13:56:00.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/58/e8d7874038e31e0533182d1c3c5331a856b9c849a71bb26a21850e8c91e1/multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff", size = 259916, upload-time = "2026-09-09T13:56:01.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f8/1b56a7401acda20efc016440f4fad3bef66c4aee54ca080ec143881ebb0d/multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6", size = 251209, upload-time = "2026-09-09T13:56:03.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/5b/68d67a9e302b0645a747ba910c30eb41f2834fcdc1d85f53eae2dfceee0a/multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110", size = 264505, upload-time = "2026-09-09T13:56:05.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/13/4dc304ba2c5f5307b474ab2ce1ed1f6b02b0b4e233c182e3981ed436c2e3/multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b", size = 264916, upload-time = "2026-09-09T13:56:09.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/0f/7b1f729d18369915009185201be5d0b8df0e525340fe6a600d2f8441d6cf/multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0", size = 236839, upload-time = "2026-09-09T13:56:11.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d1/eba1b88b18b7019d9136303fe77909257c40fabde5aaf138a4d900b6ce3c/multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78", size = 265307, upload-time = "2026-09-09T13:56:13.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a6/6c1e4106faa27118ac612f4d664eaf909de252634785286262a627108e58/multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b", size = 259041, upload-time = "2026-09-09T13:56:15.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/bc/ecfb8b6faa8e158a71b03bdf7f947f30e0bc5d899cc357573a76ab7bb1e5/multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2", size = 50628, upload-time = "2026-09-09T13:56:17.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/7f/e27fb699b70ad24dbd02ddee604658acb36f907c03c045baffe4ea774501/multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26", size = 55592, upload-time = "2026-09-09T13:56:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/7a/76de70b2f6733696803f1ee56abe44a3757a52777383032c7373d3fea0f4/multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb", size = 50300, upload-time = "2026-09-09T13:56:21.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/32/4de7320ae032dc768090d11f708d2d386df3db04cb6b8b0db0230cfc66c3/multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3", size = 53761, upload-time = "2026-09-09T13:56:23.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/45/ecb641309dc2cdc6040f18e22c68eb5e94398f9404c4365d810f4292e053/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25", size = 47505, upload-time = "2026-09-09T13:56:24.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/68/87d6161b9fef11943e0b894203da3fff561933ca3c9b2952b6e7100e9c9f/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c", size = 48549, upload-time = "2026-09-09T13:56:26.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/f7/d852d2276407640cdbd29fe11cac6e93f70f59542cba174ef9d146738946/multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23", size = 83157, upload-time = "2026-09-09T13:56:28.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e3/16fe7ffa6090591d83cf6bc2486e77ce891705fb6d0191823140928311b5/multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15", size = 50578, upload-time = "2026-09-09T13:56:30Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/0c/e38e41c1087a599f86ff58a01f358abf7c4db3c26a3e90eebb3e02193ef1/multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7", size = 48815, upload-time = "2026-09-09T13:56:32.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f0/eb691f42af8e7775992f57904ec75dc356fc7cdc896e5f30879decdd26f2/multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba", size = 274804, upload-time = "2026-09-09T13:56:36.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/05/28472ccfeb43c00a043c0385ca4294da21a5957859fb7860e2ebdb3e3011/multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e", size = 279693, upload-time = "2026-09-09T13:56:38.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a1/2b4fe73e5fecff807b47650a155c391a103136428cb21d6ba8e39c5912b5/multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b", size = 254969, upload-time = "2026-09-09T13:56:40.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/e0/c97d1822783dfe52e02fd150fa3f02eb22410211a9e2615f71541803ed4b/multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31", size = 286392, upload-time = "2026-09-09T13:56:42.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d9/772f1339e1d051236bcc137b0eac2b4aaaa0bbb56aaf924e9aaba901d9c1/multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d", size = 285348, upload-time = "2026-09-09T13:56:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/cd045747e4680362e02955a82c468e95b5e4d319e3a79574b3fb677de568/multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3", size = 282721, upload-time = "2026-09-09T13:56:46.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/14/0802d9a3aae4ef21eaa39adbd729a380fa095932105e1424e417b53e783f/multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc", size = 253168, upload-time = "2026-09-09T13:56:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/64/3f92298bab8fbe1332e708863fb55b66e755be6f416b3459720d48b33af9/multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f", size = 274209, upload-time = "2026-09-09T13:56:50.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c2/2001ac0eac1a8b7390a5902d7115f66d4f256268057a502200b6ab12dad7/multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c", size = 268044, upload-time = "2026-09-09T13:56:52.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/90/78a9e26c85f89abd562a67f7fcbaef9007fd5c37bb9efac19f1cf604e7c2/multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8", size = 274806, upload-time = "2026-09-09T13:56:53.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/71/713bd445421b21531234c1f3630b768192cb9d80c8b1c5b05c5b505ff4c0/multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368", size = 281890, upload-time = "2026-09-09T13:56:55.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/a5/1387c538663e2dc8c27bbc7cd6955cb66de0f55c780cf7cd0fc06a1a16ca/multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14", size = 249749, upload-time = "2026-09-09T13:56:58.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/104296c9d70896b9759ce0812aa4899fab76d8b16bb32dcc5a78ab547c89/multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8", size = 276138, upload-time = "2026-09-09T13:57:03.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0a/f2a0c2658e9d7ff5964ec2820a02054558636fafd663230ddc8310b8ed39/multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2", size = 277077, upload-time = "2026-09-09T13:57:06.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/50/bc46566caffba5c1c4a510519156371edf7c4ecd35c9ef917d0c1803487d/multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e", size = 46930, upload-time = "2026-09-09T13:57:08.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/1a/cafb31049ecc1a6ce52bcc69fa436cca239adc057b1718a0c49044848663/multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8", size = 50294, upload-time = "2026-09-09T13:57:09.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/51/00e037da14cd1d894b123e0bbe62de5c561679a6ab23ab1c009f2965dcda/multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f", size = 47626, upload-time = "2026-09-09T13:57:11.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/f7/aeb947982197e8b4f5c4da3961ee473ea5a050b94a6ff3b88baf64621401/multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08", size = 88801, upload-time = "2026-09-09T13:57:13.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d8/593948c016c3f850e3cd56a4e0144151eb409d2b8690f0c0ce7f7d33dbea/multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944", size = 53376, upload-time = "2026-09-09T13:57:15.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/b9/097a05bca533027c0477b6a90bf927dbbb4b23cc9090bbb37a2e972af8d5/multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84", size = 51629, upload-time = "2026-09-09T13:57:17.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/07/938ed21967f12380d0b8861645fb65a942f3669e31d5163ed94d23103b61/multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3", size = 261967, upload-time = "2026-09-09T13:57:19.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e8/e66bf843fd29c01712dde9edeb9f4ad0ffab06ab4ada4b721ad7bc73b3d5/multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5", size = 265923, upload-time = "2026-09-09T13:57:21.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f8/e9be849b225af28a8eee2c6bfea23594a777c753fe97e2ff7e2180c8935a/multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62", size = 239380, upload-time = "2026-09-09T13:57:24.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/67/4dbad08f5081978c591afae9e836ec9ddae90e9e76be6d6ce10757483dc4/multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20", size = 271591, upload-time = "2026-09-09T13:57:26.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/3f/e9c97222d7e104e54e556f118ec7d091ab41a0c10c630f2b97e5b43f5404/multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0", size = 276091, upload-time = "2026-09-09T13:57:28.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/728e7ce05ac9c0303554e7162e74d91fe49e65bad7dfbb377f783dd32c0a/multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556", size = 266493, upload-time = "2026-09-09T13:57:31.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/74/7c658d2769863af16fb7d7c6be50b29659892a06a632858863eee3a31842/multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a", size = 245302, upload-time = "2026-09-09T13:57:33.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/2c/d4350a20a0e8c66a447d694e8713438262665203fe826c3f4e385f052b72/multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4", size = 261016, upload-time = "2026-09-09T13:57:35.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/78/83df999c8beb72a012cfac42f2b833c4a48f8e836fd4407747b355a2430e/multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39", size = 255021, upload-time = "2026-09-09T13:57:37.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/13/f2c0a2dac6d91f74aa124f3e9f07ec497ceae5ed2df2753d249601cd7262/multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e", size = 263066, upload-time = "2026-09-09T13:57:39.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1d/730008d4639ace731bbb1399e1ac13cbdf506f7d6fb861d75044ffb3994d/multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1", size = 266510, upload-time = "2026-09-09T13:57:42.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ca/bec67a5d206dc5748e50c93f6f71deec14305c3657cfe250c3887caf7839/multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f", size = 239423, upload-time = "2026-09-09T13:57:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/db/5f153fe51fbac7d80f3bb8bd6fab8db8b6cd061e7a11371676dfed3712bc/multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882", size = 266902, upload-time = "2026-09-09T13:57:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0f/9efca48a351551de4dc0c183f523109dbe87c645a4732d5f1c70b4880dca/multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101", size = 260887, upload-time = "2026-09-09T13:57:48.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d8/bb879a62e0809448e53f6237e71670066ecf3bbc5896a7a6705b6628d86a/multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea", size = 50533, upload-time = "2026-09-09T13:57:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/62/3e5308d8871636e4b9620e4b3acfcf2b5caf79b19d317690ec13f7fc8b57/multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d", size = 55572, upload-time = "2026-09-09T13:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/cc/d3c10e10ee3bb7a7b4abbb3157306b2ce7e0018c9c2d16b32b468739d2b7/multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4", size = 50322, upload-time = "2026-09-09T13:57:54.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.6"
|
||||
@@ -253,6 +674,134 @@ 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 = "propcache"
|
||||
version = "0.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "25.0.1"
|
||||
@@ -296,54 +845,6 @@ wheels = [
|
||||
{ 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"
|
||||
@@ -448,3 +949,145 @@ wheels = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
|
||||
]
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<!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