max / supernote-push
10 files changed,
+509 insertions,
-0 deletions
| @@ -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. |
| @@ -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" |
| @@ -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. |
| @@ -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. |
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -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>; |