honch-micropython 0.3.0__py3-none-any.whl

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.
honch/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ from .client import Honch
2
+ from .errors import (
3
+ CompressionUnavailableError,
4
+ HonchError,
5
+ InvalidArgumentError,
6
+ NotInitializedError,
7
+ OfflineError,
8
+ RateLimitedError,
9
+ RejectedError,
10
+ ServerError,
11
+ StorageError,
12
+ TransportError,
13
+ )
14
+
15
+ __version__ = "0.3.0"
16
+
17
+ __all__ = (
18
+ "Honch",
19
+ "HonchError",
20
+ "InvalidArgumentError",
21
+ "StorageError",
22
+ "TransportError",
23
+ "OfflineError",
24
+ "RateLimitedError",
25
+ "ServerError",
26
+ "RejectedError",
27
+ "CompressionUnavailableError",
28
+ "NotInitializedError",
29
+ )
honch/client.py ADDED
@@ -0,0 +1,346 @@
1
+ try:
2
+ import _honch_core
3
+ except ImportError:
4
+ _honch_core = None
5
+
6
+ from .config import HonchConfig
7
+ from .errors import (
8
+ HonchError,
9
+ InvalidArgumentError,
10
+ NotInitializedError,
11
+ OfflineError,
12
+ RateLimitedError,
13
+ RejectedError,
14
+ ServerError,
15
+ StorageError,
16
+ TransportError,
17
+ )
18
+ from .validation import (
19
+ require_distinct_id,
20
+ require_event_name,
21
+ require_properties,
22
+ require_text,
23
+ require_value,
24
+ )
25
+
26
+ # Cap how much of the Python stack we format before handing it to the C core.
27
+ # The core's $crash `backtrace` is an ALL-OR-NOTHING field: honch_append_crash_string
28
+ # emits it only when its byte length is within HONCH_FAULT_BACKTRACE_MAX_BYTES and
29
+ # DROPS the whole property otherwise (it does not truncate). So we format
30
+ # CRASH-SITE-FIRST and keep only the leading frames that fit the byte budget,
31
+ # dropping the OUTERMOST frames — the crash site survives, not the entry point.
32
+ _CRASH_BACKTRACE_MAX_FRAMES = 10
33
+ # MUST match HONCH_FAULT_BACKTRACE_MAX_BYTES in core/src/honch_core.c. If the core
34
+ # bound changes, change this too (the core silently drops an over-long backtrace).
35
+ _CRASH_BACKTRACE_MAX_BYTES = 192
36
+ _CRASH_BACKTRACE_SEP = " <- "
37
+
38
+
39
+ def _format_crash_backtrace(exc):
40
+ """Format an exception's traceback compact and crash-site-first, e.g.
41
+ `sensor.py:42 in read <- app.py:10 in loop <- main.py:88 in <module>`.
42
+
43
+ On MicroPython the readable backtrace is the Python traceback (already
44
+ file/line/function-resolved) — there is no coredump to symbolicate — so this
45
+ is what gives a MicroPython `$crash` the "where", not just the "what".
46
+
47
+ Returns the formatted string, or None if it can't be produced. MUST NOT raise:
48
+ it runs inside the excepthook, where a failure must never displace delivery of
49
+ the original exception."""
50
+ try:
51
+ import sys
52
+ import io
53
+
54
+ buf = io.StringIO()
55
+ sys.print_exception(exc, buf) # MicroPython: prints most-recent-call-LAST
56
+ frames = []
57
+ for raw in buf.getvalue().split("\n"):
58
+ line = raw.strip()
59
+ # "File "main.py", line 42, in read"
60
+ if not line.startswith("File ") or ", line " not in line:
61
+ continue
62
+ try:
63
+ fname = line.split('"', 2)[1]
64
+ rest = line.split(", line ", 1)[1]
65
+ lineno = rest.split(",", 1)[0].strip()
66
+ func = rest.split(" in ", 1)[1].strip() if " in " in rest else "?"
67
+ frames.append("%s:%s in %s" % (fname, lineno, func))
68
+ except Exception:
69
+ continue
70
+ if not frames:
71
+ return None
72
+ frames.reverse() # crash site first
73
+ # Keep crash-site-first frames whose join stays within the core's byte
74
+ # budget; drop the outermost frames that don't fit (the core would
75
+ # otherwise drop the entire property). Measure UTF-8 bytes — that's what
76
+ # the core counts against HONCH_FAULT_BACKTRACE_MAX_BYTES.
77
+ kept = ""
78
+ for frame in frames[:_CRASH_BACKTRACE_MAX_FRAMES]:
79
+ candidate = frame if not kept else kept + _CRASH_BACKTRACE_SEP + frame
80
+ if len(candidate.encode("utf-8")) > _CRASH_BACKTRACE_MAX_BYTES:
81
+ break
82
+ kept = candidate
83
+ return kept or None
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ class Honch:
89
+ def __init__(self, **kwargs):
90
+ if _honch_core is None:
91
+ raise ImportError("Honch MicroPython requires firmware built with the _honch_core user C module")
92
+
93
+ if (
94
+ kwargs.get("platform") is not None
95
+ or kwargs.get("transport") is not None
96
+ or kwargs.get("battery_callback") is not None
97
+ or kwargs.get("auto_properties_callback") is not None
98
+ ):
99
+ raise InvalidArgumentError("Python adapter hooks are not supported by the C-core MicroPython port")
100
+
101
+ self.config = HonchConfig(**kwargs)
102
+ self._core = _honch_core.Client(_config_to_dict(self.config))
103
+ self._connectivity_connected = None
104
+ self._honch_excepthook = None
105
+ self._previous_excepthook = None
106
+
107
+ def get_device_id(self):
108
+ return self._call("get_device_id")
109
+
110
+ def queue_stats(self):
111
+ return self._call("queue_stats")
112
+
113
+ def track(self, event_name, properties=None):
114
+ require_event_name(event_name)
115
+ self._call("track", event_name, require_properties(properties))
116
+
117
+ def report_log_error(self, message, *, component=None):
118
+ """Capture a logged error as a bounded, coalesced $error event.
119
+
120
+ Intended to be driven automatically (e.g. from a logging.Handler), not
121
+ sprinkled through application code.
122
+ """
123
+ require_text(message, "error message")
124
+ self._call("report_log_error", component, message)
125
+
126
+ def install_error_hook(self):
127
+ try:
128
+ import sys
129
+ except ImportError:
130
+ return False
131
+ # Installing twice would stack our wrapper on top of itself and report
132
+ # every exception once per layer, so a repeat call is a no-op.
133
+ if self._honch_excepthook is not None:
134
+ return True
135
+ previous_hook = getattr(sys, "excepthook", None)
136
+ if previous_hook is None:
137
+ return False
138
+
139
+ def honch_excepthook(exc_type, exc, tb):
140
+ try:
141
+ self._report_crash(exc_type, exc)
142
+ except Exception:
143
+ # An excepthook must never raise: a reporting failure must not
144
+ # replace delivery of the original exception to the next hook.
145
+ pass
146
+ finally:
147
+ previous_hook(exc_type, exc, tb)
148
+
149
+ self._previous_excepthook = previous_hook
150
+ self._honch_excepthook = honch_excepthook
151
+ sys.excepthook = honch_excepthook
152
+ return True
153
+
154
+ def _report_crash(self, exc_type, exc):
155
+ """Build and queue a $crash from an exception, including the Python
156
+ traceback. Shared by install_error_hook() and run()."""
157
+ report = {
158
+ "message": str(exc) or repr(exc),
159
+ "type": getattr(exc_type, "__name__", "Exception"),
160
+ }
161
+ backtrace = _format_crash_backtrace(exc)
162
+ if backtrace:
163
+ report["backtrace"] = backtrace
164
+ self._call("report_crash", report)
165
+
166
+ def run(self, fn, *args, **kwargs):
167
+ """Run your entry point with automatic crash capture, then re-raise.
168
+
169
+ Any uncaught exception from `fn` is reported as a $crash (with the Python
170
+ traceback), flushed so a fatal crash is delivered before the program exits,
171
+ and then re-raised so behavior is unchanged. Returns `fn`'s result on
172
+ success.
173
+
174
+ Unlike install_error_hook(), this works on EVERY MicroPython build: it uses
175
+ a plain try/except, not sys.excepthook (which stock firmware often omits).
176
+ It captures exceptions that propagate out of `fn` — wrap your top-level
177
+ entry point / main loop with it.
178
+ """
179
+ try:
180
+ return fn(*args, **kwargs)
181
+ except Exception as exc:
182
+ try:
183
+ self._report_crash(type(exc), exc)
184
+ self.flush()
185
+ except Exception:
186
+ # Reporting/flush failure must never mask the original exception.
187
+ pass
188
+ raise
189
+
190
+ def uninstall_error_hook(self):
191
+ try:
192
+ import sys
193
+ except ImportError:
194
+ return False
195
+ if self._honch_excepthook is None:
196
+ return False
197
+ # Only restore if our hook is still the active one; if something was
198
+ # installed on top of us we cannot safely splice ourselves out.
199
+ if getattr(sys, "excepthook", None) is self._honch_excepthook:
200
+ sys.excepthook = self._previous_excepthook
201
+ self._honch_excepthook = None
202
+ self._previous_excepthook = None
203
+ return True
204
+
205
+ def identify(self, distinct_id, traits=None):
206
+ require_distinct_id(distinct_id)
207
+ self._call("identify", distinct_id, require_properties(traits))
208
+
209
+ def set_property(self, key, value=None):
210
+ require_text(key, "property key")
211
+ self._call("set_property", key, require_value(value))
212
+
213
+ def session_start(self, session_name=None):
214
+ if session_name is not None:
215
+ session_name = str(session_name)
216
+ self._call("session_start", session_name)
217
+
218
+ def session_end(self):
219
+ self._call("session_end")
220
+
221
+ def connectivity_changed(self, connected):
222
+ if connected is not True and connected is not False:
223
+ raise InvalidArgumentError("connected must be a boolean")
224
+ if self._connectivity_connected == connected:
225
+ return
226
+ self._call("connectivity_changed", connected)
227
+ self._connectivity_connected = connected
228
+
229
+ def connected(self):
230
+ self.connectivity_changed(True)
231
+
232
+ def disconnected(self):
233
+ self.connectivity_changed(False)
234
+
235
+ def flush(self):
236
+ # The C core is never given the Python connectivity *poll* callback (only
237
+ # connectivity_changed events are forwarded), so it cannot gate on it
238
+ # itself. Uphold the cross-port flush contract here: flushing while the
239
+ # callback reports offline raises OfflineError before core is consulted.
240
+ if not self._connectivity_available():
241
+ raise OfflineError("offline")
242
+ self._call("flush")
243
+
244
+ def tick(self):
245
+ if not self._connectivity_available():
246
+ return
247
+ self._call("tick")
248
+
249
+ def reset(self):
250
+ self._call("reset")
251
+ self._connectivity_connected = None
252
+
253
+ def shutdown(self):
254
+ self._call("shutdown")
255
+
256
+ def _call(self, name, *args):
257
+ try:
258
+ return getattr(self._core, name)(*args)
259
+ except Exception as exc:
260
+ _raise_mapped(exc)
261
+
262
+ def _connectivity_available(self):
263
+ callback = self.config.connectivity_callback
264
+ if callback is None:
265
+ return True
266
+ return bool(callback())
267
+
268
+
269
+ def _config_to_dict(config):
270
+ return {
271
+ "api_key": config.api_key,
272
+ "endpoint_url": config.endpoint_url,
273
+ "device_id": config.device_id,
274
+ "device_model": config.device_model,
275
+ "firmware_version": config.firmware_version,
276
+ "environment": config.environment,
277
+ "event_buffer": config.event_buffer,
278
+ "batch_size": config.batch_size,
279
+ "max_queued_events": config.max_queued_events,
280
+ "max_event_bytes": config.max_event_bytes,
281
+ "transport_timeout_ms": config.transport_timeout_ms,
282
+ "flush_interval_seconds": config.flush_interval_seconds,
283
+ "flush_min_interval_ms": config.flush_min_interval_ms,
284
+ "flush_event_threshold": config.flush_event_threshold,
285
+ "flush_retry_initial_ms": config.flush_retry_initial_ms,
286
+ "flush_retry_max_ms": config.flush_retry_max_ms,
287
+ "battery_low_threshold": config.battery_low_threshold,
288
+ }
289
+
290
+
291
+ def _raise_mapped(exc):
292
+ status = getattr(exc, "status", None)
293
+ if _honch_core is None:
294
+ raise exc
295
+
296
+ if status is None:
297
+ message = str(exc)
298
+ status = _STATUS_BY_MESSAGE.get(message)
299
+ if status is None:
300
+ # Unknown/unmapped status string (wording drift, or a status with no
301
+ # table entry): wrap as HonchError so callers can always catch it,
302
+ # rather than leaking a bare RuntimeError from the C module.
303
+ raise HonchError(str(exc))
304
+
305
+ if status == getattr(_honch_core, "ERROR_INVALID_ARGUMENT", None):
306
+ raise InvalidArgumentError(str(exc))
307
+ if status == getattr(_honch_core, "ERROR_IO", None):
308
+ raise StorageError(str(exc))
309
+ if status == getattr(_honch_core, "ERROR_RATE_LIMITED", None):
310
+ raise RateLimitedError(str(exc))
311
+ if status == getattr(_honch_core, "ERROR_OFFLINE", None):
312
+ raise OfflineError(str(exc))
313
+ if status == getattr(_honch_core, "ERROR_SERVER", None):
314
+ raise ServerError(str(exc))
315
+ if status == getattr(_honch_core, "ERROR_REJECTED", None):
316
+ raise RejectedError(str(exc))
317
+ if status == getattr(_honch_core, "ERROR_NOT_INITIALIZED", None):
318
+ raise NotInitializedError(str(exc))
319
+ if status == getattr(_honch_core, "ERROR_TRANSPORT", None) or status == getattr(_honch_core, "ERROR_TIMEOUT", None):
320
+ raise TransportError(str(exc))
321
+ if status == getattr(_honch_core, "ERROR_QUEUE_FULL", None):
322
+ raise StorageError(str(exc))
323
+ if status == getattr(_honch_core, "ERROR_BUSY", None):
324
+ raise HonchError(str(exc))
325
+ if status == getattr(_honch_core, "ERROR_NOT_SUPPORTED", None):
326
+ raise HonchError(str(exc))
327
+ raise HonchError(str(exc))
328
+
329
+
330
+ _STATUS_BY_MESSAGE = {
331
+ "invalid argument": getattr(_honch_core, "ERROR_INVALID_ARGUMENT", None),
332
+ "io error": getattr(_honch_core, "ERROR_IO", None),
333
+ "transport error": getattr(_honch_core, "ERROR_TRANSPORT", None),
334
+ "rate limited": getattr(_honch_core, "ERROR_RATE_LIMITED", None),
335
+ "server error": getattr(_honch_core, "ERROR_SERVER", None),
336
+ "rejected": getattr(_honch_core, "ERROR_REJECTED", None),
337
+ "not initialized": getattr(_honch_core, "ERROR_NOT_INITIALIZED", None),
338
+ "queue full": getattr(_honch_core, "ERROR_QUEUE_FULL", None),
339
+ "timeout": getattr(_honch_core, "ERROR_TIMEOUT", None),
340
+ "busy": getattr(_honch_core, "ERROR_BUSY", None),
341
+ "not supported": getattr(_honch_core, "ERROR_NOT_SUPPORTED", None),
342
+ "offline": getattr(_honch_core, "ERROR_OFFLINE", None),
343
+ "out of memory": getattr(_honch_core, "ERROR_OUT_OF_MEMORY", None),
344
+ "already initialized": getattr(_honch_core, "ERROR_ALREADY_INITIALIZED", None),
345
+ "internal error": getattr(_honch_core, "ERROR_INTERNAL", None),
346
+ } if _honch_core is not None else {}
honch/config.py ADDED
@@ -0,0 +1,74 @@
1
+ from .errors import InvalidArgumentError
2
+
3
+
4
+ SDK_VERSION = "0.3.0"
5
+ SDK_PLATFORM = "micropython"
6
+ DEFAULT_ENDPOINT_URL = "https://i.honch.io"
7
+
8
+ DEFAULT_BATCH_SIZE = 20
9
+ MAX_BATCH_SIZE = 50
10
+ DEFAULT_MAX_QUEUED_EVENTS = 1000
11
+ DEFAULT_MAX_EVENT_BYTES = 8192
12
+ DEFAULT_TRANSPORT_TIMEOUT_MS = 8000
13
+ MAX_TRANSPORT_TIMEOUT_MS = 10000
14
+ DEFAULT_FLUSH_INTERVAL_SECONDS = 120
15
+ DEFAULT_FLUSH_MIN_INTERVAL_MS = 15000
16
+ FLUSH_MIN_INTERVAL_DISABLED_MS = 0xFFFFFFFF
17
+ DEFAULT_FLUSH_EVENT_THRESHOLD = 20
18
+ DEFAULT_FLUSH_RETRY_INITIAL_MS = 1000
19
+ DEFAULT_FLUSH_RETRY_MAX_MS = 300000
20
+ DEFAULT_BATTERY_LOW_THRESHOLD = 15
21
+
22
+
23
+ def is_blank(value):
24
+ return value is None or str(value).strip() == ""
25
+
26
+
27
+ class HonchConfig:
28
+ def __init__(self, **kwargs):
29
+ required = ("api_key", "device_id", "device_model", "firmware_version", "event_buffer")
30
+ for key in required:
31
+ if is_blank(kwargs.get(key)):
32
+ raise InvalidArgumentError("missing required config: " + key)
33
+
34
+ self.api_key = str(kwargs["api_key"])
35
+ endpoint_url = kwargs.get("endpoint_url")
36
+ self.endpoint_url = DEFAULT_ENDPOINT_URL if is_blank(endpoint_url) else str(endpoint_url)
37
+ self.device_id = kwargs.get("device_id")
38
+ self.device_model = str(kwargs["device_model"])
39
+ self.firmware_version = str(kwargs["firmware_version"])
40
+ self.environment = str(kwargs.get("environment") or "production")
41
+ self.event_buffer = kwargs["event_buffer"]
42
+ self.batch_size = int(kwargs.get("batch_size") or DEFAULT_BATCH_SIZE)
43
+ if self.batch_size > MAX_BATCH_SIZE:
44
+ self.batch_size = MAX_BATCH_SIZE
45
+ if self.batch_size <= 0:
46
+ self.batch_size = DEFAULT_BATCH_SIZE
47
+
48
+ max_queued_events = kwargs.get("max_queued_events")
49
+ self.max_queued_events = DEFAULT_MAX_QUEUED_EVENTS if max_queued_events is None else int(max_queued_events)
50
+ if self.max_queued_events <= 0:
51
+ raise InvalidArgumentError("max_queued_events must be positive")
52
+
53
+ max_event_bytes = kwargs.get("max_event_bytes")
54
+ self.max_event_bytes = DEFAULT_MAX_EVENT_BYTES if max_event_bytes is None else int(max_event_bytes)
55
+ if self.max_event_bytes <= 0:
56
+ raise InvalidArgumentError("max_event_bytes must be positive")
57
+
58
+ timeout = kwargs.get("transport_timeout_ms")
59
+ self.transport_timeout_ms = DEFAULT_TRANSPORT_TIMEOUT_MS if timeout is None else int(timeout)
60
+ if self.transport_timeout_ms <= 0:
61
+ raise InvalidArgumentError("transport_timeout_ms must be positive")
62
+ if self.transport_timeout_ms > MAX_TRANSPORT_TIMEOUT_MS:
63
+ self.transport_timeout_ms = MAX_TRANSPORT_TIMEOUT_MS
64
+ self.flush_interval_seconds = int(kwargs.get("flush_interval_seconds") or DEFAULT_FLUSH_INTERVAL_SECONDS)
65
+ self.flush_min_interval_ms = int(kwargs.get("flush_min_interval_ms") or DEFAULT_FLUSH_MIN_INTERVAL_MS)
66
+ self.flush_event_threshold = int(kwargs.get("flush_event_threshold") or DEFAULT_FLUSH_EVENT_THRESHOLD)
67
+ self.flush_retry_initial_ms = int(kwargs.get("flush_retry_initial_ms") or DEFAULT_FLUSH_RETRY_INITIAL_MS)
68
+ self.flush_retry_max_ms = int(kwargs.get("flush_retry_max_ms") or DEFAULT_FLUSH_RETRY_MAX_MS)
69
+ if self.flush_retry_max_ms < self.flush_retry_initial_ms:
70
+ self.flush_retry_max_ms = self.flush_retry_initial_ms
71
+ self.battery_callback = kwargs.get("battery_callback")
72
+ self.battery_low_threshold = int(kwargs.get("battery_low_threshold") or DEFAULT_BATTERY_LOW_THRESHOLD)
73
+ self.auto_properties_callback = kwargs.get("auto_properties_callback")
74
+ self.connectivity_callback = kwargs.get("connectivity_callback")
honch/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ class HonchError(Exception):
2
+ pass
3
+
4
+
5
+ class InvalidArgumentError(HonchError):
6
+ pass
7
+
8
+
9
+ class StorageError(HonchError):
10
+ pass
11
+
12
+
13
+ class TransportError(HonchError):
14
+ pass
15
+
16
+
17
+ class OfflineError(TransportError):
18
+ pass
19
+
20
+
21
+ class RateLimitedError(TransportError):
22
+ pass
23
+
24
+
25
+ class ServerError(TransportError):
26
+ pass
27
+
28
+
29
+ class RejectedError(HonchError):
30
+ pass
31
+
32
+
33
+ class CompressionUnavailableError(HonchError):
34
+ pass
35
+
36
+
37
+ class NotInitializedError(HonchError):
38
+ pass
honch/validation.py ADDED
@@ -0,0 +1,51 @@
1
+ from .errors import InvalidArgumentError
2
+
3
+
4
+ MAX_EVENT_NAME = 128
5
+ MAX_DISTINCT_ID = 256
6
+
7
+
8
+ def require_text(value, label, max_len=None):
9
+ # Single source of truth for the layer's "non-blank string, optional max
10
+ # length" rule. Core is still the final gate (and counts UTF-8 bytes, not
11
+ # code points), so these checks exist for early, well-attributed errors.
12
+ if not isinstance(value, str) or value.strip() == "" or (max_len is not None and len(value) > max_len):
13
+ raise InvalidArgumentError("invalid " + label)
14
+
15
+
16
+ def require_event_name(event):
17
+ require_text(event, "event name", MAX_EVENT_NAME)
18
+
19
+
20
+ def require_distinct_id(distinct_id):
21
+ require_text(distinct_id, "distinct_id", MAX_DISTINCT_ID)
22
+
23
+
24
+ def require_properties(properties):
25
+ if properties is None:
26
+ return {}
27
+ if not isinstance(properties, dict):
28
+ raise InvalidArgumentError("properties must be a dict")
29
+ _validate_typed_value(properties)
30
+ return dict(properties)
31
+
32
+
33
+ def require_value(value):
34
+ _validate_typed_value(value)
35
+ return value
36
+
37
+
38
+ def _validate_typed_value(value):
39
+ if value is None or isinstance(value, (bool, int, float, str, bytes)):
40
+ return
41
+ if isinstance(value, (list, tuple)):
42
+ for item in value:
43
+ _validate_typed_value(item)
44
+ return
45
+ if isinstance(value, dict):
46
+ for key, item in value.items():
47
+ if not isinstance(key, str):
48
+ raise InvalidArgumentError("property keys must be strings")
49
+ _validate_typed_value(item)
50
+ return
51
+ raise InvalidArgumentError("unsupported property value")
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: honch-micropython
3
+ Version: 0.3.0
4
+ Summary: Honch analytics SDK for MicroPython
5
+ Author-email: Honch <support@honch.io>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/honch-io/SDK
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Honch MicroPython SDK
12
+
13
+ Stable MicroPython wrapper for the canonical Honch C core. The Python package keeps a small `honch.Honch` API while behavior comes from the same shared sources used by the ESP-IDF and POSIX ports.
14
+
15
+ ## Status
16
+
17
+ Stable `0.3.0`. The SDK is not standalone pure Python; firmware must be built with the `_honch_core` user C module.
18
+
19
+ This port targets MicroPython. CircuitPython is not covered by the current user C module build flow.
20
+
21
+ ## Build Into MicroPython
22
+
23
+ From a MicroPython checkout, build a port with the Honch user module:
24
+
25
+ ```bash
26
+ make -C ports/unix \
27
+ USER_C_MODULES=/path/to/SDK/ports/micropython/usermod/honch/micropython.cmake
28
+ ```
29
+
30
+ For board firmware, pass the same user module path and freeze the wrapper:
31
+
32
+ ```bash
33
+ make BOARD=MYBOARD \
34
+ USER_C_MODULES=/path/to/SDK/ports/micropython/usermod/honch/micropython.cmake \
35
+ FROZEN_MANIFEST=/path/to/SDK/ports/micropython/manifest.py
36
+ ```
37
+
38
+ The Honch user C module does not set firmware-global MicroPython options such
39
+ as `MICROPY_C_HEAP_SIZE`. Keep heap sizing in the board or host firmware
40
+ configuration. If your board needs additional C heap for Honch and other native
41
+ modules, opt into that value in the board config rather than in the user module.
42
+
43
+ `mip` can install wrapper files from package metadata, but those files require firmware that already contains `_honch_core`.
44
+ Do not install the `honch/` wrapper into `/lib` when it is already frozen into
45
+ the firmware. Keeping both copies wastes the board filesystem and can make
46
+ MicroPython import an older `/lib/honch` package instead of the frozen SDK.
47
+
48
+ ## Basic Usage
49
+
50
+ ```python
51
+ import honch
52
+
53
+ client = honch.Honch(
54
+ api_key="project-key",
55
+ endpoint_url="https://i.honch.io",
56
+ device_id="device-serial-001",
57
+ device_model="ActionCam X1",
58
+ firmware_version="1.2.3",
59
+ event_buffer=bytearray(8192),
60
+ )
61
+
62
+ client.identify("user-123", {"plan": "beta"})
63
+ client.session_start("recording")
64
+ client.track("recording_started", {"mode": "hdr"})
65
+ client.session_end()
66
+ client.flush()
67
+ client.shutdown()
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ Required:
73
+
74
+ - `api_key`
75
+ - `device_id`
76
+ - `device_model`
77
+ - `firmware_version`
78
+ - `event_buffer`
79
+
80
+ Optional:
81
+ - `endpoint_url` (default `https://i.honch.io`)
82
+ - `environment`
83
+ - `batch_size`
84
+ - `max_queued_events`
85
+ - `max_event_bytes`
86
+ - `transport_timeout_ms` (finite positive milliseconds, clamped to 10000)
87
+ - `flush_interval_seconds`
88
+ - `flush_min_interval_ms` (default `15000`; use `0xFFFFFFFF` to disable for benchmarks)
89
+ - `flush_event_threshold`
90
+ - `flush_retry_initial_ms`
91
+ - `flush_retry_max_ms`
92
+ - `battery_low_threshold`
93
+ - `connectivity_callback` (return false while offline; ticks are skipped and `flush()` raises `OfflineError`)
94
+
95
+ Python `platform=`, `transport=`, `battery_callback=`, and `auto_properties_callback=` hooks are not supported by the C-core-derived port. Board behavior belongs in the user module adapters.
96
+
97
+ client init does synchronous work on the caller's thread. It validates config,
98
+ initializes the C core against the caller-provided event buffer, derives initial
99
+ state from the supplied device/config values, and queues `$device_boot` before
100
+ returning. It does not perform network I/O; delivery remains cooperative through
101
+ `tick()` or explicit `flush()` calls.
102
+
103
+ Crash reporting is automatic. `client.install_error_hook()` installs a
104
+ best-effort `sys.excepthook` wrapper (on runtimes that expose that hook) that
105
+ emits a one-time `$crash` event for an uncaught exception (`source="exception"`,
106
+ with the exception type in `exception_cause`); the original exception is always
107
+ delivered to the previous hook. `client.report_log_error(message,
108
+ component=...)` emits a bounded, coalesced `$error` event and is intended to be
109
+ driven automatically (e.g. from a `logging.Handler`), not sprinkled through
110
+ application code. The port does not install panic hooks or board-specific
111
+ reset-reason adapters; board reset telemetry belongs in the user module adapter.
112
+
113
+ client.tick() and client.flush() may block for up to the configured transport
114
+ timeout because urequests.post holds the MicroPython interpreter while the HTTP
115
+ request is in progress. Do not call tick() from a latency-sensitive control
116
+ loop, timing-critical sensor loop, UI refresh path, or watchdog-sensitive
117
+ section. Do not call tick() or flush() from ISR-adjacent callbacks or
118
+ high-priority tasks. Schedule it from a low-priority part of the program where a
119
+ stalled interpreter is acceptable for the configured timeout. Explicit timeout
120
+ values above the hard maximum of 10000 ms are clamped.
121
+ Each scheduled tick posts at most one wire chunk, so large queued uploads may
122
+ need several pump iterations to finish.
123
+
124
+ Do not call `tick()` while WLAN is disconnected or the radio is intentionally
125
+ off. If your loop cannot guarantee that, pass `connectivity_callback`; it should
126
+ be fast and read host-owned connectivity state.
127
+
128
+ ## Public API
129
+
130
+ ```python
131
+ client.track(event_name, properties=None)
132
+ client.report_log_error(message, component=None)
133
+ client.install_error_hook()
134
+ client.uninstall_error_hook()
135
+ client.identify(distinct_id, traits=None)
136
+ client.set_property(key, value=None)
137
+ client.session_start(session_name=None)
138
+ client.session_end()
139
+ client.connectivity_changed(connected)
140
+ client.connected()
141
+ client.disconnected()
142
+ client.tick()
143
+ client.flush()
144
+ client.reset()
145
+ client.shutdown()
146
+ client.get_device_id()
147
+ client.queue_stats()
148
+ ```
149
+
150
+ After `identify()`, future events use the new distinct ID. The `$identify`
151
+ event also includes the previous identity as `$anon_distinct_id`, usually the
152
+ device ID, so earlier anonymous events can merge into the identified person.
153
+
154
+ Call `client.tick()` periodically from the device main loop for scheduled flush work. Use `client.flush()` when you want an immediate drain attempt.
155
+
156
+ Properties support typed event values, including strings, integers, floats,
157
+ booleans, lists, dictionaries, null, and `bytes`. Capture may reject bytes
158
+ unless the project enables binary properties. SDK-owned auto property keys
159
+ supplied by users are rejected before compact wire-v2 packetization.
160
+
161
+ ## Storage And Transport
162
+
163
+ The default user C module stores queued events in the caller-provided
164
+ `event_buffer`. Events, `identify()` state, and firmware-version state are
165
+ volatile by default and are lost across reset or power loss. `device_id` is required
166
+ because this port does not persist SDK identity. Pass a caller-provided stable
167
+ device_id from board provisioning, NVS, a filesystem file, or another
168
+ product-owned durable source.
169
+
170
+ That RAM queue is bounded and compact. Consuming a non-tail event uses an O(n)
171
+ memmove per consumed event to keep the caller-provided buffer contiguous; this
172
+ is acceptable at default sizes, but larger buffers and queue limits should be
173
+ measured on the target board.
174
+
175
+ Flush sends compact chunk frames to `POST <endpoint_url>/capture` with `Content-Type: application/vnd.honch.chunk`, `X-Honch-Project-Key`, and `X-Honch-Stream-Id`.
176
+
177
+ ## Security
178
+
179
+ Use HTTPS in production. Verify the board has network, time, and trust setup needed for certificate validation. Keep project keys out of source control, logs, and event properties.
180
+
181
+ ## Tests
182
+
183
+ ```bash
184
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=ports/micropython python3 -m unittest discover \
185
+ -s ports/micropython/tests -t . -v
186
+ ```
187
+
188
+ Full runtime validation requires building MicroPython with `_honch_core` and running the wrapper on that interpreter or firmware.
@@ -0,0 +1,9 @@
1
+ honch/__init__.py,sha256=XBof-C15jmlkF9kWuTDOyHipOql8KpXMCfXokaSkDEk,549
2
+ honch/client.py,sha256=UfgvuLWeq20f3agj9uWuarMWRHce3GnFLIk8rtzcgrE,13752
3
+ honch/config.py,sha256=wqXCZVJZ0sNIwSdSQb3mFmCkFtfmj5BGGdIAQ3zAHhk,3685
4
+ honch/errors.py,sha256=bH8QYhdys5tnsxtiqxlCHQxHBob-xS3Qa2e3hqMR3Bg,473
5
+ honch/validation.py,sha256=Qd1Exu1uxvW81_5p3yJH3sbId6ekXepoInYHyhqBC5w,1604
6
+ honch_micropython-0.3.0.dist-info/METADATA,sha256=mGJZoM7GgCQISq7cFaj6ovZ3hbJ0DaeAjd7HPCKMpck,7648
7
+ honch_micropython-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ honch_micropython-0.3.0.dist-info/top_level.txt,sha256=9OAH0ocYw6Xseoknyp2rWuUnT2D18WBWsrUgH9nIqCc,6
9
+ honch_micropython-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ honch