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 +29 -0
- honch/client.py +346 -0
- honch/config.py +74 -0
- honch/errors.py +38 -0
- honch/validation.py +51 -0
- honch_micropython-0.3.0.dist-info/METADATA +188 -0
- honch_micropython-0.3.0.dist-info/RECORD +9 -0
- honch_micropython-0.3.0.dist-info/WHEEL +5 -0
- honch_micropython-0.3.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
honch
|