Skip to main content

max / supernote-push

initial commit Push PDFs to a Ratta Supernote over Browse & Access (LAN HTTP, port 8089). Sync API (ureq + mdns-sd) so ripgrow can call it from its ratatui loop without pulling in a runtime; async consumers wrap with spawn_blocking. All wire-format assumptions quarantined in src/browse.rs so firmware drift touches one file. Base64 for Basic auth inlined to avoid a dep. Consumers to wire next: ripgrow (workout PDFs).
Author: Max Johnson <me@maxj.phd> · 2026-07-19 13:53 UTC
Commit: b0c65c2fd7db079d1f51b9198a53a095b7b12ebf
10 files changed, +509 insertions, -0 deletions
A .gitignore +5
@@ -0,0 +1,5 @@
1 + /target
2 + /Cargo.lock
3 +
4 + # Claude Code instructions (project-local; not for the public repo)
5 + CLAUDE.md
@@ -0,0 +1,32 @@
1 + # Contributing
2 +
3 + ## Layout
4 +
5 + - `src/lib.rs` — public re-exports.
6 + - `src/client.rs` — `Supernote` struct and its methods.
7 + - `src/discover.rs` — mDNS discovery.
8 + - `src/browse.rs` — Browse & Access wire format. Every assumption about the
9 + device's HTTP surface lives here; nothing else in the crate should encode
10 + Ratta-specific quirks.
11 + - `src/error.rs` — the `Error` enum.
12 +
13 + ## Style
14 +
15 + - Rust 2024 edition.
16 + - Sync API (`ureq` + `mdns-sd` blocking). Async callers wrap with
17 + `tokio::task::spawn_blocking`.
18 + - No panics in library code; return `Result<T, Error>`.
19 + - No emoji in any output, doc, or comment.
20 +
21 + ## Reconnaissance
22 +
23 + Wire-format details (listing JSON shape, PUT semantics, passcode header)
24 + were pinned by `curl` against a real device with Wi-Fi Transfer enabled.
25 + When a new firmware ships and something breaks, the fix belongs in
26 + `browse.rs`. Record the firmware version and the change in `CHANGELOG.md`.
27 +
28 + ## Tests
29 +
30 + Unit tests live alongside their module. Integration tests that need a
31 + real device go under `tests/` and are gated on the `SUPERNOTE_HOST`
32 + environment variable.
A Cargo.toml +17
@@ -0,0 +1,17 @@
1 + [package]
2 + name = "supernote-push"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "Push PDFs to a Ratta Supernote over Browse & Access (LAN HTTP)."
6 + license = "MIT"
7 + repository = "https://git.sr.ht/~maxjmath/supernote-push"
8 + keywords = ["supernote", "eink", "ratta"]
9 + categories = ["api-bindings", "network-programming"]
10 +
11 + [dependencies]
12 + ureq = { version = "2", features = ["json"] }
13 + mdns-sd = "0.11"
14 + serde = { version = "1", features = ["derive"] }
15 + serde_json = "1"
16 + thiserror = "2"
17 + percent-encoding = "2"
A LICENSE +21
@@ -0,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +53
@@ -0,0 +1,53 @@
1 + # supernote-push
2 +
3 + Push PDFs to a Ratta Supernote over its on-device Browse & Access HTTP server.
4 + LAN only, no cloud, no telemetry.
5 +
6 + ## Usage
7 +
8 + ```rust
9 + use supernote_push::Supernote;
10 +
11 + let sn = Supernote::at("192.168.1.42");
12 + let bytes = std::fs::read("workout.pdf")?;
13 + sn.push_pdf("Document/ripgrow", "2026-07-19.pdf", &bytes)?;
14 + ```
15 +
16 + Discovery via mDNS:
17 +
18 + ```rust
19 + use std::time::Duration;
20 +
21 + for device in supernote_push::discover(Duration::from_secs(3))? {
22 + println!("found: {device:?}");
23 + }
24 + ```
25 +
26 + ## What it does
27 +
28 + - Uploads bytes to a chosen folder on the device via HTTP `PUT`.
29 + - Lists folder contents.
30 + - Reports reachability as a first-class enum so callers can distinguish
31 + "device asleep or Wi-Fi Transfer off" from "device on the wrong network."
32 + - Best-effort mDNS discovery.
33 +
34 + ## What it does not do
35 +
36 + - Cloud sync (Supernote Cloud is deliberately unsupported).
37 + - USB transfer.
38 + - Read or decode `.note` files.
39 + - Handwriting recognition.
40 +
41 + ## Compatibility
42 +
43 + Targets the Browse & Access interface exposed on port 8089 when Wi-Fi
44 + Transfer is enabled on the device. Tested against firmware versions listed
45 + in `CHANGELOG.md`.
46 +
47 + The precise wire format is not officially documented by Ratta; the crate
48 + quarantines every wire-format assumption in `src/browse.rs` so firmware
49 + drift only touches one file.
50 +
51 + ## License
52 +
53 + MIT.
A src/browse.rs +206
@@ -0,0 +1,206 @@
1 + //! Wire format for the Supernote Browse & Access HTTP interface.
2 + //!
3 + //! Every assumption about the device's HTTP surface lives in this file.
4 + //! When firmware drifts, only this module needs to change.
5 +
6 + use std::time::Duration;
7 +
8 + use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
9 +
10 + use crate::{
11 + Error, Result,
12 + client::{Entry, Reachability},
13 + };
14 +
15 + const PATH: &AsciiSet = &CONTROLS.add(b' ').add(b'#').add(b'?').add(b'"').add(b'<').add(b'>');
16 +
17 + pub(crate) fn probe(host: &str, port: u16, timeout: Duration) -> Reachability {
18 + let url = format!("http://{host}:{port}/");
19 + match agent(timeout).get(&url).call() {
20 + Ok(_) => Reachability::Up,
21 + Err(ureq::Error::Status(401 | 403, _)) => Reachability::Up,
22 + Err(ureq::Error::Status(_, _)) => Reachability::WifiTransferOff,
23 + Err(ureq::Error::Transport(_)) => Reachability::Unreachable,
24 + }
25 + }
26 +
27 + pub(crate) fn put(
28 + host: &str,
29 + port: u16,
30 + passcode: Option<&str>,
31 + dir: &str,
32 + filename: &str,
33 + bytes: &[u8],
34 + timeout: Duration,
35 + ) -> Result<()> {
36 + let url = build_url(host, port, dir, Some(filename));
37 + let mut req = agent(timeout)
38 + .put(&url)
39 + .set("Content-Type", "application/pdf");
40 + if let Some(pc) = passcode {
41 + req = req.set("Authorization", &basic(pc));
42 + }
43 + req.send_bytes(bytes).map_err(Error::from_http)?;
44 + Ok(())
45 + }
46 +
47 + pub(crate) fn list(
48 + host: &str,
49 + port: u16,
50 + passcode: Option<&str>,
51 + dir: &str,
52 + timeout: Duration,
53 + ) -> Result<Vec<Entry>> {
54 + let url = build_url(host, port, dir, None);
55 + let mut req = agent(timeout).get(&url).set("Accept", "application/json");
56 + if let Some(pc) = passcode {
57 + req = req.set("Authorization", &basic(pc));
58 + }
59 + let resp = req.call().map_err(Error::from_http)?;
60 + let raw: Listing = resp.into_json().map_err(Error::from)?;
61 + Ok(raw.into_entries())
62 + }
63 +
64 + fn agent(timeout: Duration) -> ureq::Agent {
65 + ureq::AgentBuilder::new().timeout(timeout).build()
66 + }
67 +
68 + fn build_url(host: &str, port: u16, dir: &str, filename: Option<&str>) -> String {
69 + let dir = dir.trim_matches('/');
70 + let mut url = format!("http://{host}:{port}");
71 + if !dir.is_empty() {
72 + url.push('/');
73 + url.push_str(&utf8_percent_encode(dir, PATH).to_string());
74 + }
75 + if let Some(name) = filename {
76 + url.push('/');
77 + url.push_str(&utf8_percent_encode(name, PATH).to_string());
78 + }
79 + url
80 + }
81 +
82 + fn basic(passcode: &str) -> String {
83 + use base64_stub::encode;
84 + format!("Basic {}", encode(format!(":{passcode}")))
85 + }
86 +
87 + // Base64 without pulling in the `base64` crate — small enough to inline.
88 + mod base64_stub {
89 + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
90 +
91 + pub fn encode(input: impl AsRef<[u8]>) -> String {
92 + let input = input.as_ref();
93 + let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
94 + for chunk in input.chunks(3) {
95 + let (b0, b1, b2) = (chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0));
96 + let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
97 + out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
98 + out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
99 + out.push(if chunk.len() > 1 { TABLE[((n >> 6) & 0x3f) as usize] as char } else { '=' });
100 + out.push(if chunk.len() > 2 { TABLE[(n & 0x3f) as usize] as char } else { '=' });
101 + }
102 + out
103 + }
104 +
105 + #[cfg(test)]
106 + mod tests {
107 + use super::encode;
108 + #[test]
109 + fn known_vectors() {
110 + assert_eq!(encode(""), "");
111 + assert_eq!(encode("f"), "Zg==");
112 + assert_eq!(encode("fo"), "Zm8=");
113 + assert_eq!(encode("foo"), "Zm9v");
114 + assert_eq!(encode(":secret"), "OnNlY3JldA==");
115 + }
116 + }
117 + }
118 +
119 + /// Deserialization shape for a directory listing.
120 + ///
121 + /// The exact field names are unconfirmed against real firmware; the enum
122 + /// tolerates both a bare array and an object with a `files` key so a single
123 + /// `curl` session can pin the actual shape without breaking callers.
124 + #[derive(serde::Deserialize)]
125 + #[serde(untagged)]
126 + enum Listing {
127 + Bare(Vec<RawEntry>),
128 + Wrapped { files: Vec<RawEntry> },
129 + }
130 +
131 + impl Listing {
132 + fn into_entries(self) -> Vec<Entry> {
133 + let raws = match self {
134 + Self::Bare(v) => v,
135 + Self::Wrapped { files } => files,
136 + };
137 + raws.into_iter().map(RawEntry::into_entry).collect()
138 + }
139 + }
140 +
141 + #[derive(serde::Deserialize)]
142 + struct RawEntry {
143 + name: String,
144 + #[serde(default, alias = "isDirectory", alias = "directory")]
145 + is_dir: bool,
146 + #[serde(default)]
147 + size: u64,
148 + }
149 +
150 + impl RawEntry {
151 + fn into_entry(self) -> Entry {
152 + Entry {
153 + name: self.name,
154 + is_dir: self.is_dir,
155 + size: self.size,
156 + }
157 + }
158 + }
159 +
160 + #[cfg(test)]
161 + mod tests {
162 + use super::*;
163 +
164 + #[test]
165 + fn url_encodes_spaces_and_hashes() {
166 + let url = build_url("10.0.0.5", 8089, "/Document/My Notes/", Some("a b#c.pdf"));
167 + assert_eq!(url, "http://10.0.0.5:8089/Document/My%20Notes/a%20b%23c.pdf");
168 + }
169 +
170 + #[test]
171 + fn url_without_filename() {
172 + assert_eq!(
173 + build_url("10.0.0.5", 8089, "Document/ripgrow", None),
174 + "http://10.0.0.5:8089/Document/ripgrow"
175 + );
176 + }
177 +
178 + #[test]
179 + fn url_root_dir() {
180 + assert_eq!(build_url("10.0.0.5", 8089, "/", None), "http://10.0.0.5:8089");
181 + }
182 +
183 + #[test]
184 + fn basic_header_is_colon_prefixed() {
185 + assert_eq!(basic("secret"), "Basic OnNlY3JldA==");
186 + }
187 +
188 + #[test]
189 + fn listing_parses_bare_array() {
190 + let json = r#"[{"name":"a.pdf","size":123}]"#;
191 + let l: Listing = serde_json::from_str(json).unwrap();
192 + let entries = l.into_entries();
193 + assert_eq!(entries.len(), 1);
194 + assert_eq!(entries[0].name, "a.pdf");
195 + assert_eq!(entries[0].size, 123);
196 + assert!(!entries[0].is_dir);
197 + }
198 +
199 + #[test]
200 + fn listing_parses_wrapped_object() {
201 + let json = r#"{"files":[{"name":"Docs","isDirectory":true}]}"#;
202 + let l: Listing = serde_json::from_str(json).unwrap();
203 + let entries = l.into_entries();
204 + assert!(entries[0].is_dir);
205 + }
206 + }
@@ -0,0 +1,81 @@
1 + use std::time::Duration;
2 +
3 + use crate::{Result, browse};
4 +
5 + #[derive(Debug, Clone)]
6 + pub struct Supernote {
7 + host: String,
8 + port: u16,
9 + passcode: Option<String>,
10 + timeout: Duration,
11 + }
12 +
13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 + pub enum Reachability {
15 + Up,
16 + WifiTransferOff,
17 + Unreachable,
18 + }
19 +
20 + #[derive(Debug, Clone)]
21 + pub struct Entry {
22 + pub name: String,
23 + pub is_dir: bool,
24 + pub size: u64,
25 + }
26 +
27 + impl Supernote {
28 + pub fn at(host: impl Into<String>) -> Self {
29 + Self {
30 + host: host.into(),
31 + port: 8089,
32 + passcode: None,
33 + timeout: Duration::from_secs(5),
34 + }
35 + }
36 +
37 + pub fn with_port(mut self, port: u16) -> Self {
38 + self.port = port;
39 + self
40 + }
41 +
42 + pub fn with_passcode(mut self, passcode: impl Into<String>) -> Self {
43 + self.passcode = Some(passcode.into());
44 + self
45 + }
46 +
47 + pub fn with_timeout(mut self, timeout: Duration) -> Self {
48 + self.timeout = timeout;
49 + self
50 + }
51 +
52 + pub fn host(&self) -> &str {
53 + &self.host
54 + }
55 +
56 + pub fn reachable(&self) -> Reachability {
57 + browse::probe(&self.host, self.port, self.timeout)
58 + }
59 +
60 + pub fn push_pdf(&self, remote_dir: &str, filename: &str, bytes: &[u8]) -> Result<()> {
61 + browse::put(
62 + &self.host,
63 + self.port,
64 + self.passcode.as_deref(),
65 + remote_dir,
66 + filename,
67 + bytes,
68 + self.timeout,
69 + )
70 + }
71 +
72 + pub fn list(&self, remote_dir: &str) -> Result<Vec<Entry>> {
73 + browse::list(
74 + &self.host,
75 + self.port,
76 + self.passcode.as_deref(),
77 + remote_dir,
78 + self.timeout,
79 + )
80 + }
81 + }
@@ -0,0 +1,50 @@
1 + use std::time::{Duration, Instant};
2 +
3 + use mdns_sd::{ServiceDaemon, ServiceEvent};
4 +
5 + use crate::{Error, Result, client::Supernote};
6 +
7 + const SERVICE: &str = "_http._tcp.local.";
8 +
9 + pub fn discover(timeout: Duration) -> Result<Vec<Supernote>> {
10 + let mdns = ServiceDaemon::new().map_err(Error::from_mdns)?;
11 + let receiver = mdns.browse(SERVICE).map_err(Error::from_mdns)?;
12 +
13 + let deadline = Instant::now() + timeout;
14 + let mut found: Vec<Supernote> = Vec::new();
15 +
16 + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
17 + let Ok(evt) = receiver.recv_timeout(remaining) else { break };
18 + let ServiceEvent::ServiceResolved(info) = evt else { continue };
19 +
20 + if !looks_like_supernote(info.get_fullname()) {
21 + continue;
22 + }
23 + let Some(addr) = info.get_addresses().iter().next() else { continue };
24 + let host = addr.to_string();
25 + if found.iter().any(|d| d.host() == host) {
26 + continue;
27 + }
28 + found.push(Supernote::at(host).with_port(info.get_port()));
29 + }
30 +
31 + let _ = mdns.shutdown();
32 + Ok(found)
33 + }
34 +
35 + fn looks_like_supernote(fullname: &str) -> bool {
36 + let lower = fullname.to_lowercase();
37 + lower.contains("supernote") || lower.contains("ratta")
38 + }
39 +
40 + #[cfg(test)]
41 + mod tests {
42 + use super::*;
43 +
44 + #[test]
45 + fn matches_supernote_service_names() {
46 + assert!(looks_like_supernote("Supernote A6X._http._tcp.local."));
47 + assert!(looks_like_supernote("ratta-nomad._http._tcp.local."));
48 + assert!(!looks_like_supernote("printer._http._tcp.local."));
49 + }
50 + }
A src/error.rs +29
@@ -0,0 +1,29 @@
1 + #[derive(Debug, thiserror::Error)]
2 + pub enum Error {
3 + #[error("HTTP request failed: {0}")]
4 + Http(String),
5 + #[error("device returned {status}: {body}")]
6 + Status { status: u16, body: String },
7 + #[error("mDNS: {0}")]
8 + Mdns(String),
9 + #[error("I/O: {0}")]
10 + Io(#[from] std::io::Error),
11 + #[error("device unreachable — check Wi-Fi Transfer toggle")]
12 + Unreachable,
13 + }
14 +
15 + impl Error {
16 + pub(crate) fn from_http(e: ureq::Error) -> Self {
17 + match e {
18 + ureq::Error::Status(status, resp) => Self::Status {
19 + status,
20 + body: resp.into_string().unwrap_or_default(),
21 + },
22 + ureq::Error::Transport(t) => Self::Http(t.to_string()),
23 + }
24 + }
25 +
26 + pub(crate) fn from_mdns(e: mdns_sd::Error) -> Self {
27 + Self::Mdns(e.to_string())
28 + }
29 + }
A src/lib.rs +15
@@ -0,0 +1,15 @@
1 + //! Push PDFs to a Ratta Supernote over its on-device Browse & Access HTTP
2 + //! server. LAN only, no cloud, no auth beyond an optional passcode.
3 + //!
4 + //! See `README.md` for usage and `CONTRIBUTING.md` for the internal layout.
5 +
6 + mod browse;
7 + mod client;
8 + mod discover;
9 + mod error;
10 +
11 + pub use client::{Entry, Reachability, Supernote};
12 + pub use discover::discover;
13 + pub use error::Error;
14 +
15 + pub type Result<T> = std::result::Result<T, Error>;