diff --git a/CMakeLists.txt b/CMakeLists.txt index 08c2d6d..f3393d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -242,6 +242,8 @@ add_executable(udpscope src/source/udps_source.cpp src/source/udps_kind.cpp src/source/probe_source.cpp + src/source/e1608/e1608_link.cpp + src/source/e1608/e1608_device.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/source/e1608/e1608_device.cpp b/src/source/e1608/e1608_device.cpp new file mode 100644 index 0000000..ba30be5 --- /dev/null +++ b/src/source/e1608/e1608_device.cpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_device.hpp" + +#include +#include +#include +#include + +namespace udpscope::e1608 { + +namespace { + +constexpr double kClockHz = 80e6; + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); } + +float le_f32(const std::uint8_t* p) { + const std::uint32_t u = static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); + float f; + std::memcpy(&f, &u, sizeof f); + return f; +} + +void put_le32(std::vector& v, std::uint32_t x) { + for (int i = 0; i < 4; ++i) v.push_back(static_cast((x >> (8 * i)) & 0xFF)); +} + +/// Reads `count` (slope, intercept) pairs at `addr`. +void read_table(Link& link, std::uint16_t addr, int count, Cal* out) { + const std::uint16_t bytes = static_cast(count * 8); + const std::vector req = {static_cast(addr & 0xFF), + static_cast(addr >> 8), + static_cast(bytes & 0xFF), + static_cast(bytes >> 8)}; + const std::vector r = link.command(0x40, req, bytes); + for (int i = 0; i < count; ++i) { + out[i].slope = le_f32(&r[static_cast(i) * 8]); + out[i].intercept = le_f32(&r[static_cast(i) * 8 + 4]); + } +} + +} // namespace + +double full_scale(Range r) { + static const double fs[] = {10.0, 5.0, 2.0, 1.0}; + return fs[static_cast(r)]; +} + +const char* range_name(Range r) { + static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"}; + return n[static_cast(r)]; +} + +bool parse_range(const std::string& text, Range& out) { + const std::string t = lower(text); + for (int i = 0; i < 4; ++i) { + if (t == lower(range_name(static_cast(i)))) { + out = static_cast(i); + return true; + } + } + return false; +} + +const char* trigger_name(Trigger t) { + switch (t) { + case Trigger::None: return "none"; + case Trigger::Rising: return "rising"; + case Trigger::Falling: return "falling"; + case Trigger::High: return "high"; + case Trigger::Low: return "low"; + } + return "none"; +} + +bool parse_trigger(const std::string& text, Trigger& out) { + const std::string t = lower(text); + for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) { + if (t == trigger_name(c)) { + out = c; + return true; + } + } + return false; +} + +double Calibration::volts(std::uint16_t raw, Range range, bool diff) const { + const Cal& c = (diff ? df : se)[static_cast(range)]; + const double code = clamp_code(std::round(static_cast(raw) * c.slope + c.intercept)); + return (code - 32768.0) * full_scale(range) / 32768.0; +} + +std::uint16_t Calibration::dac_code(int ch, double v) const { + v = std::min(10.0, std::max(-10.0, v)); + const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0)); + const Cal& c = ao[ch & 1]; + return static_cast(clamp_code(std::round(nominal * c.slope + c.intercept))); +} + +void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector& queue, + const Calibration& cal, std::vector>& out) { + const std::size_t nq = queue.size(); + out.resize(nq); + for (std::size_t q = 0; q < nq; ++q) { + out[q].resize(scans); + for (std::size_t s = 0; s < scans; ++s) + out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff()); + } +} + +bool Device::read_calibration(Calibration& cal) { + Calibration c; + try { + read_table(link_, 0x00, 4, c.df); + read_table(link_, 0x20, 4, c.se); + read_table(link_, 0x40, 2, c.ao); + } catch (const LinkError& e) { + if (e.kind() != LinkError::Kind::Command) throw; + cal = Calibration{}; + return false; + } + c.loaded = true; + cal = c; + return true; +} + +std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; } + +void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); } + +void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); } + +double Device::aout(int ch, double volts, const Calibration& cal) { + const double v = std::min(10.0, std::max(-10.0, volts)); + const std::uint16_t code = cal.dac_code(ch, v); + link_.command(0x21, {static_cast(ch & 1), static_cast(code & 0xFF), + static_cast(code >> 8)}); + return v; +} + +std::uint32_t Device::counter() { + const std::vector r = link_.command(0x30, {}, 4); + return static_cast(r[0]) | (static_cast(r[1]) << 8) | + (static_cast(r[2]) << 16) | (static_cast(r[3]) << 24); +} + +void Device::counter_reset() { link_.command(0x31); } + +std::uint16_t Device::status() { + const std::vector r = link_.command(0x52, {}, 2); + return static_cast(r[0] | (r[1] << 8)); +} + +void Device::scan_start(const std::vector& queue, double aggregate_hz, bool ext_clock, + Trigger trigger) { + link_.open_scan(); + try { + std::vector q; + q.push_back(static_cast(queue.size())); + for (const QueueEntry& e : queue) { + q.push_back(e.wire_channel); + q.push_back(static_cast(e.range)); + } + link_.command(0x15, q); + + std::uint32_t pacer = 0; + if (!ext_clock) { + const double p = std::round(kClockHz / aggregate_hz - 1.0); + pacer = static_cast(std::min(4294967295.0, std::max(1.0, p))); + } + std::vector s; + put_le32(s, 0); // count 0: scan until stopped + put_le32(s, pacer); + s.push_back(static_cast(trigger)); + link_.command(0x11, s); + link_.send_filler(); + } catch (...) { + link_.close_scan(); + throw; + } +} + +void Device::scan_stop() { + link_.close_scan(); + try { + link_.command(0x13, {1}); + } catch (const LinkError&) { + } +} + +} // namespace udpscope::e1608 diff --git a/src/source/e1608/e1608_device.hpp b/src/source/e1608/e1608_device.hpp new file mode 100644 index 0000000..a0f91c6 --- /dev/null +++ b/src/source/e1608/e1608_device.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/e1608/e1608_link.hpp" + +#include +#include +#include +#include + +namespace udpscope::e1608 { + +enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 }; + +double full_scale(Range r); ///< 10, 5, 2, 1 +const char* range_name(Range r); ///< "BIP10V" ... +/// Case-insensitive: "BIP10V", "bip5v", ... +bool parse_range(const std::string& text, Range& out); + +enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 }; + +const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low" +bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive + +struct Cal { + double slope = 1.0; + double intercept = 0.0; +}; + +/// The factory calibration: one (slope, intercept) per range for single-ended and differential +/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values. +struct Calibration { + Cal se[4]; + Cal df[4]; + Cal ao[2]; + bool loaded = false; + + /// A raw ADC count as calibrated volts. + double volts(std::uint16_t raw, Range range, bool diff) const; + /// The DAC code that makes `ch` output `volts` (clamped to +-10 V). + std::uint16_t dac_code(int ch, double volts) const; +}; + +/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire. +struct QueueEntry { + std::uint8_t wire_channel; + Range range; + bool diff() const noexcept { return wire_channel >= 8; } +}; + +/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts: +/// `out[queue index][scan]`. +void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector& queue, + const Calibration& cal, std::vector>& out); + +/// Typed commands over a `Link`. Holds a reference: the link must outlive it. +class Device { + public: + explicit Device(Link& link) : link_(link) {} + + /// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the + /// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other + /// error propagates. + bool read_calibration(Calibration& cal); + + std::uint8_t din(); + void dout(std::uint8_t value); + /// `inputs_mask`: a set bit makes that line an input. + void dconf(std::uint8_t inputs_mask); + /// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts. + double aout(int ch, double volts, const Calibration& cal); + std::uint32_t counter(); + void counter_reset(); + /// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun. + std::uint16_t status(); + + /// Opens the scan socket, writes the queue, starts the scan and sends the filler byte. + /// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket + /// is closed again. + void scan_start(const std::vector& queue, double aggregate_hz, bool ext_clock, + Trigger trigger); + /// Closes the scan socket and tells the board to stop; board errors are ignored. + void scan_stop(); + + private: + Link& link_; +}; + +} // namespace udpscope::e1608 diff --git a/src/source/e1608/e1608_link.cpp b/src/source/e1608/e1608_link.cpp new file mode 100644 index 0000000..3055ff3 --- /dev/null +++ b/src/source/e1608/e1608_link.cpp @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_link.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +void Fd::reset(int fd) noexcept { + if (fd_ >= 0) ::close(fd_); + fd_ = fd; +} + +namespace { + +using Clock = std::chrono::steady_clock; +using Kind = LinkError::Kind; + +constexpr std::size_t kMaxFrame = 1024; + +[[noreturn]] void sys_fail(const std::string& what) { + throw LinkError(Kind::Io, what + ": " + std::strerror(errno)); +} + +int ms_left(Clock::time_point deadline) { + const auto d = std::chrono::duration_cast(deadline - Clock::now()); + return d.count() < 0 ? 0 : static_cast(d.count()); +} + +/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout. +bool wait_fd(int fd, short events, int timeout_ms) { + pollfd p{fd, events, 0}; + for (;;) { + const int r = ::poll(&p, 1, timeout_ms); + if (r > 0) return true; + if (r == 0) return false; + if (errno == EINTR) continue; + sys_fail("poll"); + } +} + +sockaddr_in make_addr(const std::string& host, int port) { + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_port = htons(static_cast(port)); + if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a; + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr) + throw LinkError(Kind::Io, "cannot resolve '" + host + "'"); + a.sin_addr = reinterpret_cast(res->ai_addr)->sin_addr; + ::freeaddrinfo(res); + return a; +} + +void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) { + while (n > 0) { + if (!wait_fd(fd, POLLOUT, ms_left(deadline))) + throw LinkError(Kind::Timeout, "timed out sending to the board"); + const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT); + if (w < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + sys_fail("send"); + } + p += w; + n -= static_cast(w); + } +} + +void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) { + while (n > 0) { + if (!wait_fd(fd, POLLIN, ms_left(deadline))) + throw LinkError(Kind::Timeout, "timed out waiting for the board"); + const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT); + if (r == 0) throw LinkError(Kind::Io, "the board closed the connection"); + if (r < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + sys_fail("recv"); + } + p += r; + n -= static_cast(r); + } +} + +/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first. +Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) { + const sockaddr_in a = make_addr(host, port); + Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)); + if (!fd.valid()) sys_fail("socket"); + const int one = 1; + ::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf); + const std::string where = host + ":" + std::to_string(port); + if (::connect(fd.get(), reinterpret_cast(&a), sizeof a) < 0 && + errno != EINPROGRESS) + sys_fail("cannot connect to " + where); + if (!wait_fd(fd.get(), POLLOUT, timeout_ms)) + throw LinkError(Kind::Timeout, "timed out connecting to " + where); + int err = 0; + socklen_t len = sizeof err; + ::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len); + if (err != 0) { + errno = err; + sys_fail("cannot connect to " + where); + } + return fd; +} + +} // namespace + +// --- addresses ------------------------------------------------------------------------------ + +bool parse_mac(const std::string& text, Mac& out) { + Mac m{}; + std::size_t pos = 0; + for (std::size_t i = 0; i < 6; ++i) { + if (pos + 2 > text.size()) return false; + unsigned v = 0; + for (std::size_t k = 0; k < 2; ++k) { + const char c = text[pos + k]; + v <<= 4; + if (c >= '0' && c <= '9') v |= static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return false; + } + m[i] = static_cast(v); + pos += 2; + if (i < 5) { + if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false; + ++pos; + } + } + if (pos != text.size()) return false; + out = m; + return true; +} + +std::string format_mac(const Mac& mac) { + char buf[24]; + std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], + mac[4], mac[5]); + return buf; +} + +std::vector sweep_targets(const std::string& subnet) { + std::string s = subnet; + while (!s.empty() && s.front() == ' ') s.erase(s.begin()); + while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back(); + if (s.empty()) return {"255.255.255.255"}; + int parts = 0; + std::size_t i = 0; + while (i <= s.size()) { + const std::size_t dot = s.find('.', i); + const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i); + if (part.empty() || part.size() > 3) return {}; + int v = 0; + for (char c : part) { + if (c < '0' || c > '9') return {}; + v = v * 10 + (c - '0'); + } + if (v > 255) return {}; + ++parts; + if (dot == std::string::npos) break; + i = dot + 1; + } + if (parts != 3) return {}; + std::vector out; + out.reserve(254); + for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h)); + return out; +} + +std::vector discover(const std::vector& targets, int port, int wait_ms) { + std::vector found; + Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!u.valid()) sys_fail("socket"); + const int one = 1; + ::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + const std::uint8_t probe = 'D'; + for (const std::string& t : targets) { + sockaddr_in a{}; + try { + a = make_addr(t, port); + } catch (const LinkError&) { + continue; + } + // A sweep may hit unreachable hosts; those sends fail and are simply skipped. + (void)::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&a), + sizeof a); + } + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + for (;;) { + if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break; + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + break; + } + if (n < 29 || buf[0] != 'D') continue; + DeviceInfo info; + info.product_id = static_cast(buf[7] | (buf[8] << 8)); + if (info.product_id != kProductId) continue; + std::copy(buf + 1, buf + 7, info.mac.begin()); + bool dup = false; + for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac; + if (dup) continue; + char ip[INET_ADDRSTRLEN] = {}; + ::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip); + info.ip = ip; + found.push_back(info); + } + return found; +} + +// --- Link ----------------------------------------------------------------------------------- + +void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) { + close(); + host_ = host; + port_ = port; + timeout_ms_ = timeout_ms; + next_id_ = 0; + + // The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces. + { + const sockaddr_in a = make_addr(host, port); + Fd u(::socket(AF_INET, SOCK_DGRAM, 0)); + if (!u.valid()) sys_fail("socket"); + if (::connect(u.get(), reinterpret_cast(&a), sizeof a) < 0) + sys_fail("handshake with " + host); + const std::uint8_t msg[5] = {'C', static_cast(code & 0xFF), + static_cast((code >> 8) & 0xFF), + static_cast((code >> 16) & 0xFF), + static_cast((code >> 24) & 0xFF)}; + if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host); + if (!wait_fd(u.get(), POLLIN, timeout_ms)) + throw LinkError(Kind::Timeout, "no answer from the board at " + host); + std::uint8_t rep[16]; + const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0); + if (n < 0) sys_fail("handshake with " + host); + if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply"); + switch (rep[1]) { + case 0: + case 2: + break; + case 1: + throw LinkError(Kind::Rejected, "the board rejected the connection code"); + case 3: + throw LinkError(Kind::Busy, "the board is busy (another client holds it)"); + default: + throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1])); + } + } + cmd_ = tcp_connect(host, port, timeout_ms, 0); +} + +void Link::close() { + scan_.reset(); + cmd_.reset(); +} + +std::vector Link::command(std::uint8_t cmd, const std::vector& payload, + int expect) { + if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected"); + if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long"); + // Bytes left by an earlier exchange that timed out must not be read as this reply. + { + std::uint8_t junk[256]; + while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) { + } + } + const std::uint8_t id = ++next_id_; + std::vector f; + f.reserve(7 + payload.size()); + f.push_back(0xDB); + f.push_back(cmd); + f.push_back(id); + f.push_back(0); + f.push_back(static_cast(payload.size() & 0xFF)); + f.push_back(static_cast((payload.size() >> 8) & 0xFF)); + f.insert(f.end(), payload.begin(), payload.end()); + unsigned sum = 0; + for (std::uint8_t b : f) sum += b; + f.push_back(static_cast(0xFF - (sum & 0xFF))); + + const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_); + send_all(cmd_.get(), f.data(), f.size(), deadline); + + std::uint8_t h[6]; + read_exact(cmd_.get(), h, sizeof h, deadline); + if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply"); + const std::size_t n = static_cast(h[4] | (h[5] << 8)); + if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply"); + std::vector body(n + 1); + read_exact(cmd_.get(), body.data(), body.size(), deadline); + unsigned rsum = 0; + for (std::uint8_t b : h) rsum += b; + for (std::uint8_t b : body) rsum += b; + if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply"); + if (h[1] != (cmd | 0x80) || h[2] != id) + throw LinkError(Kind::Protocol, "reply does not match the command"); + if (h[3] != 0) { + static const char* const kText[] = {"ok", "protocol error", "parameter error", + "busy", "not ready", "timeout", + "other error"}; + const char* t = h[3] < 7 ? kText[h[3]] : "unknown error"; + char hex[8]; + std::snprintf(hex, sizeof hex, "%02x", cmd); + throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t); + } + body.pop_back(); + if (expect >= 0 && body.size() != static_cast(expect)) + throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) + + " bytes, expected " + std::to_string(expect)); + return body; +} + +void Link::open_scan() { + close_scan(); + scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20); +} + +void Link::close_scan() { scan_.reset(); } + +void Link::send_filler() { + if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected"); + const std::uint8_t z = 0; + send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_)); +} + +std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) { + if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open"); + if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0; + const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT); + if (n > 0) return static_cast(n); + if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board"); + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0; + sys_fail("scan recv"); +} + +} // namespace udpscope::e1608 diff --git a/src/source/e1608/e1608_link.hpp b/src/source/e1608/e1608_link.hpp new file mode 100644 index 0000000..7c0a98d --- /dev/null +++ b/src/source/e1608/e1608_link.hpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +constexpr int kDefaultPort = 54211; +constexpr std::uint16_t kProductId = 0x012F; + +/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot +/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong +/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id). +class LinkError : public std::runtime_error { + public: + enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command }; + LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {} + Kind kind() const noexcept { return kind_; } + + private: + Kind kind_; +}; + +/// An owned file descriptor. +class Fd { + public: + Fd() = default; + explicit Fd(int fd) : fd_(fd) {} + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd(Fd&& o) noexcept : fd_(o.release()) {} + Fd& operator=(Fd&& o) noexcept { + if (this != &o) reset(o.release()); + return *this; + } + ~Fd() { reset(); } + int get() const noexcept { return fd_; } + bool valid() const noexcept { return fd_ >= 0; } + int release() noexcept { + const int f = fd_; + fd_ = -1; + return f; + } + /// Closes the current descriptor (if any) and takes `fd`. + void reset(int fd = -1) noexcept; + + private: + int fd_ = -1; +}; + +using Mac = std::array; + +/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else. +bool parse_mac(const std::string& text, Mac& out); +/// Lower-case `aa:bb:cc:dd:ee:ff`. +std::string format_mac(const Mac& mac); + +struct DeviceInfo { + std::string ip; + Mac mac{}; + std::uint16_t product_id = 0; +}; + +/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional +/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty. +std::vector sweep_targets(const std::string& subnet); + +/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id +/// `kProductId`) and one entry per MAC. +std::vector discover(const std::vector& targets, int port, int wait_ms); + +/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`). +/// Not thread safe; the source's producer thread owns it. +class Link { + public: + /// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code). + void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms); + /// Closes the command and scan sockets. Safe to call at any time. + void close(); + bool is_open() const noexcept { return cmd_.valid(); } + + /// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws + /// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame. + std::vector command(std::uint8_t cmd, const std::vector& payload = {}, + int expect = -1); + + /// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting). + void open_scan(); + void close_scan(); + bool scan_open() const noexcept { return scan_.valid(); } + /// The single `0x00` byte on the command socket that starts the stream after scan start. + void send_filler(); + /// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on + /// a timeout. Throws `Io` when the board closed the socket. + std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms); + + private: + Fd cmd_; + Fd scan_; + std::string host_; + int port_ = kDefaultPort; + int timeout_ms_ = 2000; + std::uint8_t next_id_ = 0; +}; + +} // namespace udpscope::e1608