crowddrop-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: crowddrop-sdk
3
+ Version: 0.1.0
4
+ Summary: CrowdDrop's SDK for embodied-agent hardware. This release covers cloud_brain: the LLM stays in the cloud, the device is a thin actuator/sensor bridge over Pub/Sub.
5
+ Project-URL: Repository, https://github.com/crowddrop-ai/crowddrop_ai_agents
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: cloud-brain
9
+ Requires-Dist: crowddrop-pubsub-sdk<1.0.0,>=0.2.0; extra == "cloud-brain"
10
+ Requires-Dist: requests<3.0.0,>=2.28.0; extra == "cloud-brain"
11
+ Requires-Dist: google-auth<3.0.0,>=2.29.0; extra == "cloud-brain"
12
+
13
+ # crowddrop-sdk
14
+
15
+ CrowdDrop's SDK for connecting embodied-agent hardware to the CrowdDrop
16
+ platform. It splits into two architecturally different scenarios:
17
+
18
+ - **`cloud_brain`** (this release) - the LLM/tool-calling brain stays in
19
+ CrowdDrop's cloud backend; your device is a thin actuator/sensor bridge
20
+ over Google Cloud Pub/Sub. This is the right choice if your hardware can't
21
+ run an LLM locally but can run a real Python process.
22
+ - **`edge_brain`** (planned, not yet released) - the LLM itself runs on your
23
+ device. A different SDK surface entirely, for hardware with real local
24
+ inference capacity.
25
+
26
+ If you're building a companion-computer-class device (e.g. Raspberry-Pi
27
+ class, full Linux + Python) that receives commands and reports telemetry,
28
+ you want the `cloud-brain` extra:
29
+
30
+ ```bash
31
+ pip install "crowddrop-sdk[cloud-brain]"
32
+ ```
33
+
34
+ This pulls in `crowddrop-pubsub-sdk` (CrowdDrop's Pub/Sub transport library)
35
+ as its only dependency - nothing else, so it stays light on constrained
36
+ hardware. The base `crowddrop-sdk` install (no extra) has no dependencies at
37
+ all.
38
+
39
+ ## Getting credentials
40
+
41
+ There's no self-service signup. CrowdDrop issues one **agent device key**
42
+ per physical device, tied to the persona it embodies, and hands it to you
43
+ out-of-band along with the URL of the backend's token-vending endpoint. If
44
+ you don't have both yet, ask whoever set up your CrowdDrop persona - there's
45
+ no dashboard to generate one yourself. Your device never handles a raw GCP
46
+ service-account key file: it trades its device key for a short-lived GCP
47
+ access token by calling the token-vending endpoint (see
48
+ `cloud_brain/drone/edge_agent.py`'s `fetch_gcp_access_token`), and refreshes
49
+ that token automatically as it nears expiry.
50
+
51
+ ## API reference (`cloud_brain`)
52
+
53
+ - **`crowddrop_sdk.cloud_brain.events.DRONE_COMMANDS`** - the eight
54
+ supported movement primitives, each a fixed pulse with no
55
+ duration/distance parameter: `take_off`, `land`, `forward`, `backward`,
56
+ `strafe_left`, `strafe_right`, `turn_left`, `turn_right`.
57
+ - **`DroneCommandEvent(drone_id, command, issued_at, sequence)`** - what you
58
+ receive, one per command.
59
+ - **`DroneTelemetryEvent(drone_id, latitude, longitude, heading,
60
+ battery_level, reported_at, sequence)`** - what you publish back.
61
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneCommandSubscriber`** -
62
+ pull-based subscriber for commands (you always initiate the connection
63
+ outward - nothing is ever pushed to your device).
64
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneTelemetryPublisher`** -
65
+ publisher for telemetry, with a `publish_telemetry(drone_id, latitude,
66
+ longitude, heading, battery_level)` convenience method.
67
+ - **Two integration points you implement** - `handle_command(event)` (map
68
+ each command to your real flight-controller call) and
69
+ `read_battery_and_gps()` (return a real sensor reading). Both are stand-ins
70
+ in the example below; this SDK doesn't know your hardware's API.
71
+
72
+ ## Quickstart
73
+
74
+ See [`cloud_brain/drone/README.md`](crowddrop_sdk/cloud_brain/drone/README.md)
75
+ for a runnable example (`edge_agent.py`) and the exact env vars it needs.
76
+
77
+ ## Releasing (publishing a new version to PyPI)
78
+
79
+ Releases are tag-triggered via `.github/workflows/publish-crowddrop-sdk.yml`,
80
+ using PyPI's **Trusted Publishing** (OIDC) — no API token is stored as a
81
+ GitHub secret.
82
+
83
+ 1. Bump `version` in `crowddrop_sdk/pyproject.toml`.
84
+ 2. Commit that change (on a branch, via the normal PR flow).
85
+ 3. Once merged, tag the merge commit and push the tag:
86
+ ```bash
87
+ git tag crowddrop-sdk-v<version> # e.g. crowddrop-sdk-v0.1.1
88
+ git push origin crowddrop-sdk-v<version>
89
+ ```
90
+ The tag push is what fires the workflow — it builds `crowddrop_sdk/` and
91
+ uploads it to [pypi.org/project/crowddrop-sdk](https://pypi.org/project/crowddrop-sdk/).
92
+ No other trigger publishes this package.
93
+
94
+ If `cloud-brain`'s dependency on `crowddrop-pubsub-sdk` (see `pyproject.toml`)
95
+ needs bumping too, release `pubsub_sdk` first — see its own README's
96
+ "Releasing" section, same mechanism, separate workflow/tag prefix
97
+ (`pubsub-sdk-v*`).
98
+
99
+ **One-time setup, not yet done as of this writing — needed before the first
100
+ tag push, and again only if this ever moves to a different PyPI
101
+ account/org:**
102
+ - Register a **pending publisher** for `crowddrop-sdk` at
103
+ https://pypi.org/manage/account/publishing/ — this can be done before the
104
+ PyPI project exists, so it covers the *first-ever* release too, not just
105
+ subsequent ones. Fill in: PyPI project name `crowddrop-sdk`, repo owner
106
+ `crowddrop-ai`, repo name `crowddrop_ai_agents`, workflow filename
107
+ `publish-crowddrop-sdk.yml`, environment name `pypi`. Requires a PyPI
108
+ account with 2FA enabled — no API token to generate or store.
109
+ - The `pypi` GitHub Environment referenced by the workflow is created
110
+ automatically the first time the workflow runs against it; create it
111
+ manually in this repo's Settings → Environments beforehand only if you
112
+ want a required-reviewer protection rule (so a tag push pauses for human
113
+ approval before it actually publishes).
114
+ - `../scripts/publish_python_packages.sh` (manual `build` + `twine upload`)
115
+ is kept as a fallback/local-dry-run tool only — with a pending publisher
116
+ registered, it's no longer needed even for the first release.
117
+
118
+ Versioning is manual — nothing cross-checks the tag against
119
+ `pyproject.toml`'s `version`. Bump the file first, commit, *then* tag that
120
+ exact commit; tagging a commit whose `pyproject.toml` still has an
121
+ already-published version will fail the upload (PyPI rejects re-uploading an
122
+ existing version).
@@ -0,0 +1,110 @@
1
+ # crowddrop-sdk
2
+
3
+ CrowdDrop's SDK for connecting embodied-agent hardware to the CrowdDrop
4
+ platform. It splits into two architecturally different scenarios:
5
+
6
+ - **`cloud_brain`** (this release) - the LLM/tool-calling brain stays in
7
+ CrowdDrop's cloud backend; your device is a thin actuator/sensor bridge
8
+ over Google Cloud Pub/Sub. This is the right choice if your hardware can't
9
+ run an LLM locally but can run a real Python process.
10
+ - **`edge_brain`** (planned, not yet released) - the LLM itself runs on your
11
+ device. A different SDK surface entirely, for hardware with real local
12
+ inference capacity.
13
+
14
+ If you're building a companion-computer-class device (e.g. Raspberry-Pi
15
+ class, full Linux + Python) that receives commands and reports telemetry,
16
+ you want the `cloud-brain` extra:
17
+
18
+ ```bash
19
+ pip install "crowddrop-sdk[cloud-brain]"
20
+ ```
21
+
22
+ This pulls in `crowddrop-pubsub-sdk` (CrowdDrop's Pub/Sub transport library)
23
+ as its only dependency - nothing else, so it stays light on constrained
24
+ hardware. The base `crowddrop-sdk` install (no extra) has no dependencies at
25
+ all.
26
+
27
+ ## Getting credentials
28
+
29
+ There's no self-service signup. CrowdDrop issues one **agent device key**
30
+ per physical device, tied to the persona it embodies, and hands it to you
31
+ out-of-band along with the URL of the backend's token-vending endpoint. If
32
+ you don't have both yet, ask whoever set up your CrowdDrop persona - there's
33
+ no dashboard to generate one yourself. Your device never handles a raw GCP
34
+ service-account key file: it trades its device key for a short-lived GCP
35
+ access token by calling the token-vending endpoint (see
36
+ `cloud_brain/drone/edge_agent.py`'s `fetch_gcp_access_token`), and refreshes
37
+ that token automatically as it nears expiry.
38
+
39
+ ## API reference (`cloud_brain`)
40
+
41
+ - **`crowddrop_sdk.cloud_brain.events.DRONE_COMMANDS`** - the eight
42
+ supported movement primitives, each a fixed pulse with no
43
+ duration/distance parameter: `take_off`, `land`, `forward`, `backward`,
44
+ `strafe_left`, `strafe_right`, `turn_left`, `turn_right`.
45
+ - **`DroneCommandEvent(drone_id, command, issued_at, sequence)`** - what you
46
+ receive, one per command.
47
+ - **`DroneTelemetryEvent(drone_id, latitude, longitude, heading,
48
+ battery_level, reported_at, sequence)`** - what you publish back.
49
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneCommandSubscriber`** -
50
+ pull-based subscriber for commands (you always initiate the connection
51
+ outward - nothing is ever pushed to your device).
52
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneTelemetryPublisher`** -
53
+ publisher for telemetry, with a `publish_telemetry(drone_id, latitude,
54
+ longitude, heading, battery_level)` convenience method.
55
+ - **Two integration points you implement** - `handle_command(event)` (map
56
+ each command to your real flight-controller call) and
57
+ `read_battery_and_gps()` (return a real sensor reading). Both are stand-ins
58
+ in the example below; this SDK doesn't know your hardware's API.
59
+
60
+ ## Quickstart
61
+
62
+ See [`cloud_brain/drone/README.md`](crowddrop_sdk/cloud_brain/drone/README.md)
63
+ for a runnable example (`edge_agent.py`) and the exact env vars it needs.
64
+
65
+ ## Releasing (publishing a new version to PyPI)
66
+
67
+ Releases are tag-triggered via `.github/workflows/publish-crowddrop-sdk.yml`,
68
+ using PyPI's **Trusted Publishing** (OIDC) — no API token is stored as a
69
+ GitHub secret.
70
+
71
+ 1. Bump `version` in `crowddrop_sdk/pyproject.toml`.
72
+ 2. Commit that change (on a branch, via the normal PR flow).
73
+ 3. Once merged, tag the merge commit and push the tag:
74
+ ```bash
75
+ git tag crowddrop-sdk-v<version> # e.g. crowddrop-sdk-v0.1.1
76
+ git push origin crowddrop-sdk-v<version>
77
+ ```
78
+ The tag push is what fires the workflow — it builds `crowddrop_sdk/` and
79
+ uploads it to [pypi.org/project/crowddrop-sdk](https://pypi.org/project/crowddrop-sdk/).
80
+ No other trigger publishes this package.
81
+
82
+ If `cloud-brain`'s dependency on `crowddrop-pubsub-sdk` (see `pyproject.toml`)
83
+ needs bumping too, release `pubsub_sdk` first — see its own README's
84
+ "Releasing" section, same mechanism, separate workflow/tag prefix
85
+ (`pubsub-sdk-v*`).
86
+
87
+ **One-time setup, not yet done as of this writing — needed before the first
88
+ tag push, and again only if this ever moves to a different PyPI
89
+ account/org:**
90
+ - Register a **pending publisher** for `crowddrop-sdk` at
91
+ https://pypi.org/manage/account/publishing/ — this can be done before the
92
+ PyPI project exists, so it covers the *first-ever* release too, not just
93
+ subsequent ones. Fill in: PyPI project name `crowddrop-sdk`, repo owner
94
+ `crowddrop-ai`, repo name `crowddrop_ai_agents`, workflow filename
95
+ `publish-crowddrop-sdk.yml`, environment name `pypi`. Requires a PyPI
96
+ account with 2FA enabled — no API token to generate or store.
97
+ - The `pypi` GitHub Environment referenced by the workflow is created
98
+ automatically the first time the workflow runs against it; create it
99
+ manually in this repo's Settings → Environments beforehand only if you
100
+ want a required-reviewer protection rule (so a tag push pauses for human
101
+ approval before it actually publishes).
102
+ - `../scripts/publish_python_packages.sh` (manual `build` + `twine upload`)
103
+ is kept as a fallback/local-dry-run tool only — with a pending publisher
104
+ registered, it's no longer needed even for the first release.
105
+
106
+ Versioning is manual — nothing cross-checks the tag against
107
+ `pyproject.toml`'s `version`. Bump the file first, commit, *then* tag that
108
+ exact commit; tagging a commit whose `pyproject.toml` still has an
109
+ already-published version will fail the upload (PyPI rejects re-uploading an
110
+ existing version).
@@ -0,0 +1,5 @@
1
+ """crowddrop-sdk: CrowdDrop's SDK for embodied-agent hardware. See
2
+ crowddrop_sdk.cloud_brain for this release's cloud-brain scenario (import
3
+ that submodule directly - the base install here declares no dependencies, so
4
+ nothing cloud_brain-specific is re-exported at this top level).
5
+ """
@@ -0,0 +1,13 @@
1
+ """cloud_brain: the LLM/tool-calling brain stays in the cloud, the device is
2
+ a thin actuator/sensor bridge over Google Cloud Pub/Sub. This submodule ships
3
+ the shared wire contract (events.py) and the drone's own client classes
4
+ (drone/channels.py) - the backend's equivalents live outside this published
5
+ package, in the crowddrop_ai_agents repo itself.
6
+ """
7
+ from .events import DRONE_COMMANDS, DroneCommandEvent, DroneTelemetryEvent
8
+
9
+ __all__ = [
10
+ "DRONE_COMMANDS",
11
+ "DroneCommandEvent",
12
+ "DroneTelemetryEvent",
13
+ ]
@@ -0,0 +1,3 @@
1
+ """The physical drone's own client classes (channels.py) and runnable
2
+ example (edge_agent.py) - see this directory's README.md.
3
+ """
@@ -0,0 +1,88 @@
1
+ """The physical drone's own pub/sub classes - imported only by edge_agent.py
2
+ (and whatever real edge code eventually replaces/extends it). The backend's
3
+ equivalents (DroneCommandPublisher, DroneTelemetrySubscriber) live in
4
+ fastapi/app/first_citizen_drone/channels.py instead, outside this published
5
+ package - nothing outside that repo ever uses them.
6
+
7
+ Both classes are thin GenericPublisher/GenericSubscriber subclasses
8
+ (pubsub_sdk.client) - a subclass fixes the topic/subscription ids and message
9
+ type; everything else (client construction, degrade-gracefully behavior,
10
+ emulator-only auto-create, ack/nack) lives there once.
11
+ """
12
+ import datetime
13
+ import itertools
14
+ import logging
15
+ import os
16
+ from typing import Optional
17
+
18
+ from pubsub_sdk import PubSubConfig
19
+ from pubsub_sdk.client import GenericPublisher, GenericSubscriber
20
+
21
+ from ..events import DroneCommandEvent, DroneTelemetryEvent
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ DRONE_COMMAND_TOPIC_ID_ENV = "DRONE_COMMAND_TOPIC_ID"
26
+ DRONE_COMMAND_SUBSCRIPTION_ID_ENV = "DRONE_COMMAND_SUBSCRIPTION_ID"
27
+ DRONE_TELEMETRY_TOPIC_ID_ENV = "DRONE_TELEMETRY_TOPIC_ID"
28
+
29
+ DEFAULT_DRONE_COMMAND_TOPIC_ID = "drone-command-events"
30
+ DEFAULT_DRONE_COMMAND_SUBSCRIPTION_ID = "drone-command-events-drone"
31
+ DEFAULT_DRONE_TELEMETRY_TOPIC_ID = "drone-telemetry-events"
32
+
33
+
34
+ class DroneCommandSubscriber(GenericSubscriber[DroneCommandEvent]):
35
+ """The drone consumes commands. Pull-based (pull_forever), matching
36
+ StepEventSubscriber's existing pattern in this org's backend - the drone
37
+ always initiates the connection outward, never accepts inbound (it's a
38
+ mobile, likely-NATed device with no static IP).
39
+
40
+ One fixed subscription (always_self_provision=False - provisioned ahead
41
+ of time by infra against real GCP, auto-created only against the
42
+ emulator), since exactly one physical drone is in scope today. drone_id
43
+ still rides as a message attribute (see events.py) so a second physical
44
+ drone can later get its own filter_expr-scoped subscription without a
45
+ wire-format change.
46
+ """
47
+
48
+ def __init__(self, config: Optional[PubSubConfig] = None):
49
+ cfg = config or PubSubConfig.from_env()
50
+ topic_id = os.environ.get(DRONE_COMMAND_TOPIC_ID_ENV, DEFAULT_DRONE_COMMAND_TOPIC_ID)
51
+ subscription_id = os.environ.get(DRONE_COMMAND_SUBSCRIPTION_ID_ENV, DEFAULT_DRONE_COMMAND_SUBSCRIPTION_ID)
52
+ super().__init__(
53
+ config=cfg,
54
+ topic_id=topic_id,
55
+ subscription_id=subscription_id,
56
+ message_cls=DroneCommandEvent,
57
+ label="DroneCommandSubscriber",
58
+ )
59
+
60
+
61
+ class DroneTelemetryPublisher(GenericPublisher[DroneTelemetryEvent]):
62
+ """The drone publishes telemetry - real GPS/battery/heading readings, not
63
+ simulated and not a placeholder."""
64
+
65
+ def __init__(self, config: Optional[PubSubConfig] = None):
66
+ cfg = config or PubSubConfig.from_env()
67
+ topic_id = os.environ.get(DRONE_TELEMETRY_TOPIC_ID_ENV, DEFAULT_DRONE_TELEMETRY_TOPIC_ID)
68
+ super().__init__(config=cfg, topic_id=topic_id, label="DroneTelemetryPublisher")
69
+ self._sequence = itertools.count(start=1)
70
+
71
+ def publish_telemetry(
72
+ self,
73
+ drone_id: str,
74
+ latitude: float,
75
+ longitude: float,
76
+ heading: float,
77
+ battery_level: float,
78
+ ) -> bool:
79
+ event = DroneTelemetryEvent(
80
+ drone_id=drone_id,
81
+ latitude=latitude,
82
+ longitude=longitude,
83
+ heading=heading,
84
+ battery_level=battery_level,
85
+ reported_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
86
+ sequence=next(self._sequence),
87
+ )
88
+ return self.publish(event)
@@ -0,0 +1,193 @@
1
+ """Runnable example for the physical drone's companion computer. Shows the
2
+ full loop: fetch a short-lived GCP access token (never a key file - see the
3
+ README next to this file), receive commands, drive the flight controller,
4
+ and report telemetry back.
5
+
6
+ Two integration points are clearly marked below (handle_command,
7
+ read_battery_and_gps) - this file does not know the real flight-controller
8
+ API and doesn't guess it. Run it as-is against a stand-in (or with
9
+ PUBSUB_EMULATOR_HOST set, against the emulator) to see the wiring work end
10
+ to end before a real flight controller is plugged in.
11
+ """
12
+ import datetime
13
+ import functools
14
+ import logging
15
+ import os
16
+ import threading
17
+ from dataclasses import dataclass
18
+
19
+ import google.auth.credentials
20
+ import google.auth.transport.requests
21
+ import requests
22
+ from google.auth import _helpers
23
+ from google.cloud import pubsub_v1
24
+
25
+ from .channels import DroneCommandSubscriber, DroneTelemetryPublisher
26
+ from ..events import DRONE_COMMANDS, DroneCommandEvent
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ DRONE_ID_ENV = "DRONE_ID"
31
+ DRONE_DEVICE_KEY_ENV = "DRONE_DEVICE_KEY"
32
+ BACKEND_TOKEN_VENDING_URL_ENV = "BACKEND_TOKEN_VENDING_URL"
33
+
34
+ TELEMETRY_HEARTBEAT_SECONDS = 10
35
+ GCP_TOKEN_REFRESH_MARGIN_SECONDS = 300
36
+
37
+
38
+ def fetch_gcp_access_token(device_key: str, backend_url: str) -> dict:
39
+ """POSTs to the backend's token-vending endpoint with the drone's one
40
+ agent device key and returns {"access_token": ..., "expires_in_seconds":
41
+ ...}. Never a GCP service-account key file - see this package's own
42
+ README."""
43
+ response = requests.post(
44
+ backend_url,
45
+ headers={"X-Agent-Device-Key": device_key},
46
+ timeout=10,
47
+ )
48
+ response.raise_for_status()
49
+ return response.json()
50
+
51
+
52
+ class _DeviceKeyCredentials(google.auth.credentials.Credentials):
53
+ """A google-auth Credentials implementation whose refresh() calls back
54
+ into this backend's token-vending endpoint with the drone's one agent
55
+ device key, instead of the usual service-account/user OAuth flow - the
56
+ drone never handles a GCP service-account key file. The pubsub_v1 client
57
+ machinery calls refresh() automatically on the next new RPC once the
58
+ current token is near expiry; _run_token_refresh below additionally
59
+ refreshes proactively, since a long-lived streaming pull may not open a
60
+ new RPC before expiry on its own."""
61
+
62
+ def __init__(self, device_key: str, backend_url: str):
63
+ super().__init__()
64
+ self._device_key = device_key
65
+ self._backend_url = backend_url
66
+
67
+ def refresh(self, request) -> None:
68
+ token_response = fetch_gcp_access_token(self._device_key, self._backend_url)
69
+ self.token = token_response["access_token"]
70
+ self.expiry = _helpers.utcnow() + datetime.timedelta(
71
+ seconds=token_response.get("expires_in_seconds", 3600)
72
+ )
73
+
74
+
75
+ def _apply_device_key_credentials(device_key: str, backend_url: str) -> "_DeviceKeyCredentials":
76
+ """Makes every PublisherClient()/SubscriberClient() construction in this
77
+ process (they're always called with no arguments, relying on ADC - see
78
+ pubsub_sdk.client.GenericPublisher/GenericSubscriber) use these
79
+ credentials instead - the same monkeypatch technique
80
+ scripts/pubsub_smoke_test.py already uses for a plain
81
+ `gcloud auth print-access-token`, just backed by a refreshable
82
+ Credentials object here instead of a fixed token. Returns the
83
+ credentials object so the caller can also refresh it proactively (see
84
+ _run_token_refresh) - the pubsub client's own lazy on-RPC refresh isn't
85
+ guaranteed to fire mid-stream on a long-lived streaming pull."""
86
+ creds = _DeviceKeyCredentials(device_key, backend_url)
87
+ pubsub_v1.PublisherClient = functools.partial(pubsub_v1.PublisherClient, credentials=creds)
88
+ pubsub_v1.SubscriberClient = functools.partial(pubsub_v1.SubscriberClient, credentials=creds)
89
+ return creds
90
+
91
+
92
+ def _run_token_refresh(creds: "_DeviceKeyCredentials", stop_event: threading.Event) -> None:
93
+ """Proactively refreshes creds before they expire, rather than relying
94
+ solely on the pubsub client's lazy on-RPC refresh - belt-and-suspenders
95
+ for DroneCommandSubscriber's long-lived streaming pull, which may not
96
+ reopen (and thus re-authenticate) before the vended token expires."""
97
+ request = google.auth.transport.requests.Request()
98
+ while not stop_event.is_set():
99
+ creds.refresh(request)
100
+ sleep_seconds = max((creds.expiry - _helpers.utcnow()).total_seconds() - GCP_TOKEN_REFRESH_MARGIN_SECONDS, 1)
101
+ stop_event.wait(sleep_seconds)
102
+
103
+
104
+ def handle_command(event: DroneCommandEvent) -> None:
105
+ """command -> flight-controller-call dispatch. This is the integration
106
+ point - wire up the real flight controller here, one branch per command
107
+ in DRONE_COMMANDS."""
108
+ if event.command not in DRONE_COMMANDS:
109
+ logger.error("handle_command: unknown command '%s', ignoring.", event.command)
110
+ return
111
+
112
+ logger.info("handle_command: executing '%s' (sequence=%s)", event.command, event.sequence)
113
+
114
+ raise NotImplementedError(f"integration point: wire up the real flight controller here ({event.command})")
115
+
116
+
117
+ @dataclass
118
+ class SensorReading:
119
+ """A raw sensor reading, distinct from DroneTelemetryEvent - this is just
120
+ what the hardware measured, not yet stamped with drone_id/reported_at/
121
+ sequence (those are publish_telemetry's job)."""
122
+
123
+ latitude: float
124
+ longitude: float
125
+ heading: float
126
+ battery_level: float
127
+
128
+
129
+ def read_battery_and_gps() -> SensorReading:
130
+ """Returns a real GPS/battery/heading reading. Stand-in below returns a
131
+ fixed reading, for anyone testing without hardware in hand - wire up the
132
+ real sensors here."""
133
+ return SensorReading(latitude=0.0, longitude=0.0, heading=0.0, battery_level=100.0)
134
+
135
+
136
+ def _publish_telemetry_now(publisher: DroneTelemetryPublisher, drone_id: str) -> None:
137
+ reading = read_battery_and_gps()
138
+ publisher.publish_telemetry(
139
+ drone_id=drone_id,
140
+ latitude=reading.latitude,
141
+ longitude=reading.longitude,
142
+ heading=reading.heading,
143
+ battery_level=reading.battery_level,
144
+ )
145
+
146
+
147
+ def _run_telemetry_heartbeat(publisher: DroneTelemetryPublisher, drone_id: str, stop_event: threading.Event) -> None:
148
+ while not stop_event.is_set():
149
+ _publish_telemetry_now(publisher, drone_id)
150
+ stop_event.wait(TELEMETRY_HEARTBEAT_SECONDS)
151
+
152
+
153
+ def main() -> None:
154
+ logging.basicConfig(level=logging.INFO)
155
+
156
+ drone_id = os.environ[DRONE_ID_ENV]
157
+ device_key = os.environ.get(DRONE_DEVICE_KEY_ENV)
158
+ backend_url = os.environ.get(BACKEND_TOKEN_VENDING_URL_ENV)
159
+
160
+ stop_event = threading.Event()
161
+
162
+ if device_key and backend_url:
163
+ creds = _apply_device_key_credentials(device_key, backend_url)
164
+ refresh_thread = threading.Thread(target=_run_token_refresh, args=(creds, stop_event), daemon=True)
165
+ refresh_thread.start()
166
+ else:
167
+ logger.info(
168
+ "%s/%s not set - assuming ambient credentials (e.g. PUBSUB_EMULATOR_HOST for local testing).",
169
+ DRONE_DEVICE_KEY_ENV,
170
+ BACKEND_TOKEN_VENDING_URL_ENV,
171
+ )
172
+
173
+ command_subscriber = DroneCommandSubscriber()
174
+ telemetry_publisher = DroneTelemetryPublisher()
175
+
176
+ def _on_command(event: DroneCommandEvent) -> None:
177
+ handle_command(event)
178
+ _publish_telemetry_now(telemetry_publisher, drone_id)
179
+
180
+ heartbeat_thread = threading.Thread(
181
+ target=_run_telemetry_heartbeat, args=(telemetry_publisher, drone_id, stop_event), daemon=True
182
+ )
183
+ heartbeat_thread.start()
184
+
185
+ try:
186
+ command_subscriber.pull_forever(_on_command)
187
+ finally:
188
+ stop_event.set()
189
+ command_subscriber.stop()
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
@@ -0,0 +1,89 @@
1
+ """DroneCommandEvent / DroneTelemetryEvent: the wire contract for CrowdDrop's
2
+ cloud-brain scenario - the LLM/tool-calling brain stays in the cloud, the
3
+ physical device is a thin actuator/sensor bridge over Pub/Sub. Both the
4
+ backend's own channel classes (fastapi/app/first_citizen_drone/channels.py,
5
+ not published) and the drone's (cloud_brain/drone/channels.py, this module's
6
+ sibling) import these two event types - the one thing genuinely shared
7
+ between the two sides of the wire.
8
+
9
+ Field names and the to_pubsub_message()/from_pubsub_message() shape mirror
10
+ pubsub_sdk's StepEvent (JSON body + string attributes for cheap server-side
11
+ filtering without deserializing) so a developer already familiar with that
12
+ pattern in the CrowdDrop backend recognizes this one immediately.
13
+ """
14
+ import json
15
+ from dataclasses import asdict, dataclass
16
+ from typing import Dict, Optional, Tuple
17
+
18
+ DRONE_COMMANDS = (
19
+ "take_off",
20
+ "land",
21
+ "forward",
22
+ "backward",
23
+ "strafe_left",
24
+ "strafe_right",
25
+ "turn_left",
26
+ "turn_right",
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class DroneCommandEvent:
32
+ """One fixed-pulse movement primitive, backend -> drone. No duration/
33
+ distance field on any of the eight commands - take-off always rises to
34
+ the same default altitude, "a lot" of lateral movement is expressed as
35
+ repeated messages, not a bigger number."""
36
+
37
+ drone_id: str
38
+ command: str
39
+ issued_at: str
40
+ sequence: Optional[int] = None
41
+
42
+ def to_pubsub_message(self) -> Tuple[bytes, Dict[str, str]]:
43
+ body = asdict(self)
44
+ data = json.dumps(body).encode("utf-8")
45
+ attributes = {"drone_id": self.drone_id, "command": self.command}
46
+ return data, attributes
47
+
48
+ @classmethod
49
+ def from_pubsub_message(cls, data: bytes, attributes: Optional[Dict[str, str]] = None) -> "DroneCommandEvent":
50
+ body = json.loads(data.decode("utf-8"))
51
+ return cls(
52
+ drone_id=body["drone_id"],
53
+ command=body["command"],
54
+ issued_at=body["issued_at"],
55
+ sequence=body.get("sequence"),
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class DroneTelemetryEvent:
61
+ """One real GPS/battery/heading reading, drone -> backend. Real, not
62
+ simulated and not a placeholder."""
63
+
64
+ drone_id: str
65
+ latitude: float
66
+ longitude: float
67
+ heading: float
68
+ battery_level: float
69
+ reported_at: str
70
+ sequence: Optional[int] = None
71
+
72
+ def to_pubsub_message(self) -> Tuple[bytes, Dict[str, str]]:
73
+ body = asdict(self)
74
+ data = json.dumps(body).encode("utf-8")
75
+ attributes = {"drone_id": self.drone_id}
76
+ return data, attributes
77
+
78
+ @classmethod
79
+ def from_pubsub_message(cls, data: bytes, attributes: Optional[Dict[str, str]] = None) -> "DroneTelemetryEvent":
80
+ body = json.loads(data.decode("utf-8"))
81
+ return cls(
82
+ drone_id=body["drone_id"],
83
+ latitude=body["latitude"],
84
+ longitude=body["longitude"],
85
+ heading=body["heading"],
86
+ battery_level=body["battery_level"],
87
+ reported_at=body["reported_at"],
88
+ sequence=body.get("sequence"),
89
+ )
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: crowddrop-sdk
3
+ Version: 0.1.0
4
+ Summary: CrowdDrop's SDK for embodied-agent hardware. This release covers cloud_brain: the LLM stays in the cloud, the device is a thin actuator/sensor bridge over Pub/Sub.
5
+ Project-URL: Repository, https://github.com/crowddrop-ai/crowddrop_ai_agents
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: cloud-brain
9
+ Requires-Dist: crowddrop-pubsub-sdk<1.0.0,>=0.2.0; extra == "cloud-brain"
10
+ Requires-Dist: requests<3.0.0,>=2.28.0; extra == "cloud-brain"
11
+ Requires-Dist: google-auth<3.0.0,>=2.29.0; extra == "cloud-brain"
12
+
13
+ # crowddrop-sdk
14
+
15
+ CrowdDrop's SDK for connecting embodied-agent hardware to the CrowdDrop
16
+ platform. It splits into two architecturally different scenarios:
17
+
18
+ - **`cloud_brain`** (this release) - the LLM/tool-calling brain stays in
19
+ CrowdDrop's cloud backend; your device is a thin actuator/sensor bridge
20
+ over Google Cloud Pub/Sub. This is the right choice if your hardware can't
21
+ run an LLM locally but can run a real Python process.
22
+ - **`edge_brain`** (planned, not yet released) - the LLM itself runs on your
23
+ device. A different SDK surface entirely, for hardware with real local
24
+ inference capacity.
25
+
26
+ If you're building a companion-computer-class device (e.g. Raspberry-Pi
27
+ class, full Linux + Python) that receives commands and reports telemetry,
28
+ you want the `cloud-brain` extra:
29
+
30
+ ```bash
31
+ pip install "crowddrop-sdk[cloud-brain]"
32
+ ```
33
+
34
+ This pulls in `crowddrop-pubsub-sdk` (CrowdDrop's Pub/Sub transport library)
35
+ as its only dependency - nothing else, so it stays light on constrained
36
+ hardware. The base `crowddrop-sdk` install (no extra) has no dependencies at
37
+ all.
38
+
39
+ ## Getting credentials
40
+
41
+ There's no self-service signup. CrowdDrop issues one **agent device key**
42
+ per physical device, tied to the persona it embodies, and hands it to you
43
+ out-of-band along with the URL of the backend's token-vending endpoint. If
44
+ you don't have both yet, ask whoever set up your CrowdDrop persona - there's
45
+ no dashboard to generate one yourself. Your device never handles a raw GCP
46
+ service-account key file: it trades its device key for a short-lived GCP
47
+ access token by calling the token-vending endpoint (see
48
+ `cloud_brain/drone/edge_agent.py`'s `fetch_gcp_access_token`), and refreshes
49
+ that token automatically as it nears expiry.
50
+
51
+ ## API reference (`cloud_brain`)
52
+
53
+ - **`crowddrop_sdk.cloud_brain.events.DRONE_COMMANDS`** - the eight
54
+ supported movement primitives, each a fixed pulse with no
55
+ duration/distance parameter: `take_off`, `land`, `forward`, `backward`,
56
+ `strafe_left`, `strafe_right`, `turn_left`, `turn_right`.
57
+ - **`DroneCommandEvent(drone_id, command, issued_at, sequence)`** - what you
58
+ receive, one per command.
59
+ - **`DroneTelemetryEvent(drone_id, latitude, longitude, heading,
60
+ battery_level, reported_at, sequence)`** - what you publish back.
61
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneCommandSubscriber`** -
62
+ pull-based subscriber for commands (you always initiate the connection
63
+ outward - nothing is ever pushed to your device).
64
+ - **`crowddrop_sdk.cloud_brain.drone.channels.DroneTelemetryPublisher`** -
65
+ publisher for telemetry, with a `publish_telemetry(drone_id, latitude,
66
+ longitude, heading, battery_level)` convenience method.
67
+ - **Two integration points you implement** - `handle_command(event)` (map
68
+ each command to your real flight-controller call) and
69
+ `read_battery_and_gps()` (return a real sensor reading). Both are stand-ins
70
+ in the example below; this SDK doesn't know your hardware's API.
71
+
72
+ ## Quickstart
73
+
74
+ See [`cloud_brain/drone/README.md`](crowddrop_sdk/cloud_brain/drone/README.md)
75
+ for a runnable example (`edge_agent.py`) and the exact env vars it needs.
76
+
77
+ ## Releasing (publishing a new version to PyPI)
78
+
79
+ Releases are tag-triggered via `.github/workflows/publish-crowddrop-sdk.yml`,
80
+ using PyPI's **Trusted Publishing** (OIDC) — no API token is stored as a
81
+ GitHub secret.
82
+
83
+ 1. Bump `version` in `crowddrop_sdk/pyproject.toml`.
84
+ 2. Commit that change (on a branch, via the normal PR flow).
85
+ 3. Once merged, tag the merge commit and push the tag:
86
+ ```bash
87
+ git tag crowddrop-sdk-v<version> # e.g. crowddrop-sdk-v0.1.1
88
+ git push origin crowddrop-sdk-v<version>
89
+ ```
90
+ The tag push is what fires the workflow — it builds `crowddrop_sdk/` and
91
+ uploads it to [pypi.org/project/crowddrop-sdk](https://pypi.org/project/crowddrop-sdk/).
92
+ No other trigger publishes this package.
93
+
94
+ If `cloud-brain`'s dependency on `crowddrop-pubsub-sdk` (see `pyproject.toml`)
95
+ needs bumping too, release `pubsub_sdk` first — see its own README's
96
+ "Releasing" section, same mechanism, separate workflow/tag prefix
97
+ (`pubsub-sdk-v*`).
98
+
99
+ **One-time setup, not yet done as of this writing — needed before the first
100
+ tag push, and again only if this ever moves to a different PyPI
101
+ account/org:**
102
+ - Register a **pending publisher** for `crowddrop-sdk` at
103
+ https://pypi.org/manage/account/publishing/ — this can be done before the
104
+ PyPI project exists, so it covers the *first-ever* release too, not just
105
+ subsequent ones. Fill in: PyPI project name `crowddrop-sdk`, repo owner
106
+ `crowddrop-ai`, repo name `crowddrop_ai_agents`, workflow filename
107
+ `publish-crowddrop-sdk.yml`, environment name `pypi`. Requires a PyPI
108
+ account with 2FA enabled — no API token to generate or store.
109
+ - The `pypi` GitHub Environment referenced by the workflow is created
110
+ automatically the first time the workflow runs against it; create it
111
+ manually in this repo's Settings → Environments beforehand only if you
112
+ want a required-reviewer protection rule (so a tag push pauses for human
113
+ approval before it actually publishes).
114
+ - `../scripts/publish_python_packages.sh` (manual `build` + `twine upload`)
115
+ is kept as a fallback/local-dry-run tool only — with a pending publisher
116
+ registered, it's no longer needed even for the first release.
117
+
118
+ Versioning is manual — nothing cross-checks the tag against
119
+ `pyproject.toml`'s `version`. Bump the file first, commit, *then* tag that
120
+ exact commit; tagging a commit whose `pyproject.toml` still has an
121
+ already-published version will fail the upload (PyPI rejects re-uploading an
122
+ existing version).
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ crowddrop_sdk/__init__.py
4
+ crowddrop_sdk.egg-info/PKG-INFO
5
+ crowddrop_sdk.egg-info/SOURCES.txt
6
+ crowddrop_sdk.egg-info/dependency_links.txt
7
+ crowddrop_sdk.egg-info/requires.txt
8
+ crowddrop_sdk.egg-info/top_level.txt
9
+ crowddrop_sdk/cloud_brain/__init__.py
10
+ crowddrop_sdk/cloud_brain/events.py
11
+ crowddrop_sdk/cloud_brain/drone/__init__.py
12
+ crowddrop_sdk/cloud_brain/drone/channels.py
13
+ crowddrop_sdk/cloud_brain/drone/edge_agent.py
@@ -0,0 +1,5 @@
1
+
2
+ [cloud-brain]
3
+ crowddrop-pubsub-sdk<1.0.0,>=0.2.0
4
+ requests<3.0.0,>=2.28.0
5
+ google-auth<3.0.0,>=2.29.0
@@ -0,0 +1 @@
1
+ crowddrop_sdk
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "crowddrop-sdk"
3
+ version = "0.1.0"
4
+ description = "CrowdDrop's SDK for embodied-agent hardware. This release covers cloud_brain: the LLM stays in the cloud, the device is a thin actuator/sensor bridge over Pub/Sub."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = []
8
+
9
+ [project.optional-dependencies]
10
+ cloud-brain = [
11
+ "crowddrop-pubsub-sdk>=0.2.0,<1.0.0",
12
+ "requests>=2.28.0,<3.0.0",
13
+ "google-auth>=2.29.0,<3.0.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/crowddrop-ai/crowddrop_ai_agents"
18
+
19
+ [build-system]
20
+ requires = ["setuptools"]
21
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+