Skip to content

Commit c64c3fd

Browse files
Bucknallaclaude
andcommitted
Improve upload chunking, add receive server and example README
- Fix upload.py to match Go implementation: remove status from card.binary.put, only set total for multi-chunk uploads, handle {io} errors in verification retry loop - Add receive_binary.py server for receiving uploaded files via Notehub - Add end-to-end README covering both sending and receiving - Include blues_logo.png test image - Reorganize binary-mode examples into loopback/ and upload/ subdirs - Use 64KB max_chunk_size in example for cellular reliability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9bc52a7 commit c64c3fd

7 files changed

Lines changed: 315 additions & 17 deletions

File tree

File renamed without changes.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Binary Upload Example
2+
3+
Upload a binary file from a Notecard to a remote server via Notehub, using the note-python SDK's chunked upload mechanism.
4+
5+
This example includes two scripts:
6+
7+
- **`binary_upload_example.py`** — Runs on the host (e.g. Raspberry Pi) connected to a Notecard. Reads `blues_logo.png`, chunks it through the Notecard's binary buffer, and sends it to Notehub via `web.post`.
8+
- **`receive_binary.py`** — A minimal HTTP server that receives the binary data routed from Notehub and saves it to disk.
9+
10+
## Prerequisites
11+
12+
- A [Blues Notecard](https://blues.com/products/notecard/) connected via USB serial
13+
- A [Notehub](https://notehub.io) account and project
14+
- Python 3.7+
15+
- `pyserial` and `note-python` installed (`pip install pyserial note-python`)
16+
- [ngrok](https://ngrok.com/) (or another tunnel) to expose the receive server publicly
17+
18+
## Setup
19+
20+
### 1. Start the receive server
21+
22+
On the machine where you want to receive files:
23+
24+
```bash
25+
python3 receive_binary.py
26+
```
27+
28+
This starts an HTTP server on port 8080 (pass a different port as an argument if needed). Files are saved to the current directory.
29+
30+
### 2. Expose the server with ngrok
31+
32+
In a separate terminal:
33+
34+
```bash
35+
ngrok http 8080
36+
```
37+
38+
Copy the HTTPS forwarding URL (e.g. `https://abc123.ngrok.io`).
39+
40+
### 3. Create a Notehub proxy route
41+
42+
In [Notehub](https://notehub.io), go to your project's **Routes** and create a new **General HTTP/HTTPS** route:
43+
44+
- **Route alias**: `upload`
45+
- **URL**: your ngrok HTTPS URL
46+
47+
### 4. Configure and run the upload script
48+
49+
Edit `binary_upload_example.py` and set:
50+
51+
- **`PRODUCT_UID`** — your Notehub product UID (e.g. `com.your-company:your-project`)
52+
- **`ROUTE_ALIAS`** — the route alias from step 3 (default: `upload`)
53+
- **Serial port** — update the `serial.Serial(...)` path to match your Notecard's port
54+
55+
Then run:
56+
57+
```bash
58+
python3 binary_upload_example.py
59+
```
60+
61+
The script will:
62+
63+
1. Connect the Notecard to Notehub and wait for a connection
64+
2. Read `blues_logo.png` (~222 KB)
65+
3. Upload it in 64 KB chunks, printing progress after each chunk
66+
4. Print a summary with total bytes, duration, and throughput
67+
68+
### 5. Check the output
69+
70+
The receive server prints a line for each file received:
71+
72+
```
73+
Listening on port 8080. Saving files to: /your/current/directory
74+
Press Ctrl+C to stop.
75+
76+
[14:23:01] Received 222,511 bytes -> blues_logo.png
77+
```
78+
79+
## Chunk size tuning
80+
81+
The `MAX_CHUNK_SIZE` constant in `binary_upload_example.py` controls how large each chunk is. The Notecard's binary buffer can hold ~250 KB, but large single-chunk uploads over cellular can time out. The default of 64 KB is a good balance between throughput and reliability. Lower it to 32 KB if you experience timeouts on slow connections.
82+
83+
## File naming
84+
85+
Files are named using the Notecard's `label` field (sent as the `X-Notecard-Label` HTTP header by Notehub). If no label is present, the server generates a timestamped filename with an extension inferred from the file's magic bytes (`.png`, `.jpg`, `.pdf`, `.bin`, etc.).

examples/binary-mode/binary_upload_example.py renamed to examples/binary-mode/upload/binary_upload_example.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515
import os
1616
import sys
17+
import time
1718

1819
sys.path.insert(0, os.path.abspath(
1920
os.path.join(os.path.dirname(__file__), '..', '..')))
@@ -25,8 +26,12 @@
2526
from notecard.upload import upload # noqa: E402
2627

2728

28-
PRODUCT_UID = 'com.your-company.your-project'
29-
ROUTE_ALIAS = 'my-upload-route'
29+
PRODUCT_UID = 'com.blues.abucknall:uploaddemo'
30+
ROUTE_ALIAS = 'upload'
31+
# Keep chunks small enough to reliably transfer over cellular.
32+
# The Notecard buffer can hold ~250KB, but pushing that much data
33+
# in a single web.post over cellular often times out.
34+
MAX_CHUNK_SIZE = 65536 # 64 KB
3035

3136

3237
def on_progress(info):
@@ -39,21 +44,39 @@ def on_progress(info):
3944

4045
def run_example():
4146
"""Connect to Notecard and upload binary data to Notehub."""
42-
port = serial.Serial('/dev/ttyACM0', 9600)
47+
port = serial.Serial('/dev/cu.usbmodemNOTE1', 115200)
4348
card = notecard.OpenSerial(port, debug=True)
4449

4550
# Connect the Notecard to Notehub.
4651
hub.set(card, product=PRODUCT_UID, mode='continuous')
4752

48-
# Generate some test data (1 KB of a repeating pattern).
49-
data = bytearray(range(256)) * 4
50-
print(f'Uploading {len(data)} bytes to route "{ROUTE_ALIAS}"...')
53+
# Wait for the Notecard to connect to Notehub.
54+
print('Waiting for Notehub connection...')
55+
while True:
56+
rsp = hub.status(card)
57+
connected = rsp.get('connected', False)
58+
status_msg = rsp.get('status', '')
59+
if connected:
60+
print('Connected to Notehub.')
61+
break
62+
print(f' Not yet connected: {status_msg}')
63+
time.sleep(2)
64+
65+
# Read the image file to upload.
66+
image_path = os.path.join(os.path.dirname(__file__), 'blues_logo.png')
67+
with open(image_path, 'rb') as f:
68+
data = f.read()
69+
70+
print(f'Uploading {image_path} ({len(data)} bytes) '
71+
f'to route "{ROUTE_ALIAS}"...')
5172

5273
result = upload(
5374
card,
5475
data,
5576
route=ROUTE_ALIAS,
56-
label='test_data.bin',
77+
label='blues_logo.png',
78+
content_type='image/png',
79+
max_chunk_size=MAX_CHUNK_SIZE,
5780
progress_cb=on_progress,
5881
)
5982

217 KB
Loading
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
"""
3+
receive_binary.py
4+
5+
A minimal HTTP server that receives binary files routed from Notehub
6+
via a General HTTP/HTTPS route and saves them to the current directory.
7+
8+
Usage:
9+
python3 receive_binary.py [port]
10+
11+
Default port: 8080
12+
13+
Setup:
14+
1. Run this script (optionally with ngrok to expose it publicly):
15+
python3 receive_binary.py
16+
ngrok http 8080
17+
18+
2. In Notehub, create a General HTTP/HTTPS route pointing to this
19+
server's URL (or your ngrok URL).
20+
21+
3. Send a binary file from your Notecard:
22+
{"req": "web.post", "route": "<your-route>", "binary": true, ...}
23+
24+
Files are saved to the current directory with a name derived from the
25+
Notecard's "label" field, or falling back to a timestamped filename
26+
with an extension inferred from the file's magic bytes.
27+
"""
28+
29+
import os
30+
import sys
31+
import time
32+
from http.server import BaseHTTPRequestHandler, HTTPServer
33+
34+
# Magic bytes used to infer file extensions
35+
MAGIC_SIGNATURES = [
36+
(b"\x89PNG", "png"),
37+
(b"\xff\xd8\xff", "jpg"),
38+
(b"%PDF", "pdf"),
39+
(b"GIF8", "gif"),
40+
(b"PK\x03\x04", "zip"),
41+
(b"\x1f\x8b", "gz"),
42+
]
43+
44+
DEFAULT_PORT = 8080
45+
46+
47+
def decode_chunked(data: bytes) -> bytes:
48+
"""Decode HTTP chunked transfer encoding."""
49+
output = b""
50+
pos = 0
51+
while pos < len(data):
52+
end = data.find(b"\r\n", pos)
53+
if end == -1:
54+
break
55+
size_str = data[pos:end].decode(errors="ignore").strip()
56+
if not size_str:
57+
break
58+
try:
59+
size = int(size_str, 16)
60+
except ValueError:
61+
break
62+
if size == 0:
63+
break
64+
pos = end + 2
65+
output += data[pos : pos + size]
66+
pos += size + 2
67+
return output
68+
69+
70+
def infer_extension(data: bytes) -> str:
71+
"""Infer file extension from magic bytes."""
72+
for magic, ext in MAGIC_SIGNATURES:
73+
if data[: len(magic)] == magic:
74+
return ext
75+
return "bin"
76+
77+
78+
def make_filename(label: str, data: bytes) -> str:
79+
"""
80+
Use the Notecard's label if provided, otherwise generate a timestamped
81+
filename with an inferred extension.
82+
"""
83+
if label:
84+
return label
85+
ext = infer_extension(data)
86+
timestamp = time.strftime("%Y%m%d_%H%M%S")
87+
return f"received_{timestamp}.{ext}"
88+
89+
90+
class BinaryReceiveHandler(BaseHTTPRequestHandler):
91+
def do_POST(self):
92+
# Read body, handling both Content-Length and chunked encoding
93+
content_length = self.headers.get("Content-Length")
94+
transfer_encoding = self.headers.get("Transfer-Encoding", "")
95+
96+
if content_length:
97+
raw = self.rfile.read(int(content_length))
98+
else:
99+
raw = self.rfile.read()
100+
101+
if "chunked" in transfer_encoding.lower():
102+
body = decode_chunked(raw)
103+
else:
104+
body = raw
105+
106+
if not body:
107+
self._respond(400, "Empty body")
108+
return
109+
110+
# Notehub sets X-Notecard-Label to the note's label field
111+
label = self.headers.get("X-Notecard-Label", "").strip()
112+
filename = make_filename(label, body)
113+
114+
filepath = os.path.join(os.getcwd(), filename)
115+
with open(filepath, "wb") as f:
116+
f.write(body)
117+
118+
print(f"[{time.strftime('%H:%M:%S')}] Received {len(body):,} bytes -> {filename}")
119+
self._respond(200, "OK")
120+
121+
def _respond(self, code: int, message: str):
122+
self.send_response(code)
123+
self.send_header("Content-Type", "text/plain")
124+
self.end_headers()
125+
self.wfile.write(message.encode())
126+
127+
def log_message(self, format, *args):
128+
# Suppress default request logging — we handle it ourselves
129+
pass
130+
131+
132+
def main():
133+
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
134+
server = HTTPServer(("", port), BinaryReceiveHandler)
135+
print(f"Listening on port {port}. Saving files to: {os.getcwd()}")
136+
print("Press Ctrl+C to stop.\n")
137+
try:
138+
server.serve_forever()
139+
except KeyboardInterrupt:
140+
print("\nStopped.")
141+
142+
143+
if __name__ == "__main__":
144+
main()

notecard/upload.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def _stage_binary_chunk(card, chunk_data):
2929
"""Stage a binary chunk into the Notecard's binary buffer.
3030
3131
Performs card.binary.put + raw byte transmit + verification, with
32-
retries on failure.
32+
retries on failure. Mirrors the Go implementation's inner binary
33+
transfer retry loop.
3334
3435
Args:
3536
card (Notecard): The Notecard object.
@@ -39,13 +40,12 @@ def _stage_binary_chunk(card, chunk_data):
3940
Exception: If staging fails after all retries.
4041
"""
4142
encoded = cobs_encode(bytearray(chunk_data), ord('\n'))
42-
md5 = _md5_hash(chunk_data)
4343
req = {
4444
'req': 'card.binary.put',
4545
'cobs': len(encoded),
46-
'status': md5,
4746
}
4847
encoded.append(ord('\n'))
48+
expected_len = len(chunk_data)
4949

5050
tries_left = BINARY_STAGE_RETRIES
5151
while tries_left > 0:
@@ -63,15 +63,24 @@ def _stage_binary_chunk(card, chunk_data):
6363
finally:
6464
card.unlock()
6565

66-
rsp = card.Transaction({'req': 'card.binary'})
67-
if 'err' in rsp:
66+
try:
67+
rsp = card.Transaction({'req': 'card.binary'})
68+
except Exception:
6869
tries_left -= 1
6970
if tries_left == 0:
70-
raise Exception(
71-
f'Failed to stage binary data: {rsp["err"]}')
71+
raise
7272
continue
7373

74-
expected_len = len(chunk_data)
74+
if 'err' in rsp:
75+
err_msg = rsp['err']
76+
if '{bad-bin}' in err_msg or '{io}' in err_msg:
77+
tries_left -= 1
78+
if tries_left == 0:
79+
raise Exception(
80+
f'Failed to stage binary data: {err_msg}')
81+
continue
82+
raise Exception(f'Failed to stage binary data: {err_msg}')
83+
7584
actual_len = rsp.get('length', 0)
7685
if actual_len != expected_len:
7786
tries_left -= 1
@@ -156,13 +165,17 @@ def upload(card, data, route, target=None, label=None,
156165
'binary': True,
157166
'content': content_type,
158167
'offset': offset,
159-
'total': total_len,
160168
'status': chunk_md5,
161169
}
162170
if target:
163171
web_req['name'] = target
164172
if label:
165173
web_req['label'] = label
174+
# Only set total for multi-chunk (segmented) uploads, matching
175+
# the Go implementation. This tells Notehub to expect multiple
176+
# segments and reassemble them.
177+
if total_chunks > 1:
178+
web_req['total'] = total_len
166179

167180
web_tries = WEB_POST_RETRIES
168181
while web_tries > 0:

0 commit comments

Comments
 (0)