Metadata-Version: 2.4
Name: gmg-local
Version: 0.2.0
Summary: Local UDP client for Green Mountain Grills Wi-Fi pellet grills
Project-URL: Homepage, https://github.com/y3klab/gmg-local
Project-URL: Issues, https://github.com/y3klab/gmg-local/issues
Author: Y3K Lab
License-Expression: MIT
License-File: LICENSE
Keywords: bbq,gmg,green mountain grills,grill,home assistant,pellet,smoker
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# gmg-local

Local UDP client for **Green Mountain Grills** Wi-Fi pellet grills.

No cloud account, no vendor API, no internet. It talks to the grill on your LAN
and nothing else.

> **Not on PyPI yet** - built and tested, awaiting a decision to publish.
> Origin and attribution: [NOTICE.md](NOTICE.md).

```python
from gmg_local import discover

for grill in discover():
    state = grill.status()
    print(grill.serial_number, state["temp"], "F")
```

## Why this exists

Every public Python implementation of this protocol froze on **2023-01-28**. The
most-installed Home Assistant integration for these grills reads grill
temperature from a **single byte**, so a 350 °F grill reports as **94 °F** - the
one number the device exists to tell you.

```python
u16(94, 1) == 350   # not 94
```

## Install

```bash
pip install -e .          # from a clone; not on PyPI yet
```

Requires Python 3.11+. No dependencies.

## API

| | |
|---|---|
| `discover(timeout=1, ip_bind_address="0.0.0.0", target=None)` | Broadcast for grills. Pass `target` to unicast across VLANs, where broadcast dies at the L2 edge. |
| `Grill.status(retries=5)` | One poll, decoded. Retries on silence **and** on junk. |
| `Grill.set_temp(f)` / `set_temp_probe(f, n)` | Targets, in Fahrenheit. |
| `Grill.power_on()` / `power_on_cool()` / `power_off()` | Cold smoke is `power_on_cool`. |
| `parse_status(frame)` | Pure. No sockets, no clock - testable without a grill. |
| `poll_interval_for(state)` | 10s while cooking, 60s while off. |

## What the status frame contains

A healthy grill answers `UR001!` with **52 bytes**; this library decodes through
byte 33.

| offset | field | notes |
|---|---|---|
| 2, 4, 6 | grill temp, probe 1, setpoint | 16-bit LE pairs |
| 8 | API version | |
| 16, 18 | probe 2 temp and setpoint | |
| 24-27 | warn state | 32-bit LE |
| 28 | probe 1 setpoint | |
| 30 | power state | 0 Off, 1 On, 2 Fan, 3 Cold Smoke |
| 32 | fire state | 0 Default, 1 Off, 2 Startup, 3 Running, 4 Cool Down, 5 Fail, 198 Cold Smoke |
| 33 | **fire state progress** | see below |
| 34-51 | **undecoded** | `gmg` 0.0.4 labels 48 and 50 as pellet alarms - unverified |

### Byte 33

Emitted as `fireStatePercentage`. GMG's own cloud API names this field
**`fireStateProgress`**, paired with `fireState`.

It is a four-step progress marker through *whatever fire state the grill is
currently in*. Across a full instrumented cycle it ran `25 → 50 → 75 → 100`
during Startup, hitting 100 at the exact second the grill switched to Running,
then `75 → 50 → 0` through Cool Down, hitting 0 as the fan stopped.

Two readings are ruled out by that data:

- **Not a startup-stage index.** A stage counter does not run backwards.
- **Not a pellet-hopper level.** A hopper does not fill during ignition and
  empty when the fan stops. One public implementation labels it `hopper_pct`
  and ships it as a pellet gauge; it reads 100% on a hot grill and 0% on a cold
  one.

**What each 25% step physically means is unknown**, and is not in the packet.
Display the step number; do not invent labels.

## Things the hardware does that will surprise you

- **Probe writes are silently discarded while the grill is off.** Write 203, read
  back 0. No error, no acknowledgement.
- **The grill serves one client at a time.** Concurrent conversations lose
  messages, so all I/O here is serialised behind a lock.
- **Short datagrams happen.** They are a transport artifact, not data. Parsing
  one either raises or invents fields; this library retries instead.
- **Temperature keeps rising after the fire is cut** - roughly 18 °F over a
  minute in one observed shutdown.

## Tests

```bash
pip install -e ".[dev]" && pytest
```

The fixtures are **reconstructed** - real decoded observations re-encoded at the
documented offsets - not captured frames. They prove the parser is self-consistent
with observed behaviour, not that the offsets are correct. Offsets are
corroborated across four independent implementations.

Preserving a genuine 52-byte capture is the prerequisite for decoding bytes
34-51, and should replace these fixtures when one exists.

## Credits

See [NOTICE.md](NOTICE.md). Original integration by **jwhitby91**; protocol
cross-checked against **brandenc40/green-mountain-grill** and **Christopher
McKay's** `gmg`.
