# enody Python SDK

> Python SDK for Enody Lighting spectrally tunable fixtures. Provides USB and WiFi device control, WiFi pairing/token authorization, spectral data access, colorimetry, and GPU-accelerated spectral optimization.

## Architecture

Enody is a federated system where Runtimes communicate via message passing. An Environment is the entry point for device discovery and resource access — it owns connections to remote Runtimes and exposes the resources they contain. Discovery environments (UsbEnvironment, WifiEnvironment) actively scan for devices. Fixtures are addressed by identifier, independent of which Host owns them, enabling mesh routing and location transparency.

enody_python wraps the enody-rs Rust core library via PyO3 bindings. The native extension lives at `enody._enody_rs`. High-level Python interfaces, optimization, and colorimetry are pure Python on top.

Build system: maturin. Dependencies: tinygrad (tensors/autodiff), colour-science (color science), enody-rs (Rust core via PyO3). WiFi support comes from enody-rs 0.2.x and includes mDNS discovery, Noise token pairing, token storage, and authenticated WiFi runtimes.

## Entity hierarchy

Environment → Runtime → Host → Fixture → Source → Emitter

- Environment: Discovery and organizational surface. Owns connections to remote Runtimes and provides access to their resources. Implementations: UsbEnvironment, UserDefinedEnvironment.
- Runtime: Message-passing participant in the Enody mesh. Has exactly one Host. A Host can run multiple Runtimes.
- Host: The physical device, identified by UUID and firmware version.
- Fixture: Addressable light output unit containing Sources. Addressed by identifier independent of Host.
- Source: Independently controllable region containing Emitters.
- Emitter: Single LED channel with a characteristic spectral distribution (401 samples, 380-780nm, 1nm resolution).

## Package structure

```
enody/
  __init__.py        # Re-exports core types, USB/WiFi environments, token helpers, device classes
  interface.py       # High-level UsbEnvironment, WifiEnvironment, Runtime, Host, Fixture, Source, Emitter, Token, TokenStore, WifiConnection, UpdateTarget classes
  colorimetry.py     # XYZ, Chromaticity, SpectralSample, SpectralData
  data.py            # Sample data loaders (no device required)
  optimize.py        # Differentiable spectral optimization functions (tinygrad)
  cli.py             # CLI entry point (enody list, info, monitor, wifi-scan, wifi-join, wifi-setup, wifi-generate-token, download-spectral-data, update)
  data/
    fixture.json     # Bundled fixture with 1 source, 12 emitters
    response.json    # Photopic response functions (melanopic, rhodopic, cones, CIE XYZ)
```

## Native types (enody._enody_rs)

SpectralSample(wavelength: f32, measurement: f32)
  .wavelength, .measurement   # Properties (getters), not methods

SpectralData
  .samples() → list[SpectralSample]
  .wavelengths() → list[f32]
  .measurements() → list[f32]
  .sample_count() → int

Chromaticity(x: f32, y: f32)
  .x, .y

Flux
  Flux.relative(value: f32) → Flux    # value in 0.0-1.0
  .value → f32

Configuration
  Configuration.blackbody(cct: f32)    # CCT in Kelvin
  Configuration.chromatic(x: f32, y: f32)  # CIE 1931 xy
  Configuration.manual()
  Configuration.flux()
  Configuration.spectral()

UsbEnvironment()                       # Not thread-safe
  .runtimes() → list[Runtime]
  .start_discovery(), .stop_discovery()
  .next_runtime_event() → EnvironmentRuntimeEvent

WifiEnvironment(tokens=None, timeout_ms=800, excluded_host_ids=None)
  # tokens=None loads TokenStore.load().tokens(); [] disables saved-token discovery
  .runtimes() → list[Runtime]
  .start_discovery(), .stop_discovery()
  .next_runtime_event() → EnvironmentRuntimeEvent
  .exclude_host_id(host_id), .remove_excluded_host_id(host_id)

EnvironmentRuntimeEvent
  .kind() → "arrived" | "left"
  .runtime() → Runtime

Token(host_id: str, key_id: str, data: list[int])
  .host_id(), .key_id(), .data()
  .to_dict() / Token.from_dict(data)
  repr redacts token data

TokenStore
  TokenStore.load(), TokenStore.load_from_path(path)
  TokenStore.save_token(token)
  .tokens() → list[Token]
  .upsert(token), .save(), .save_to_path(path)

WifiNetwork
  .ssid(), .bssid(), .channel(), .rssi(), .auth()

WifiDiscoveredDevice
  .endpoint(), .host_id(), .firmware_version(), .is_ep01()
  .is_token_generation_candidate()

WifiConnection
  .discover_token_generation_devices(timeout_ms=800) → list[WifiDiscoveredDevice]
  .generate_token_from_discovered_device(device, on_approval=None) → Token
  .generate_token_from_endpoint(endpoint, on_approval=None) → Token
  .runtime_from_discovered_device(token, device) → Runtime
  .runtime_from_endpoint(token, endpoint) → Runtime

Runtime
  .host() → Host
  .connect(), .disconnect(), .is_connected()
  .generate_token() → Token

Host
  .identifier() → str (UUID)
  .version() → str
  .fixtures() → list[Fixture]
  .wifi_scan() → list[WifiNetwork]
  .wifi_join(ssid, password="")

## High-level Python classes (enody.interface)

Emitter
  Emitter.from_json(json_data) → Emitter
  Emitter.from_device(remote_emitter) → Emitter
  .identifier() → str
  .spectral_data() → SpectralData
  .tensor() → Tensor              # shape: (401,)
  .set_flux(flux)                  # device-backed only

Source
  Source.from_json(json_data) → Source
  Source.from_device(remote_source) → Source
  .identifier() → str
  .emitters() → list[Emitter]
  .tensor() → Tensor              # shape: (n_emitters, 401)
  .display(config, flux)           # device-backed only
  .plot_emitter_spectral_distributions()
  .plot_emitter_chromaticity_diagram()

Fixture
  Fixture.from_json(json_data) → Fixture
  Fixture.from_device(remote_fixture) → Fixture
  .identifier() → str
  .sources() → list[Source]
  .tensor() → Tensor              # shape: (n_sources, n_emitters, 401)
  .display(config, flux)           # device-backed only

## WiFi authorization flow

Primary Home Assistant style flow:
  token = enody.generate_wifi_token(on_approval=callback, save=False)

Behavior:
  1. Discover token-generation candidates over mDNS using WifiConnection.discover_token_generation_devices().
  2. If exactly one device is found, run unauthenticated WiFi pairing over Noise_NN.
  3. Device sends approval instructions via callback; user physically approves on EP01.
  4. Returned Token is verified by reconnecting with the authenticated WiFi Noise PSK flow and reading host.identifier().
  5. Verification retries 8 times with 500ms delay by default.
  6. Token can be stored by Home Assistant via token.to_dict(), or by enody via TokenStore.save_token(token).

Helpers:
  generate_wifi_token(device=None, endpoint=None, timeout_ms=800, on_approval=None, verify=True, save=False)
  verify_wifi_token_from_discovered_device(token, device)
  verify_wifi_token_from_endpoint(token, endpoint)
  discover_runtimes(tokens=None, include_usb=True, include_wifi=True, wifi_timeout_ms=800)

Saved-token runtime discovery:
  tokens = enody.TokenStore.load().tokens()
  env = enody.WifiEnvironment(tokens)
  runtimes = env.runtimes()

## enody.data

All sample data is extracted from the single bundled fixture (data/fixture.json):
  sample_fixture() → Fixture         # 1 source, 12 emitters
  sample_source() → Source           # First source from fixture
  sample_emitter() → Emitter        # First emitter from source

Photopic response data (from data/response.json):
  melanopic_action() → list[float]   # 401 measurement values
  rhodopic_action() → list[float]
  s_cone_action() → list[float]
  m_cone_action() → list[float]
  l_cone_action() → list[float]
  cie_x_action() → list[float]
  cie_y_action() → list[float]
  cie_z_action() → list[float]

## enody.optimize

All functions operate on tinygrad Tensors and are differentiable.

### SSI (Spectral Similarity Index)
ssi(test: Tensor, reference: Tensor) → Tensor
  Input shapes: (n, 301) — 380-680nm range
  Output: SSI scores (100 = perfect match)
  Uses resampling convolution, normalization, weighting, and smoothing.

### Photopic response functions
All accept (n, 401) tensors (380-780nm) and return weighted response sums:
  melanopic_response(t) → Tensor
  rhodopic_response(t) → Tensor
  s_cone_response(t) → Tensor
  m_cone_response(t) → Tensor
  l_cone_response(t) → Tensor
  cie_x_response(t) → Tensor
  cie_y_response(t) → Tensor
  cie_z_response(t) → Tensor

### Chromaticity
cie_1931_chromaticity(t: Tensor) → Tensor
  Input: (n, 401) tensor
  Output: (2, 1, n, 1) tensor — [x, y] chromaticity coordinates

## enody.colorimetry

Pure Python color science types with colour-science integration:
  XYZ(x, y, z)
  Chromaticity(x, y)
  SpectralSample(wavelength, measurement)    # Properties: .wavelength, .measurement
  SpectralData(samples)
    .wavelengths() → list
    .measurements() → list
    .sample_count() → int
    .spectral_distribution() → colour.SpectralDistribution
    .tensor() → Tensor

## CLI (enody command)

  enody list                          # List connected EP01 devices
  enody info                          # Show detailed USB/WiFi device/fixture/source/emitter info
  enody monitor                       # Stream device log output
  enody wifi-scan                     # Scan WiFi networks through first USB EP01
  enody wifi-join SSID                # Join a WiFi network through first USB EP01
  enody wifi-setup                    # Interactive scan/join and USB-authenticated token save
  enody wifi-generate-token           # mDNS WiFi pairing, verification, and token save
  enody download-spectral-data [-o FILE]  # Save spectral data as JSON
  enody update [-f FILE]              # Update device firmware (interactive version picker, or offline .bin)

## Common optimization pattern

```python
from tinygrad.tensor import Tensor, dtypes
from tinygrad import nn
from enody import Configuration, Flux
from enody.optimize import ssi, cie_1931_chromaticity

source = fixture.sources()[0]
spd_matrix = source.tensor()               # (n_emitters, 401)
n = len(source.emitters())

weights = Tensor.ones(1, n, dtype=dtypes.float32) * 0.5
optimizer = nn.optim.Adam([weights], lr=1e-3)

for i in range(1000):
    optimizer.zero_grad()
    duty_cycles = weights.clip(0, 1)
    emission = (duty_cycles @ spd_matrix) + 1e-9  # (1, 401)

    # Compute loss using ssi(), cie_1931_chromaticity(), or photopic responses
    loss = ...
    loss.backward()
    optimizer.step()

# Apply to hardware
for idx, emitter in enumerate(source.emitters()):
    emitter.set_flux(Flux.relative(float(weights.clip(0,1).numpy().flatten()[idx])))
fixture.display(Configuration.manual(), Flux.relative(1.0))
```

## Spectral data conventions

- Full spectrum: 401 samples, 380-780nm, 1nm resolution
- SSI range: 301 samples, 380-680nm (first 301 of full spectrum)
- All values are f32
- Tensors use tinygrad float32 dtype

## JSON data format

Spectral data is encoded as a list of SpectralSample objects, directly reflecting the internal type:

```json
{
  "identifier": "uuid-string",
  "sources": [{
    "identifier": "uuid-string",
    "emitters": [{
      "identifier": "uuid-string",
      "spectral_data": [
        {"wavelength": 380.0, "measurement": 0.0},
        {"wavelength": 381.0, "measurement": 0.001}
      ]
    }]
  }]
}
```

Response data (response.json) uses the same sample list format:

```json
{
  "Melanopic response": [
    {"wavelength": 380.0, "measurement": 0.0},
    {"wavelength": 381.0, "measurement": 0.001}
  ]
}
```
