Metadata-Version: 2.5
Name: inmotion-sdk
Version: 0.3.2
Summary: An SDK for integrating with inMotion API
Project-URL: Homepage, https://github.com/jwaring/inmotion-python-sdk
Project-URL: Repository, https://github.com/jwaring/inmotion-python-sdk
Project-URL: Issues, https://github.com/jwaring/inmotion-python-sdk/issues
Project-URL: Documentation, https://inmotion.io/assets/sdk-docs/inmotion.html
Author-email: Jason Waring <jason@softwaringsolutions.com>
License: MIT
License-File: LICENSE
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Requires-Dist: marshmallow
Requires-Dist: marshmallow-dataclass
Requires-Dist: pycryptodome
Requires-Dist: python-dotenv
Requires-Dist: requests
Provides-Extra: dev
Requires-Dist: pdoc; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: tox; extra == 'dev'
Description-Content-Type: text/markdown

# inmotion-python-sdk

A Python Software Development Kit (SDK) for integration with inMotion APIs.

This is still a fledgling project as only a handful of endpoints have been implemented.

Full API reference (generated from docstrings): https://inmotion.io/assets/sdk-docs/inmotion.html

# Prerequisites

* Python 3.10 or higher
* An inMotion account with either a Dev Key/Secret + API Key pair, or a username/password —
  see "Authenticating" below for where to create these

# Install

```bash
pip install inmotion-sdk
```

The distribution is `inmotion-sdk`, but the importable package is still `inmotion`:

```python
import inmotion
```

# Quickstart

Every operation in this SDK hangs off a `session`, obtained from a client. There are two ways to
authenticate, depending on what credentials you have.

## Authenticating with a Dev Key/Secret + API Key

Create a Dev Key/Secret under your inMotion account's `Settings` menu, `Dev Keys` tab (only shown
if the account allows key creation), and an API Key under the `API Keys` tab (`Consumer` authority
or higher is recommended). You'll also need your account key.

```python
from inmotion.apikey_client import InMotionAPIKeyClient

client = InMotionAPIKeyClient(
    base_url="https://api.inmotion.io",
    dev_key="...",
    dev_secret="...",
    api_key="...",
)
session = client.get_session(account="...")
```

## Authenticating with a username and password

```python
from inmotion.credentials_client import InMotionCredentialsClient

client = InMotionCredentialsClient(base_url="https://api.inmotion.io", dev_key="...", dev_secret="...")
session = client.get_session(account="...", username="...", password="...")
```

Once you have a `session`, it exposes the feature areas documented below, e.g.
`session.activities()`, `session.accounts()`, `session.folio()`. Call `session.disconnect()` when
done: it logs out, which invalidates the token on the server.

The response models ignore fields the SDK does not know, so a server that adds a field does not
break an older client.

### Multi-factor authentication

When the account has MFA enabled, `get_session` raises `InMotionMfaRequiredError`:

```python
from inmotion.exceptions import InMotionMfaRequiredError

try:
    session = client.get_session(account, username, password)
except InMotionMfaRequiredError as challenge:
    session = client.verify_mfa(account, challenge.mfa_token, input("Code: "))
```

`resend_mfa(mfa_token)` regenerates an email/SMS code, and `switch_mfa_method(mfa_token, method)`
moves the challenge to another enrolled method.

### Registering an account

`register_account` creates an account and its owner without a session. When the result's
`mfaEnrollment` is set, the owner must enroll in MFA before getting a session: enroll using the
`/user/mfa` calls with `mfaEnrollment.enrollToken` as the token, then
`client.complete_mfa_enrollment(account, token)`. If `mfaEnrollment.mandatory` is false,
`client.skip_mfa_enrollment(account, token)` goes straight to a session.

```python
details = client.register_account(AccountRegistrationModel(
    owner=UserRegistrationModel(userKey="jdoe", userName="jdoe", password="...", displayName="J Doe",
                                email="jdoe@example.com", attrs={}, licenseAccepted=now_millis,
                                publicUserName=False),
    name="Acme", address=None, accountType=AccountType.FREE_PERSONAL, profiles=[], attrs={}))
```

# Examples

`examples/` has small, self-contained scripts demonstrating the Site and Track activity APIs and roll-up statistics:

- `site_timeseries_example.py` — generates a synthetic weather-sensor CSV (temperature + humidity),
  then reads it back and uploads it as a Site activity timeseries.
- `site_statistics_example.py` — reads a data stream's roll-up statistics for a window and prints
  each variable's coverage, mean and range.
- `track_timeseries_example.py` — generates a synthetic vehicle-track CSV (lat/lon/altitude +
  speed), then reads it back and uploads it as a Track activity timeseries.

Each script both generates its own input data and uploads it, so there's nothing to fetch — they're
meant to be read top-to-bottom as a concrete illustration of find-or-create-activity plus
publish-records. To run one:

```bash
cp examples/.env.example examples/.env   # fill in real credentials
python3 examples/site_timeseries_example.py
```

# Feature Areas

## Upgrading from an older version

If you were already calling `find_track_activity`, `find_site_activity`, `get_track_records`,
`get_site_records`, `find_activities_within_time_range`, or `find_latest_activity_stats`, note that
earlier versions of this SDK issued every HTTP request as a `POST`, even for these read-only,
`GET`-only endpoints. Against a real inMotion server this either silently invoked the wrong
operation or returned a 404. These methods now issue the correct HTTP verb — no code changes are
required to call them, but double-check any code that was working around the previous failures.

`InMotionCredentialsClient.connect(...)` has been renamed to `get_session(...)` to match
`InMotionAPIKeyClient`, and now authenticates against the current `/api/latest/authenticate`
endpoint rather than the deprecated `/api/authenticate`.

`session.raster_overlay()` and `InMotionRasterOverlay` have been removed in 0.3.0. The server no longer has
a `/raster-overlay` API, so every call would have failed. Gridded and raster data are now Datasets, read with
`session.datasets()` (see "Datasets" below), and found with `session.explorer()`.

## Track and Site Activities

In addition to create/update/find/records, the activities interface now supports the full
lifecycle of a track or site activity:

```python
activities = session.activities()

activities.delete_track_activity(track_key)
activities.unlock_track_activity(track_key)
activities.find_all_track_records(track_key)  # no time-range restriction
records_bytes = activities.download_track_records(track_key, "csv")  # 'csv', 'json' or 'gpx'

activities.share_track_activity(track_key, "any")  # or "private"
activities.unshare_track_activity(track_key, "any")
activities.find_shared_track_activity(track_key)
activities.find_all_shared_track_records(track_key)
```

The same methods exist for sites (`delete_site_activity`, `unlock_site_activity`,
`find_all_site_records`, `download_site_records`, `share_site_activity`, `unshare_site_activity`,
`find_shared_site_activity`, `find_shared_site_records`).

Cross-account analytics, a batch create/update endpoint, and activity master data are also
available:

```python
activities.find_activity_master_data()  # profile types and activity types

analytics = activities.find_activity_analytics(ActivityAnalyticsRequestModel(groupBy=["activityType"]))
track_metrics = activities.find_activity_track_metrics(ActivityAnalyticsRequestModel(accounts=[account_key]))
variable_stats = activities.find_activity_variable_stats(ActivityAnalyticsRequestModel(accounts=[account_key]))

activities.find_latest_activity_stats_by_type(since, CoordinateConvention.TRACK)

activities.batch_record_update(ActivityBatchCommandsModel(
    tracks=TrackActivityBatchCommandsModel(create=[TrackCreateActivityBatchModel(activity=..., recordInterval=1000)]),
))
```

## Accounts

`session.accounts()` exposes account management operations:

```python
accounts = session.accounts()

account = accounts.find_account(account_key)
tags = accounts.find_account_tags(account_key)
accounts.request_account_data(account_key)  # emails a copy of the account's data to its owner
accounts.update_account(account_key, AccountModel(name="Acme", address=None, accountType=AccountType.CONSUMER_STANDARD, attrs={}, profiles=[]))

users = accounts.find_account_users(account_key)
accounts.register_account_user(account_key, user_key, privileges)
accounts.unregister_account_user(account_key, user_key)
accounts.batch_update_account_users(account_key, [AccountUpdateBatchCommandModel(action="register", userName="jdoe", privileges=None)])

new_account = accounts.create_account_only(AccountModel(name="New Co", address=None, accountType="I", attrs={}, profiles=[]))
accounts.mark_account_for_deletion(account_key, and_user=False)

accounts.find_my_accounts()  # accounts the authenticated user belongs to
```

It also manages an account's Standard Data Type/Variant Type overrides (submitted as raw YAML
text, requires the "custom-sdt" account feature) and its Device Config lifecycle (requires the
"custom-device-config" account feature):

```python
accounts.list_standard_data_types(account_key)
accounts.create_standard_data_type(account_key, yaml_document)
accounts.update_standard_data_type(account_key, key, yaml_document)
accounts.delete_standard_data_type(account_key, key)
# ...and the equivalent list/create/update/delete_standard_data_variant_type methods

accounts.fetch_device_configs(account_key)  # merged, consumer-facing fetch
accounts.list_device_configs(account_key)  # full version history, for an editing UI
created = accounts.create_device_config(account_key, yaml_document)
accounts.start_device_config_development(account_key, created["name"])
accounts.save_device_config(account_key, created["name"], yaml_document)
accounts.publish_device_config(account_key, created["name"], "1.0.0")
accounts.withdraw_device_config(account_key, created["name"], 1)
accounts.republish_device_config(account_key, created["name"], 1)
accounts.discard_device_config_development(account_key, created["name"])
accounts.delete_device_config(account_key, created["name"])

# System-wide (non-account-scoped) global tier, and a combined sync delta across global + accounts
accounts.fetch_global_device_configs()
accounts.sync_device_configs(DeviceConfigSyncRequestModel(accountKeys=[account_key]))
# Pass maxSchemaVersion=2 to receive canonical schema-v2 documents (variables/mqtt blocks intact);
# omitted or 1 means the server down-projects every entry to the frozen v1 shape and omits
# `type: mqtt` entries entirely.
accounts.sync_device_configs(DeviceConfigSyncRequestModel(accountKeys=[account_key], maxSchemaVersion=2))
```

## Activity Configuration

`session.activity_config()` manages the Quality Control, Processing, and Custom Data sections of
an activity's configuration, and supports bad-period detection for track activities:

```python
config = session.activity_config()

full_config = config.find_activity_config(activity_key)

config.update_qc_config(activity_key, ActivityConfigQCUpdateModel(qualityControl=QCConfigModel(regions=[...])))
config.update_processing_config(activity_key, ActivityConfigProcessingUpdateModel(processing={...}))
config.update_custom_data_config(activity_key, ActivityConfigCustomDataUpdateModel(entries=[...]))

# Workflow: detect candidate bad periods on a track activity, then merge them into the QC config
detected = config.detect_bad_periods(activity_key, ActivityConfigBadPeriodDetectRequestModel())
config.merge_bad_periods(activity_key, ActivityConfigBadPeriodMergeRequestModel(
    periods=detected.periods, detectorVersion="v1", dryRun=False))

regions = config.generate_qc_regions(activity_key, ActivityConfigQCRegionGenerateRequestModel())

config.delete_activity_config(activity_key)
```

## Developer Keys and API Keys

`session.dev_keys()` and `session.api_keys()` manage the developer key / secret pairs and API keys
issued against an account:

```python
dev_keys = session.dev_keys()
keys = dev_keys.find_dev_keys(account_key)
new_key = dev_keys.create_dev_key(account_key, AccountDevKeyCreatorModel(name="ci", hmacEnabled=True, expiryOn=None))
dev_keys.update_dev_key(account_key, new_key.devKey, AccountDevKeyUpdatorModel(name="ci-renamed", hmacEnabled=None))
dev_keys.delete_dev_key(account_key, new_key.devKey)

api_keys = session.api_keys()
api_key = api_keys.create_api_key(account_key, AccountAPIKeyCreatorModel(name="integration", privs=privileges, expiryOn=None))
```

## User Management

`session.user()` manages the currently authenticated user, and account-scoped user registration:

```python
user = session.user()

user.find_user_attributes()
user.update_user_attributes(UserAttributesModel(userName="jdoe", displayName="J Doe", email="j@x.com",
                                                 publicUserName=False, firstName=None, lastName=None,
                                                 avatarUrl=None, attrs={}))

# Register a new user and grant them a privilege level ('view', 'contribute', or 'admin') on an account
user.create_user_against_account(account_key, "view", registration)

user.request_password_reset(UserPasswordRequestModel(userNameOrEmail="jdoe"))
user.unregister_from_account(account_key)

# A one-time code key pair, for device pairing/bootstrap flows
otc = user.create_otc()
```

## Uploads

`session.upload()` manages file uploads (e.g. track/route/coverage data files) and their
processing lifecycle:

```python
upload = session.upload()

created = upload.upload_file(account_key, "/path/to/track.gpx", content_type="application/gpx+xml")
uuid = next(iter(created))

upload.find_upload_metadata(uuid)
upload.update_upload_metadata(uuid, UploadMetadataChangeCommandModel(
    mimeType="application/gpx+xml", nature="track", attributes={}))

preview = upload.find_upload_preview(uuid, "track")
upload.process_upload(uuid)  # commit the upload into inMotion once its nature/metadata are set

upload.cancel_upload(uuid)  # or, once no longer needed:
upload.delete_upload(uuid)

upload.find_uploads(account_key)  # all tracked uploads for the account

upload.upload_diagnostics(account_key, "/path/to/crash.log")  # stored server-side, outside the tracked-upload pipeline
```

**Note:** `upload_file`'s multipart request signing has been verified against the server's
signing/verification code (`APIActions.scala`) but not yet against a live inMotion instance —
test it against `.env.test` before relying on it in production.

## Folio

`session.folio()` manages folios: a tree-structured document attached to an account - a versioned
root plus an arbitrary tree of named sections, each holding items that are either inline
structured text or references to an Activity, DataStream, or another Folio.

```python
folio_api = session.folio()

root = FolioRootModel(attrs={}, items=[], sections=[])
f = folio_api.create_folio(FolioModel(name="Site A", description="...", accountKey=account_key, owner=user_key, created=0, root=root))
folio_api.update_folio(f.key, FolioModel(name="Site A (renamed)", description="...", accountKey=account_key, owner=user_key, created=0, root=root))
folio_api.find_folio(f.key)
folio_api.find_folios(account_key, name="Site A")
folio_api.find_folios_by_reference(account_key, data_stream_key)
folio_api.set_folio_locked(f.key, True)   # lock/unlock against further Contributor edits

# Sections and items are addressed by a "/"-separated `path` from the root (omitted = the root itself)
folio_api.create_section(f.key, FolioSectionCreateModel(name="Sensors", description="..."))
folio_api.find_section(f.key, path="Sensors", deep=True)
folio_api.update_section(f.key, FolioSectionUpdateModel(description="Updated"), path="Sensors")

folio_api.add_items(f.key, [FolioItemModel(kind="dataStream", name="Reading 1", dataStreamKey=ds_key, owned=True)], path="Sensors")
folio_api.update_item(f.key, "Reading 1", FolioItemModel(kind="text", name="Reading 1", format="YAML", content="..."), path="Sensors")
folio_api.delete_item(f.key, "Reading 1", path="Sensors")
folio_api.delete_items(f.key, ["Reading 2"], path="Sensors", cascade=True)

folio_api.delete_section(f.key, path="Sensors", cascade=True)
folio_api.validate_folio(f.key)  # check against the folio's optional template, if any

folio_api.delete_folio(f.key)
```

## Shapes

`session.shape()` manages shapes: a named, classified collection of polygons (which may have
holes/islands), stored as a single GeoJSON FeatureCollection, and owned directly by an account
(not gated by Folio's role/contributor model).

```python
shape_api = session.shape()

s = shape_api.create_shape(ShapeModel(name="Field 12", accountKey=account_key, geojson=geojson_str))
shape_api.find_shapes(account_key, classification="Boundary")
shape_api.find_shape(s.key)

shape_api.update_shape(s.key, ShapeUpdateModel(name="Field 12 (renamed)"))
shape_api.update_shape_geometry(s.key, ShapeGeometryModel(geojson=updated_geojson_str))

copy = shape_api.duplicate_shape(s.key)  # independent copy of geometry + metadata
shape_api.delete_shape(s.key)
```

A track activity's GPS records can also be converted into a standalone Route shape (gated by the
"track-to-shape" account feature) via `session.activities().convert_track_to_route(track_key)`,
which returns the new shape's key.

## Datasets

`session.datasets()` reads gridded data such as model output, satellite imagery and drone imagery. A dataset
is a data stream with a mapping that says how its file is laid out, so it has a description (dimensions,
coordinate sets and variables) and is read as tiles and slices of values, as samples at places and times, and
with a display config that says how it is first shown. Times are ISO 8601 instants such as
`2025-02-10T02:00:00Z`; a variable that does not vary with time takes no `time`, and one that does needs it.

```python
ds_api = session.datasets()

for d in ds_api.find_datasets(account_key):
    print(d.key, d.name, d.kind)

description = ds_api.find_dataset_description(key)
variable = description.variables[0]          # name, sdt, units, dims, coords, extent

# Values of a variable, parsed from the API's hyperslab format
tile = ds_api.find_dataset_tile(key, variable.name, z=4, x=13, y=8, time="2025-02-10T02:00:00Z")
rows = tile.grid(variable.name)             # a list of rows; use `tile.variables[...].unpack(v)` for real values
window = ds_api.find_dataset_slice(key, variable.name, time="2025-02-10T02:00:00Z", start="0,0", count="100,100")

# The range colours run between where none is chosen (2nd to 98th percentile over the extent)
r = ds_api.find_dataset_range(key, variable.name, time="2025-02-10T02:00:00Z")
```

Sample a variable at places and times, such as those of a Site or a Track. Point values are interpolated across
the cell and between the two records either side of the time, with no extrapolation. Give an `observed` value
with a position to get the comparison of the dataset with the observations:

```python
result = ds_api.sample_dataset(key, DatasetSampleRequestModel(
    variable="temp", level=0,
    positions=[DatasetSamplePositionModel(longitude=115.5, latitude=-32.0, time="2004-02-01T00:00:00Z", observed=21.8),
               DatasetSamplePositionModel(longitude=115.6, latitude=-32.1, time="2004-02-01T06:00:00Z", observed=21.4)]))
for s in result.samples:
    print(s.value if s.value is not None else s.reason)   # reason: outside, no data or masked
print(result.comparison.bias, result.comparison.rootMeanSquareError, result.comparison.correlation)
```

A request over the cost limit is refused with an error that gives the largest window or batch that is allowed;
it is never truncated.

How a dataset is first shown is its display config. It is replaced with YAML (or JSON) text, with keys such as
`fill_mode` (`image`, `bands`, `composite` or `ndvi`) and `axis_range`, and refused with each thing wrong at its
path if it does not agree with the mapping:

```python
ds_api.update_dataset_display(key, """
view:
  variable: band_5
  fill_mode: ndvi
  ndvi: { nir: band_5, red: band_3 }
""")
display = ds_api.find_dataset_display(key)    # display.view.fillMode == "ndvi"
```

A mapping is replaced the same way with `update_dataset_mapping(key, yaml_text)`, name, comment, tags, copyright
and licence with `update_dataset_properties`, and the files of a dataset's set are listed with
`find_dataset_members` and checked against their registered size and SHA-256 with `verify_dataset_members`.
`find_dataset_edges` gives the cell edges that cross a tile, to draw a grid.

Gridded data is also served to GIS clients such as QGIS as WMS and WMTS layers, on an account with the
`wms-serving` feature; see the inMotion documentation.

## Explorer

`session.explorer()` searches the Tracks, Sites, shapes, rasters and grids of every account you can read, by kind,
category, place, time or text. Leaving `scope` out looks in every account you can access whose subscription
includes Explore. A place is an `ExplorerBBoxModel` in degrees (a region over the antimeridian has `east` past
180) and times are milliseconds since the epoch.

```python
explorer = session.explorer()

page = explorer.find_catalog(kinds=["track", "site"],
                             bbox=ExplorerBBoxModel(west=110.6, south=-34.5, east=115.8, north=-26.1),
                             time_from=1041379200000, time_to=1072915200000, limit=100)
for layer in page.layers:
    print(layer.kind, layer.label, layer.time_from_datetime(), layer.time_to_datetime())
print(page.total, page.facets["kind"])        # counts by kind, category and profile
if page.capped:
    more = explorer.find_catalog(kinds=["track", "site"], cursor=page.nextCursor)

cells = explorer.find_clusters(cell=1.0, kinds=["site"])          # layers grouped into a grid
profile = explorer.find_time_profile(buckets=60, kinds=["track"])  # how many end in each slice of time
```

Together these find the records a dataset can be compared with: search the catalog within a dataset's extent
and time, read a Track's or Site's records with `session.activities()`, then pass their places, times and values
to `sample_dataset`.

## MQTT Ingestion Deployments

`session.mqtt_deployment()` manages deployments of a `type: mqtt` (schema-v2) Device Config. The
Device Config is the *class*; registering a deployment against a published version of it creates
the backing Site activity (sensor schema from the config's `variables:` block) and mints a
scoped, publish-only API key the device authenticates to the broker with. Every method is
admin-gated on the account, and the key value is returned exactly once. Responses have no fixed
schema, so each method returns a raw `dict`.

```python
mqtt = session.mqtt_deployment()

reg = mqtt.register_mqtt_deployment(account_key, MqttDeploymentRegistrationModel(
    deviceConfigName="Backyard Weather", name="Shed roof", latitude=-31.95, longitude=115.86))
deployment_id = reg["deploymentId"]
publish_key = reg["apiKey"]           # shown only here

mqtt.list_mqtt_deployments(account_key)
mqtt.update_mqtt_deployment(account_key, deployment_id, MqttDeploymentUpdateModel(name="Shed roof (N)"))
mqtt.repoint_mqtt_deployment(account_key, deployment_id)      # move to the latest published config version
mqtt.rotate_mqtt_deployment_key(account_key, deployment_id)   # new publish key, old one revoked
mqtt.delete_mqtt_deployment(account_key, deployment_id)       # backing Site activity is retained
```

## Events

`session.events()` manages events: an Activity peer of Track/Site whose payload is arbitrary
(photo, sqlite file, diagnostics, ...) rather than structured data - a single point in space/time,
optionally carrying a thumbnail/icon.

```python
events = session.events()

location = EventLocationModel(latitude=51.5, longitude=-0.1, timeUtc=int(time.time() * 1000))
event = events.create_event(EventCreatorModel(dataStream=data_stream_creator, location=location))

events.update_event(event.dataStream.key, EventUpdateModel(dataStream=data_stream_creator))
events.find_event(event.dataStream.key)
events.unlock_event(event.dataStream.key)

events.find_events(DataStreamFilterModel(accounts=[account_key]))
events.find_nearby_events(EventNearbyFilterModel(
    accounts=[account_key], minTime=0, maxTime=int(time.time() * 1000),
    minLatitude=51.0, maxLatitude=52.0, minLongitude=-1.0, maxLongitude=1.0))

events.delete_event(event.dataStream.key)
```

## Data Streams

`session.data_stream()` manages data streams and their two kinds of data channel: hyperslab
(array/gridded numeric data) and blob (byte-oriented data, e.g. images or arbitrary binary blobs).

```python
ds_api = session.data_stream()

ds = ds_api.create_data_stream(DataStreamCreatorModel(
    name="Weather Station 1", description="...", account=account_key, owner=user_key, tags=[],
    sourceIdentifier="ws1", sourceCategory="weather", sourceProfile="standard", sourceName="WS1",
    acqConv="raw", coordConv="wgs84", timezone="UTC", attrs={}, created=0))

ds_api.find_data_stream(ds.key)
ds_api.find_data_streams(DataStreamFilterModel(accounts=[account_key]))
ds_api.find_data_streams_by_name(account_key, "Weather")

# Hyperslab (numeric) channels
ds_api.create_hyperslab_channel(ds.key, DataChannelCreatorModel(
    channelType="temperature", profiles={}, unlimitedDim="time", fixedDims={}, vars={}, created=0))
ds_api.update_invariant_hyperslab_data(ds.key, "temperature", {"units": "celsius"})
ds_api.update_hyperslab_record_data(ds.key, "temperature", {"timeUtc": [...], "value": [...]})
ds_api.find_hyperslab_record_data(ds.key, "temperature", start, end)

# Roll-up statistics: a window as a few hundred buckets of count, mean and spread
stats = ds_api.retrieve_statistics(ds.key, "AA", start, end, max_points=300)
if stats.available:                       # false when no roll-up covers the window
    temperature = stats.variable("airTemperature")
    for when, n, mean in zip(stats.times_datetime(), temperature.values["n"], temperature.values["mean"]):
        ...                               # a bucket with no readings has n 0 and mean None

# Blob (byte-oriented) channels
ds_api.create_blob_channel(ds.key, DataChannelCreatorModel(
    channelType="image", profiles={}, unlimitedDim=None, fixedDims={}, vars={}, created=0))
ds_api.update_invariant_blob_data(ds.key, "image", "raw", image_bytes)
ds_api.register_invariant_blob_data(ds.key, "image", "raw", data_uuid)  # data already uploaded
ds_api.find_latest_blob_record_data(ds.key, "image", "raw")
raw_bytes = ds_api.open_blob_stream(ds.key, blob_key)

ds_api.find_blobs(DataStreamFilterModel(accounts=[account_key]))
ds_api.unlock_data_stream(ds.key)
ds_api.delete_data_stream(ds.key)
```

**Note:** the hyperslab data endpoints (`find`/`update_invariant_hyperslab_data`,
`find`/`update_hyperslab_record_data`) and the three channel management calls
(`create`/`update`/`delete_hyperslab_channel`, `create`/`update`/`delete_blob_channel`) return a raw
`dict` rather than a typed model — the server itself has no fixed schema for these (the shape is
derived per data-channel variable definition), so a fixed dataclass here would be guessing a schema
the server doesn't have. The blob byte-upload methods (`update_invariant_blob_data`,
`update_blob_record_data`) have been verified against the server's signing code but not yet against
a live inMotion instance — test them against `.env.test` before relying on them in production.

# Development

The following sections are for contributing to the SDK itself, not for consuming it.

## Build

To build the SDK, you can use the following command:

```bash
uv build
```

## Generate Docs

Static HTML API docs, generated from the package's docstrings via [pdoc](https://pdoc.dev/):

```bash
bin/generate-docs.sh
```

Output lands in `docs/` by default (override with `INMOTION_DOCS_DIR`); it's gitignored since
it's a generated artifact, not source.

## Unit Tests

The `tests/` directory contains a `pytest`-based unit test suite covering request signing, error
handling, and the API key / credentials client authentication flows. These tests mock all HTTP
calls, so no live inMotion environment is required.

```bash
source .venv/bin/activate
uv pip install -e ".[dev]"
python -m pytest tests/
```

## Integration Tests

* Ensure that there is an inMotion integration test environment available.
* Configure the environment file (`.env.test` in the root directory) with the necessary credentials and URLs.

```dotenv
BASE_URL="http://localhost:9000"
DEV_KEY="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
DEV_SECRET="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
API_KEY="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
ACCOUNT="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
```

The `DEV_KEY`, `DEV_SECRET` can be created using the inMotion `Settings ...` menu under the right hand side. If the account allows the
creation of keys, a tab called `Dev Keys` will be shown. Create the key / secret and copy the values.

The `API_KEY` can be created using the `API Keys` tab. The `ACCOUNT` key and will need to be copied. Note that it is recommended that the
API Key be at least `Consumer` authority to support the text.

Please run the tests in a virtual environment to avoid dependency conflicts.

```bash
source .venv/bin/activate
uv pip install -e .
python3 scripts/test.py
```
