Instrument Neutral Distributed Interface INDI 2.2.5
protocol.cpp
Go to the documentation of this file.
1/*
2 Gemini Power Box Hub Advanced v3 INDI Driver
3
4 Copyright (C) 2026 Dieter R Kedrowitsch <[email protected]>
5
6 This program is free software; you can redistribute it and/or modify it
7 under the terms of the GNU General Public License as published by the Free
8 Software Foundation; either version 2 of the License, or (at your option)
9 any later version.
10
11 This program is distributed in the hope that it will be useful, but WITHOUT
12 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14 more details.
15
16 You should have received a copy of the GNU General Public License along with
17 this program; if not, write to the Free Software Foundation, Inc., 59 Temple
18 Place - Suite 330, Boston, MA 02111-1307, USA.
19
20 The full GNU General Public License is included in this distribution in the
21 file called LICENSE.
22*/
23
25#include <cerrno>
26#include <algorithm>
27#include <cstring>
28#include <cctype>
29#include <cstdio>
30#include <cmath>
31#include <exception>
32#include <sstream>
33#include <iomanip>
34#include <locale>
35#include <chrono>
36#include <thread>
37#include <utility>
38#include <fcntl.h>
39#include <unistd.h>
40#include <termios.h>
41#include <sys/ioctl.h>
42#include <sys/select.h>
43
44namespace geminipbh
45{
46
60
74
75namespace
76{
77thread_local GeminiProtocol::TelemetryCallbackHandle currentTelemetryCallbackHandle = 0;
78thread_local GeminiProtocol::ConnectionCallbackHandle currentConnectionCallbackHandle = 0;
79}
80
81GeminiProtocol::GeminiProtocol() : writeFn_(::write) {}
86
87static bool isAsciiWhitespace(char c)
88{
89 return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v';
90}
91
92bool GeminiProtocol::open(const std::string &port, int settleMs)
93{
94 return open(port, settleMs, false);
95}
96
97bool GeminiProtocol::open(const std::string &port, int settleMs, bool exclusive)
98{
99 close();
100 lastOpenErrno_ = 0;
101 lastOpenBusy_ = false;
102 fd_ = ::open(port.c_str(), O_RDWR | O_NOCTTY);
103 if (fd_ < 0)
104 {
105 lastOpenErrno_ = errno;
106 lastOpenBusy_ = errno == EBUSY;
107 return false;
108 }
109
110#if !defined(__CYGWIN__)
111 if (exclusive && ioctl(fd_, TIOCEXCL) != 0)
112 {
113 lastOpenErrno_ = errno;
114 lastOpenBusy_ = errno == EBUSY;
115 ::close(fd_);
116 fd_ = -1;
117 return false;
118 }
119#else
120 (void)exclusive;
121#endif
122
123 struct termios tio;
124 std::memset(&tio, 0, sizeof(tio));
125 if (tcgetattr(fd_, &tio) != 0)
126 {
127 lastOpenErrno_ = errno;
128 ::close(fd_);
129 fd_ = -1;
130 return false;
131 }
132 cfsetospeed(&tio, B19200);
133 cfsetispeed(&tio, B19200);
134 tio.c_cflag |= (CLOCAL | CREAD);
135 tio.c_cflag &= ~CSIZE;
136 tio.c_cflag |= CS8;
137 tio.c_cflag &= ~PARENB;
138 tio.c_cflag &= ~CSTOPB;
139 tio.c_cflag &= ~CRTSCTS;
140 tio.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
141 tio.c_iflag &= ~(IXON | IXOFF | IXANY);
142 tio.c_iflag &= ~(INLCR | ICRNL | IGNCR);
143 tio.c_oflag &= ~OPOST;
144 tio.c_cc[VMIN] = 0;
145 tio.c_cc[VTIME] = 0;
146 tcflush(fd_, TCIOFLUSH);
147 if (tcsetattr(fd_, TCSANOW, &tio) != 0)
148 {
149 lastOpenErrno_ = errno;
150 ::close(fd_);
151 fd_ = -1;
152 return false;
153 }
154 int flags = TIOCM_DTR | TIOCM_RTS;
155 if (ioctl(fd_, TIOCMSET, &flags) != 0)
156 {
157#ifdef GEMINI_TESTING
158 if (errno == ENOTTY || errno == EINVAL)
159 {
160 // Linux PTYs used in integration tests do not support modem-control
161 // ioctls. Production builds still fail this path as required.
162 }
163 else
164#endif
165 {
166 lastOpenErrno_ = errno;
168 {
169 const std::string message = "failed to assert RTS/DTR on " + port + ": " + std::strerror(errno);
170 emitWarningInstrumentation(instrumentation::Level::Error,
172 message);
173 }
174 ::close(fd_);
175 fd_ = -1;
176 return false;
177 }
178 }
179
180 if (settleMs > 0)
181 {
182 std::this_thread::sleep_for(std::chrono::milliseconds(settleMs));
183 }
184 return true;
185}
186
188{
189 if (fd_ >= 0)
190 {
191 ::tcflush(fd_, TCIOFLUSH);
192 int flags = ::fcntl(fd_, F_GETFL, 0);
193 if (flags >= 0) ::fcntl(fd_, F_SETFL, flags | O_NONBLOCK);
194 ::close(fd_);
195 fd_ = -1;
196 }
197}
199{
200 return fd_ >= 0;
201}
202
204{
205 return lastOpenErrno_;
206}
207
209{
210 return lastOpenBusy_;
211}
212
214{
215 instrumentation_.setLevel(level);
216}
217
219{
220 return instrumentation_.level();
221}
222
224{
225 return instrumentation_.enabled(level);
226}
227
233
235{
236 return instrumentation_.removeListener(handle);
237}
238
239bool GeminiProtocol::openWakePipe()
240{
241 if (wakeReadFd_ >= 0 && wakeWriteFd_ >= 0) return true;
242
243 int fds[2] = {-1, -1};
244 if (::pipe(fds) != 0)
245 {
247 {
248 const std::string message = std::string("failed to create reader wake pipe: ") + std::strerror(errno);
249 emitWarningInstrumentation(instrumentation::Level::Error,
251 message);
252 }
253 return false;
254 }
255
256 for (int fd : fds)
257 {
258 int flags = ::fcntl(fd, F_GETFL, 0);
259 if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0)
260 {
262 {
263 const std::string message = std::string("failed to configure reader wake pipe: ") + std::strerror(errno);
264 emitWarningInstrumentation(instrumentation::Level::Error,
266 message);
267 }
268 ::close(fds[0]);
269 ::close(fds[1]);
270 return false;
271 }
272 }
273
274 wakeReadFd_ = fds[0];
275 wakeWriteFd_ = fds[1];
276 return true;
277}
278
279void GeminiProtocol::closeWakePipe()
280{
281 if (wakeReadFd_ >= 0)
282 {
283 ::close(wakeReadFd_);
284 wakeReadFd_ = -1;
285 }
286 if (wakeWriteFd_ >= 0)
287 {
288 ::close(wakeWriteFd_);
289 wakeWriteFd_ = -1;
290 }
291}
292
293void GeminiProtocol::signalReaderWake()
294{
295 if (wakeWriteFd_ < 0) return;
296
297 const char byte = 'x';
298 while (::write(wakeWriteFd_, &byte, 1) < 0)
299 {
300 if (errno == EINTR) continue;
301 break;
302 }
303}
304
305void GeminiProtocol::drainReaderWake(int wakeFd)
306{
307 if (wakeFd < 0) return;
308
309 char buf[64];
310 while (::read(wakeFd, buf, sizeof(buf)) > 0) {}
311}
312
313void GeminiProtocol::emitInstrumentationEvent(instrumentation::Event event) const
314{
315 event.source = "geminipbh.GeminiProtocol";
316 event.connectionGeneration = connectionGeneration_.load(std::memory_order_acquire);
317 instrumentation_.emit(std::move(event));
318}
319
320void GeminiProtocol::emitConnectionInstrumentation(instrumentation::ConnectionState state,
322 const std::string &reason) const
323{
324 if (!instrumentationEnabled(level)) return;
325 emitInstrumentationEvent(instrumentation::makeEvent(
326 level,
328 instrumentation::ConnectionPayload{state, reason}));
329}
330
331void GeminiProtocol::emitFrameInstrumentation(instrumentation::FrameDirection direction,
332 const std::string &frame) const
333{
335 std::string token;
336 if (frame.size() >= 2 && (frame[0] == '>' || frame[0] == '*')) token.assign(1, frame[1]);
337 emitInstrumentationEvent(instrumentation::makeEvent(
340 instrumentation::EventType::FrameReceived,
341 instrumentation::FramePayload{direction, frame, token, true}));
342 }
343
344void GeminiProtocol::emitWarningInstrumentation(instrumentation::Level level,
346 const std::string &message) const
347{
348 if (!instrumentationEnabled(level)) return;
349 emitInstrumentationEvent(instrumentation::makeEvent(
350 level,
352 instrumentation::WarningPayload{code, message}));
353}
354
355void GeminiProtocol::emitParserAnomalyInstrumentation(instrumentation::Level level,
357 const std::string &detail,
358 const std::string &frame) const
359{
360 if (!instrumentationEnabled(level)) return;
361 emitInstrumentationEvent(instrumentation::makeEvent(
362 level,
364 instrumentation::ParserAnomalyPayload{code, detail, frame}));
365}
366
367void GeminiProtocol::emitReconnectInstrumentation(unsigned attempt,
368 unsigned maxAttempts,
369 std::chrono::milliseconds delayBeforeAttempt,
370 bool finalAttempt,
372{
376 {
378 }
380 {
382 }
383 if (!instrumentationEnabled(level)) return;
384 emitInstrumentationEvent(instrumentation::makeEvent(
385 level,
387 instrumentation::ReconnectAttemptPayload{attempt, maxAttempts, delayBeforeAttempt, finalAttempt, result}));
388}
389
390void GeminiProtocol::emitTimingInstrumentation(instrumentation::TimingOperation operation,
391 std::chrono::steady_clock::time_point start,
392 bool success) const
393{
395 const auto duration = std::chrono::duration_cast<std::chrono::microseconds>(
396 std::chrono::steady_clock::now() - start);
397 emitInstrumentationEvent(instrumentation::makeEvent(
400 instrumentation::TimingPayload{operation, duration, success}));
401}
402
403void GeminiProtocol::emitVerificationInstrumentation(instrumentation::VerificationOutcome outcome,
404 const std::string &detail,
405 unsigned long long relatedSequence) const
406{
409 if (!instrumentationEnabled(level)) return;
410 instrumentation::Event event = instrumentation::makeEvent(
411 level,
413 instrumentation::VerificationPayload{instrumentation::VerificationOperation::CommandStateConfirm,
414 outcome,
415 detail,
416 relatedSequence});
417 event.sequence = relatedSequence;
418 emitInstrumentationEvent(std::move(event));
419}
420
421void GeminiProtocol::drainInputLocked()
422{
423 if (fd_ < 0) return;
424 char buf[256];
425 struct timeval tv = {0, 0};
426 fd_set set;
427 FD_ZERO(&set);
428 FD_SET(fd_, &set);
429 while (select(fd_ + 1, &set, nullptr, nullptr, &tv) > 0)
430 {
431 if (::read(fd_, buf, sizeof(buf)) <= 0) break;
432 FD_ZERO(&set);
433 FD_SET(fd_, &set);
434 }
435}
436
437bool GeminiProtocol::writeAllLocked(const std::string &command)
438{
439 if (fd_ < 0) return false;
440 std::string frame = command + "\r\n";
441 emitFrameInstrumentation(instrumentation::FrameDirection::Tx, frame);
442 size_t written = 0;
443 while (written < frame.size())
444 {
445 ssize_t n = writeFn_(fd_, frame.data() + written, frame.size() - written);
446 if (n < 0)
447 {
448 if (errno == EINTR) continue;
450 {
451 const std::string message = std::string("serial write failed: ") + std::strerror(errno);
452 emitWarningInstrumentation(instrumentation::Level::Error,
454 message);
455 }
456 return false;
457 }
458 if (n == 0)
459 {
460 emitWarningInstrumentation(instrumentation::Level::Error,
462 "serial write returned 0 bytes");
463 return false;
464 }
465 written += static_cast<size_t>(n);
466 }
467 return true;
468}
469
470bool GeminiProtocol::readFrameOnce(std::string &response, int timeoutMs)
471{
472 response.clear();
473 if (fd_ < 0) return false;
474 auto deadline = std::chrono::steady_clock::now() +
475 std::chrono::milliseconds(timeoutMs);
476 char buf;
477 bool seenNonWhitespace = false;
478 while (std::chrono::steady_clock::now() < deadline && !cancelled())
479 {
480 struct timeval tv = {0, 50000};
481 fd_set set;
482 FD_ZERO(&set);
483 FD_SET(fd_, &set);
484 int rc = select(fd_ + 1, &set, nullptr, nullptr, &tv);
485 if (rc < 0)
486 {
487 if (errno == EINTR) continue;
488 return false;
489 }
490 if (rc == 0) continue;
491 ssize_t n = ::read(fd_, &buf, 1);
492 if (n <= 0) continue;
493 if (buf == '#')
494 {
495 emitFrameInstrumentation(instrumentation::FrameDirection::Rx, response);
496 return !response.empty();
497 }
498 if (!seenNonWhitespace)
499 {
500 if (isAsciiWhitespace(buf)) continue;
501 seenNonWhitespace = true;
502 }
503 response += buf;
504 }
505 return false;
506}
507
508bool GeminiProtocol::readTokenFrameOnce(char token, std::string &response, int timeoutMs)
509{
510 auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
511 while (std::chrono::steady_clock::now() < deadline && !cancelled())
512 {
513 int remaining = (int)std::chrono::duration_cast<std::chrono::milliseconds>(
514 deadline - std::chrono::steady_clock::now()).count();
515 std::string frame;
516 if (!readFrameOnce(frame, remaining > 0 ? remaining : 1)) return false;
517 if (frame.size() >= 2 && frame[0] == '*' && frame[1] == token)
518 {
519 response = frame;
520 return true;
521 }
522 if (frame.size() >= 2 && frame[0] == '*' && frame[1] == 'G')
523 {
524 StatusFrame parsed;
525 std::string err;
526 if (parseStatusPayload(frame.substr(2), parsed, err))
527 {
528 storeStatusFrame(parsed, false);
529 }
530 else
531 {
532 const bool anomalyEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
533 if (anomalyEnabled)
534 {
535 const std::string detail = std::string("ignored malformed G while waiting for ") + token + ": " + err;
536 emitParserAnomalyInstrumentation(instrumentation::Level::Verbose,
538 detail,
539 frame + "#");
540 }
541 }
542 continue;
543 }
544 const bool anomalyEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
545 if (anomalyEnabled)
546 {
547 const std::string detail = std::string("ignored frame while waiting for ") + token;
548 emitParserAnomalyInstrumentation(instrumentation::Level::Verbose,
550 detail,
551 frame + "#");
552 }
553 }
554 return false;
555}
556
557bool GeminiProtocol::parseFirmwareFrame(const std::string &frame, std::string &raw, int &numeric) const
558{
559 if (frame.size() < 3 || frame[0] != '*' || frame[1] != 'V') return false;
560 raw = frame.substr(2);
561 if (raw.empty()) return false;
562 try
563 {
564 size_t pos = 0;
565 numeric = std::stoi(raw, &pos);
566 if (pos != raw.size()) return false;
567 }
568 catch (...)
569 {
570 return false;
571 }
572 return true;
573}
574
575bool GeminiProtocol::cancelled() const
576{
577 return cancelFn_ && cancelFn_();
578}
579
580bool GeminiProtocol::sleepCancelable(int milliseconds) const
581{
582 int elapsed = 0;
583 while (elapsed < milliseconds)
584 {
585 if (cancelled()) return false;
586 int slice = std::min(50, milliseconds - elapsed);
587 std::this_thread::sleep_for(std::chrono::milliseconds(slice));
588 elapsed += slice;
589 }
590 return !cancelled();
591}
592
593bool GeminiProtocol::transactOnce(const std::string &command, std::string &response, int timeoutMs)
594{
595 std::lock_guard<std::mutex> wlock(writeMutex_);
596 drainInputLocked();
597 if (!writeAllLocked(command)) return false;
598 return readFrameOnce(response, timeoutMs);
599}
600
601GeminiProtocol::DirectQueryStatus GeminiProtocol::queryTokenFrame(const std::string &command,
602 char token,
603 int timeoutMs,
604 std::string &response,
605 std::string *detail)
606{
607 response.clear();
608 if (detail) detail->clear();
609
610 std::lock_guard<std::mutex> wlock(writeMutex_);
611 drainInputLocked();
612 if (!writeAllLocked(command))
613 {
614 if (detail) *detail = "write failed";
616 }
617
618 auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
619 std::string firstUnexpected;
620 while (std::chrono::steady_clock::now() < deadline && !cancelled())
621 {
622 const int remaining = static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
623 deadline - std::chrono::steady_clock::now()).count());
624 std::string frame;
625 if (!readFrameOnce(frame, remaining > 0 ? remaining : 1))
626 break;
627
628 if (frame.size() >= 2 && frame[0] == '*' && frame[1] == token)
629 {
630 response = frame;
632 }
633
634 if (frame.size() >= 2 && frame[0] == '*' && frame[1] == 'G')
635 {
636 StatusFrame parsed;
637 std::string err;
638 if (parseStatusPayload(frame.substr(2), parsed, err))
639 storeStatusFrame(parsed, false);
640 continue;
641 }
642
643 if (firstUnexpected.empty())
644 firstUnexpected = frame;
645 }
646
647 if (!firstUnexpected.empty())
648 {
649 if (detail) *detail = firstUnexpected;
651 }
652
653 if (detail) *detail = "timeout";
655}
656
658 std::string *detail)
659{
660 identity.clear();
661 std::string frame;
662 DirectQueryStatus status = queryTokenFrame(">H#", 'H', timeoutMs, frame, detail);
663 if (status != DirectQueryStatus::Success)
664 return status;
665
666 if (frame.size() < 3)
667 {
668 if (detail) *detail = frame;
670 }
671
672 identity = frame.substr(2);
673 if (identity.empty())
674 {
675 if (detail) *detail = frame;
677 }
679}
680
681GeminiProtocol::DirectQueryStatus GeminiProtocol::queryFirmware(std::string &raw, int &numeric, int timeoutMs,
682 std::string *detail)
683{
684 raw.clear();
685 numeric = 0;
686 std::string frame;
687 DirectQueryStatus status = queryTokenFrame(">V#", 'V', timeoutMs, frame, detail);
688 if (status != DirectQueryStatus::Success)
689 return status;
690
691 if (!parseFirmwareFrame(frame, raw, numeric))
692 {
693 if (frame.size() >= 2 && frame[0] == '*' && frame[1] == 'V')
694 raw = frame.substr(2);
695 if (detail) *detail = frame;
697 }
698
700}
701
703{
704 std::string identity;
705 return queryIdentity(identity, 2000) == DirectQueryStatus::Success &&
706 identity == "GeminiPowerBoxPlusAdv3";
707}
708
709bool GeminiProtocol::readFirmware(std::string &raw, int &numeric)
710{
711 return queryFirmware(raw, numeric, 2000) == DirectQueryStatus::Success;
712}
713
714bool GeminiProtocol::firmwareVersion(std::string &raw, int &numeric) const
715{
716 std::lock_guard<std::mutex> lock(firmwareMutex_);
717 if (!haveFirmwareVersion_) return false;
718 raw = firmwareVersionRaw_;
719 numeric = firmwareVersionNumeric_;
720 return true;
721}
722
723static bool parseBit(const std::string &s, size_t idx)
724{
725 return idx < s.size() && s[idx] == '1';
726}
727
728static bool validateBits(const std::string &s, const std::string &name, std::string &error)
729{
730 for (char c : s)
731 {
732 if (c != '0' && c != '1')
733 {
734 error = name + " field contains non-binary char";
735 return false;
736 }
737 }
738 return true;
739}
740
741static bool parseDbl(const std::string &s, double &v, const std::string &name, std::string &error)
742{
743 if (s.empty())
744 {
745 error = name + " field empty";
746 return false;
747 }
748 std::istringstream iss(s);
749 iss.imbue(std::locale::classic());
750 iss >> v;
751 if (iss.fail() || !iss.eof())
752 {
753 error = name + " field not numeric: '" + s + "'";
754 return false;
755 }
756 if (!std::isfinite(v))
757 {
758 error = name + " field is not finite";
759 return false;
760 }
761 return true;
762}
763
764static bool parseBool01(const std::string &s, bool &v, const std::string &name, std::string &error)
765{
766 if (s == "0")
767 {
768 v = false;
769 return true;
770 }
771 if (s == "1")
772 {
773 v = true;
774 return true;
775 }
776 error = name + " field must be 0 or 1";
777 return false;
778}
779
780static bool parseMode(const std::string &s, int &mode, const std::string &name, std::string &error)
781{
782 try
783 {
784 size_t p = 0;
785 mode = std::stoi(s, &p);
786 if (p != s.size())
787 {
788 error = name + " field has trailing garbage";
789 return false;
790 }
791 }
792 catch (...)
793 {
794 error = name + " field not integer";
795 return false;
796 }
797 if (mode < 0 || mode > 2)
798 {
799 error = name + " field must be 0, 1, or 2";
800 return false;
801 }
802 return true;
803}
804
805static bool parseRange(const std::string &s, double &v, double lo, double hi,
806 const std::string &name, std::string &error)
807{
808 if (!parseDbl(s, v, name, error)) return false;
809 if (v < lo || v > hi)
810 {
811 error = name + " field out of range [" +
812 std::to_string(lo) + ", " + std::to_string(hi) + "]";
813 return false;
814 }
815 return true;
816}
817
818bool GeminiProtocol::parseStatusPayload(const std::string &payload, StatusFrame &status, std::string &error) const
819{
820 static const char terminators[17] =
821 {
822 'D', 'U', 'A', 'T', 'A', 'M', 'B', 'M', 'C', 'C', 'S', 'T', 'H', 'D', 'V', 'C', 'P'
823 };
824 std::string fields[17];
825 size_t pos = 0;
826 for (int i = 0; i < 17; ++i)
827 {
828 size_t mpos = payload.find(terminators[i], pos);
829 if (mpos == std::string::npos)
830 {
831 error = std::string("missing terminator ") + terminators[i];
832 return false;
833 }
834 fields[i] = payload.substr(pos, mpos - pos);
835 pos = mpos + 1;
836 }
837 if (pos != payload.size())
838 {
839 error = "trailing data after final terminator P";
840 return false;
841 }
842
843 if (fields[0].size() != 4)
844 {
845 error = "DC field length != 4";
846 return false;
847 }
848 if (fields[1].size() != 6)
849 {
850 error = "USB field length != 6";
851 return false;
852 }
853 if (!validateBits(fields[0], "DC", error)) return false;
854 if (!validateBits(fields[1], "USB", error)) return false;
855 if (!parseBool01(fields[2], status.aht20Attached, "AHT20", error)) return false;
856 if (!parseBool01(fields[3], status.ds18b20Attached, "DS18B20", error)) return false;
857 if (!parseBool01(fields[4], status.dew6Enabled, "dew6Enabled", error)) return false;
858 if (!parseMode(fields[5], status.dew6Mode, "dew6Mode", error)) return false;
859 if (!parseBool01(fields[6], status.dew7Enabled, "dew7Enabled", error)) return false;
860 if (!parseMode(fields[7], status.dew7Mode, "dew7Mode", error)) return false;
861 if (!parseRange(fields[8], status.dew6OutputPercent, 0.0, 100.0, "dew6OutputPercent", error)) return false;
862 if (!parseRange(fields[9], status.dew7OutputPercent, 0.0, 100.0, "dew7OutputPercent", error)) return false;
863 if (!parseDbl(fields[10], status.deviceTemperatureC, "deviceTemperatureC", error)) return false;
864 if (!parseDbl(fields[11], status.airTemperatureC, "airTemperatureC", error)) return false;
865 if (!parseRange(fields[12], status.humidityPercent, 0.0, 100.0, "humidityPercent", error)) return false;
866 if (!parseDbl(fields[13], status.dewPointC, "dewPointC", error)) return false;
867 if (!parseDbl(fields[14], status.inputVoltageV, "inputVoltageV", error)) return false;
868 if (!parseDbl(fields[15], status.outputCurrentA, "outputCurrentA", error)) return false;
869 if (!parseDbl(fields[16], status.outputPowerW, "outputPowerW", error)) return false;
870
871 status.dc = { parseBit(fields[0], 0), parseBit(fields[0], 1), parseBit(fields[0], 2), parseBit(fields[0], 3) };
872 status.usb = { parseBit(fields[1], 0), parseBit(fields[1], 1), parseBit(fields[1], 2), parseBit(fields[1], 3), parseBit(fields[1], 4), parseBit(fields[1], 5) };
873 return true;
874}
875
876bool GeminiProtocol::latestStatus(StatusFrame &out, unsigned long long &frameSeq)
877{
878 std::lock_guard<std::mutex> lock(cacheMutex_);
879 if (frameSeq_ == 0) return false;
880 out = cachedStatus_;
881 frameSeq = frameSeq_;
882 return true;
883}
884
886{
887 if (!callback) return 0;
888
889 std::lock_guard<std::mutex> lock(telemetryCallbackMutex_);
890 TelemetryCallbackHandle handle = nextTelemetryCallbackHandle_++;
891 if (nextTelemetryCallbackHandle_ == 0) ++nextTelemetryCallbackHandle_;
892 telemetryCallbacks_.push_back(std::make_shared<TelemetryCallbackEntry>(handle, std::move(callback)));
893 return handle;
894}
895
897{
898 if (handle == 0) return false;
899
900 std::shared_ptr<TelemetryCallbackEntry> entry;
901 std::unique_lock<std::mutex> lock(telemetryCallbackMutex_);
902 auto it = std::find_if(telemetryCallbacks_.begin(), telemetryCallbacks_.end(),
903 [handle](const std::shared_ptr<TelemetryCallbackEntry> &candidate)
904 {
905 return candidate->handle == handle;
906 });
907 if (it == telemetryCallbacks_.end()) return false;
908
909 entry = *it;
910 entry->active = false;
911 telemetryCallbacks_.erase(it);
912
913 if (currentTelemetryCallbackHandle == handle) return true;
914
915 entry->cv.wait(lock, [&] { return entry->activeCalls == 0; });
916 return true;
917}
918
920{
921 if (!callback) return 0;
922
923 std::lock_guard<std::mutex> lock(connectionCallbackMutex_);
924 ConnectionCallbackHandle handle = nextConnectionCallbackHandle_++;
925 if (nextConnectionCallbackHandle_ == 0) ++nextConnectionCallbackHandle_;
926 connectionCallbacks_.push_back(std::make_shared<ConnectionCallbackEntry>(handle, std::move(callback)));
927 return handle;
928}
929
931{
932 if (handle == 0) return false;
933
934 std::shared_ptr<ConnectionCallbackEntry> entry;
935 std::unique_lock<std::mutex> lock(connectionCallbackMutex_);
936 auto it = std::find_if(connectionCallbacks_.begin(), connectionCallbacks_.end(),
937 [handle](const std::shared_ptr<ConnectionCallbackEntry> &candidate)
938 {
939 return candidate->handle == handle;
940 });
941 if (it == connectionCallbacks_.end()) return false;
942
943 entry = *it;
944 entry->active = false;
945 connectionCallbacks_.erase(it);
946
947 if (currentConnectionCallbackHandle == handle) return true;
948
949 entry->cv.wait(lock, [&] { return entry->activeCalls == 0; });
950 return true;
951}
952
953static HeaterMode heaterModeFromStatus(int mode)
954{
955 switch (mode)
956 {
957 case 0:
958 return HeaterMode::Auto;
959 case 1:
961 case 2:
963 }
964 return HeaterMode::Auto;
965}
966
967static void addStatusField(std::vector<instrumentation::NamedValue> &fields,
968 const std::string &name,
970{
971 fields.push_back(instrumentation::NamedValue{name, std::move(value)});
972}
973
974static std::vector<instrumentation::NamedValue> statusFields(const StatusFrame &status)
975{
976 std::vector<instrumentation::NamedValue> fields;
977 fields.reserve(25);
978 for (std::size_t i = 0; i < status.dc.size(); ++i)
979 addStatusField(fields, "dc[" + std::to_string(i) + "]", status.dc[i]);
980 for (std::size_t i = 0; i < status.usb.size(); ++i)
981 addStatusField(fields, "usb[" + std::to_string(i) + "]", status.usb[i]);
982 addStatusField(fields, "aht20Attached", status.aht20Attached);
983 addStatusField(fields, "ds18b20Attached", status.ds18b20Attached);
984 addStatusField(fields, "dew6Enabled", status.dew6Enabled);
985 addStatusField(fields, "dew6Mode", static_cast<std::int64_t>(status.dew6Mode));
986 addStatusField(fields, "dew7Enabled", status.dew7Enabled);
987 addStatusField(fields, "dew7Mode", static_cast<std::int64_t>(status.dew7Mode));
988 addStatusField(fields, "dew6OutputPercent", status.dew6OutputPercent);
989 addStatusField(fields, "dew7OutputPercent", status.dew7OutputPercent);
990 addStatusField(fields, "deviceTemperatureC", status.deviceTemperatureC);
991 addStatusField(fields, "airTemperatureC", status.airTemperatureC);
992 addStatusField(fields, "humidityPercent", status.humidityPercent);
993 addStatusField(fields, "dewPointC", status.dewPointC);
994 addStatusField(fields, "inputVoltageV", status.inputVoltageV);
995 addStatusField(fields, "outputCurrentA", status.outputCurrentA);
996 addStatusField(fields, "outputPowerW", status.outputPowerW);
997 return fields;
998}
999
1000static void addStatusChange(std::vector<instrumentation::FieldChange> &changes,
1001 const std::string &name,
1002 instrumentation::Value previous,
1003 instrumentation::Value current)
1004{
1005 if (previous != current)
1006 {
1007 changes.push_back(instrumentation::FieldChange{name, std::move(previous), std::move(current)});
1008 }
1009}
1010
1011static std::vector<instrumentation::FieldChange> statusChanges(const StatusFrame &previous,
1012 const StatusFrame &current)
1013{
1014 std::vector<instrumentation::FieldChange> changes;
1015 changes.reserve(25);
1016 for (std::size_t i = 0; i < current.dc.size(); ++i)
1017 addStatusChange(changes, "dc[" + std::to_string(i) + "]", previous.dc[i], current.dc[i]);
1018 for (std::size_t i = 0; i < current.usb.size(); ++i)
1019 addStatusChange(changes, "usb[" + std::to_string(i) + "]", previous.usb[i], current.usb[i]);
1020 addStatusChange(changes, "aht20Attached", previous.aht20Attached, current.aht20Attached);
1021 addStatusChange(changes, "ds18b20Attached", previous.ds18b20Attached, current.ds18b20Attached);
1022 addStatusChange(changes, "dew6Enabled", previous.dew6Enabled, current.dew6Enabled);
1023 addStatusChange(changes, "dew6Mode", static_cast<std::int64_t>(previous.dew6Mode),
1024 static_cast<std::int64_t>(current.dew6Mode));
1025 addStatusChange(changes, "dew7Enabled", previous.dew7Enabled, current.dew7Enabled);
1026 addStatusChange(changes, "dew7Mode", static_cast<std::int64_t>(previous.dew7Mode),
1027 static_cast<std::int64_t>(current.dew7Mode));
1028 addStatusChange(changes, "dew6OutputPercent", previous.dew6OutputPercent, current.dew6OutputPercent);
1029 addStatusChange(changes, "dew7OutputPercent", previous.dew7OutputPercent, current.dew7OutputPercent);
1030 addStatusChange(changes, "deviceTemperatureC", previous.deviceTemperatureC, current.deviceTemperatureC);
1031 addStatusChange(changes, "airTemperatureC", previous.airTemperatureC, current.airTemperatureC);
1032 addStatusChange(changes, "humidityPercent", previous.humidityPercent, current.humidityPercent);
1033 addStatusChange(changes, "dewPointC", previous.dewPointC, current.dewPointC);
1034 addStatusChange(changes, "inputVoltageV", previous.inputVoltageV, current.inputVoltageV);
1035 addStatusChange(changes, "outputCurrentA", previous.outputCurrentA, current.outputCurrentA);
1036 addStatusChange(changes, "outputPowerW", previous.outputPowerW, current.outputPowerW);
1037 return changes;
1038}
1039
1040TelemetrySnapshot GeminiProtocol::makeTelemetrySnapshot(const StatusFrame &status,
1041 unsigned long long sequence,
1042 std::chrono::steady_clock::time_point timestamp) const
1043{
1044 return TelemetrySnapshot(
1045 status.dc,
1046 status.usb,
1047 status.aht20Attached,
1048 status.ds18b20Attached,
1049 {status.dew6Enabled, status.dew7Enabled},
1050 {heaterModeFromStatus(status.dew6Mode), heaterModeFromStatus(status.dew7Mode)},
1051 {status.dew6OutputPercent, status.dew7OutputPercent},
1052 status.deviceTemperatureC,
1053 status.airTemperatureC,
1054 status.humidityPercent,
1055 status.dewPointC,
1056 status.inputVoltageV,
1057 status.outputCurrentA,
1058 status.outputPowerW,
1059 sequence,
1060 timestamp);
1061}
1062
1063TelemetrySnapshot GeminiProtocol::storeStatusFrame(const StatusFrame &status, bool dispatchCallbacks)
1064{
1065 const auto timestamp = std::chrono::steady_clock::now();
1066 const bool protocolInstrumentationEnabled = instrumentationEnabled(instrumentation::Level::Protocol);
1067 const bool verboseInstrumentationEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1068 StatusFrame previousStatus;
1069 bool havePreviousStatus = false;
1070 unsigned long long sequence = 0;
1071 {
1072 std::lock_guard<std::mutex> lock(cacheMutex_);
1073 if (verboseInstrumentationEnabled && frameSeq_ != 0)
1074 {
1075 previousStatus = cachedStatus_;
1076 havePreviousStatus = true;
1077 }
1078 cachedStatus_ = status;
1079 sequence = ++frameSeq_;
1080 lastFrameTime_ = timestamp;
1081 recoveryAttempted_.store(false, std::memory_order_release);
1082 }
1083 frameCv_.notify_all();
1084
1085 TelemetrySnapshot snapshot = makeTelemetrySnapshot(status, sequence, timestamp);
1086 if (protocolInstrumentationEnabled)
1087 {
1088 instrumentation::StatusDecodedPayload payload;
1089 payload.frameType = "G";
1090 payload.sequence = sequence;
1091 if (verboseInstrumentationEnabled) payload.fields = statusFields(status);
1092 instrumentation::Event event = instrumentation::makeEvent(
1095 std::move(payload));
1096 event.sequence = sequence;
1097 emitInstrumentationEvent(std::move(event));
1098 }
1099 if (verboseInstrumentationEnabled && havePreviousStatus)
1100 {
1101 auto changes = statusChanges(previousStatus, status);
1102 if (!changes.empty())
1103 {
1104 instrumentation::FieldChangesPayload payload;
1105 payload.sequence = sequence;
1106 payload.changes = std::move(changes);
1107 instrumentation::Event event = instrumentation::makeEvent(
1110 std::move(payload));
1111 event.sequence = sequence;
1112 emitInstrumentationEvent(std::move(event));
1113 }
1114 }
1115 if (dispatchCallbacks)
1116 {
1117 dispatchTelemetrySnapshot(snapshot);
1118 }
1119 return snapshot;
1120}
1121
1122void GeminiProtocol::beginCallbackDispatch()
1123{
1124 std::unique_lock<std::mutex> lock(callbackDispatchMutex_);
1125 callbackDispatchCv_.wait(lock, [&] { return !callbackDispatchInProgress_; });
1126 callbackDispatchInProgress_ = true;
1127}
1128
1129void GeminiProtocol::endCallbackDispatch()
1130{
1131 {
1132 std::lock_guard<std::mutex> lock(callbackDispatchMutex_);
1133 callbackDispatchInProgress_ = false;
1134 }
1135 callbackDispatchCv_.notify_all();
1136}
1137
1138void GeminiProtocol::dispatchTelemetrySnapshot(const TelemetrySnapshot &snapshot)
1139{
1140 beginCallbackDispatch();
1141 try
1142 {
1143
1144 std::vector<std::shared_ptr<TelemetryCallbackEntry>> entries;
1145 {
1146 std::lock_guard<std::mutex> lock(telemetryCallbackMutex_);
1147 entries = telemetryCallbacks_;
1148 }
1149
1150 for (const auto &entry : entries)
1151 {
1152 TelemetryCallback callback;
1153 {
1154 std::lock_guard<std::mutex> lock(telemetryCallbackMutex_);
1155 if (!entry->active) continue;
1156 ++entry->activeCalls;
1157 callback = entry->callback;
1158 }
1159
1160 currentTelemetryCallbackHandle = entry->handle;
1161 std::string diagnostic;
1162 try
1163 {
1164 callback(snapshot);
1165 }
1166 catch (const std::exception &ex)
1167 {
1168 diagnostic = std::string("telemetry callback threw exception: ") + ex.what();
1169 }
1170 catch (...)
1171 {
1172 diagnostic = "telemetry callback threw unknown exception";
1173 }
1174 currentTelemetryCallbackHandle = 0;
1175
1176 {
1177 std::lock_guard<std::mutex> lock(telemetryCallbackMutex_);
1178 --entry->activeCalls;
1179 if (entry->activeCalls == 0) entry->cv.notify_all();
1180 }
1181
1182 if (!diagnostic.empty())
1183 {
1184 try
1185 {
1186 emitWarningInstrumentation(instrumentation::Level::Error,
1188 diagnostic);
1189 }
1190 catch (...) {}
1191 }
1192 }
1193 }
1194 catch (...)
1195 {
1196 endCallbackDispatch();
1197 throw;
1198 }
1199
1200 endCallbackDispatch();
1201}
1202
1203void GeminiProtocol::dispatchConnectionEvent(ConnectionEvent event, const std::string &reason)
1204{
1205 beginCallbackDispatch();
1206 try
1207 {
1208
1209 std::vector<std::shared_ptr<ConnectionCallbackEntry>> entries;
1210 {
1211 std::lock_guard<std::mutex> lock(connectionCallbackMutex_);
1212 entries = connectionCallbacks_;
1213 }
1214
1215 for (const auto &entry : entries)
1216 {
1217 ConnectionCallback callback;
1218 {
1219 std::lock_guard<std::mutex> lock(connectionCallbackMutex_);
1220 if (!entry->active) continue;
1221 ++entry->activeCalls;
1222 callback = entry->callback;
1223 }
1224
1225 currentConnectionCallbackHandle = entry->handle;
1226 std::string diagnostic;
1227 try
1228 {
1229 callback(event);
1230 }
1231 catch (const std::exception &ex)
1232 {
1233 diagnostic = std::string("connection callback threw exception: ") + ex.what();
1234 }
1235 catch (...)
1236 {
1237 diagnostic = "connection callback threw unknown exception";
1238 }
1239 currentConnectionCallbackHandle = 0;
1240
1241 {
1242 std::lock_guard<std::mutex> lock(connectionCallbackMutex_);
1243 --entry->activeCalls;
1244 if (entry->activeCalls == 0) entry->cv.notify_all();
1245 }
1246
1247 if (!diagnostic.empty())
1248 {
1249 try
1250 {
1251 emitWarningInstrumentation(instrumentation::Level::Error,
1253 diagnostic);
1254 }
1255 catch (...) {}
1256 }
1257 }
1258 }
1259 catch (...)
1260 {
1261 endCallbackDispatch();
1262 throw;
1263 }
1264
1265 endCallbackDispatch();
1266
1269 switch (event)
1270 {
1273 break;
1276 break;
1280 break;
1283 break;
1284 }
1285 emitConnectionInstrumentation(state, level, reason);
1286}
1287
1288void GeminiProtocol::notifyCommunicationFailure(const std::string &reason)
1289{
1290 if (!connected_.load(std::memory_order_acquire)) return;
1291 bool alreadyNotified = communicationFailureNotified_.exchange(true, std::memory_order_acq_rel);
1292 if (alreadyNotified) return;
1293
1294 acceptingWrites_.store(false, std::memory_order_release);
1295 {
1296 std::lock_guard<std::mutex> qlock(queueMutex_);
1297 sessionStopRequested_.store(true, std::memory_order_release);
1298 }
1299 signalReaderWake();
1300 connected_.store(false, std::memory_order_release);
1301 queueCv_.notify_all();
1302 frameCv_.notify_all();
1303 dispatchConnectionEvent(ConnectionEvent::CommunicationFailure, reason);
1304}
1305
1306bool GeminiProtocol::waitForFrameAfter(unsigned long long minSeq, int timeoutMs)
1307{
1308 std::unique_lock<std::mutex> lock(cacheMutex_);
1309 auto deadline = std::chrono::steady_clock::now() +
1310 std::chrono::milliseconds(timeoutMs);
1311 return frameCv_.wait_until(lock, deadline, [&] { return frameSeq_ > minSeq; });
1312}
1313
1314bool GeminiProtocol::waitForFrameAfter(unsigned long long minSeq, int timeoutMs,
1315 StatusFrame &out, unsigned long long &frameSeq)
1316{
1317 std::unique_lock<std::mutex> lock(cacheMutex_);
1318 auto deadline = std::chrono::steady_clock::now() +
1319 std::chrono::milliseconds(timeoutMs);
1320 if (!frameCv_.wait_until(lock, deadline, [&] { return frameSeq_ > minSeq; }))
1321 {
1322 return false;
1323 }
1324 out = cachedStatus_;
1325 frameSeq = frameSeq_;
1326 return true;
1327}
1328
1329bool GeminiProtocol::waitForVerifiedState(unsigned long long preWriteSeq,
1330 StatusVerifier verifier,
1331 int timeoutMs,
1332 std::string &error,
1333 unsigned long long &verifiedSeq)
1334{
1335 auto start = std::chrono::steady_clock::now();
1336 auto deadline = start + std::chrono::milliseconds(timeoutMs);
1337 auto remainingMs = [&]
1338 {
1339 auto now = std::chrono::steady_clock::now();
1340 if (now >= deadline) return 0;
1341 return static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count());
1342 };
1343
1344 StatusFrame ignored;
1345 unsigned long long firstSeq = 0;
1346 if (!waitForFrameAfter(preWriteSeq, remainingMs(), ignored, firstSeq))
1347 {
1348 error = "verification timed out waiting for first post-write telemetry frame";
1349 emitVerificationInstrumentation(instrumentation::VerificationOutcome::Failed, error, 0);
1350 return false;
1351 }
1352
1353 StatusFrame candidate;
1354 if (!waitForFrameAfter(firstSeq, remainingMs(), candidate, verifiedSeq))
1355 {
1356 error = "verification timed out waiting for second post-write telemetry frame";
1357 emitVerificationInstrumentation(instrumentation::VerificationOutcome::Failed, error, firstSeq);
1358 return false;
1359 }
1360
1361 if (!verifier || !verifier(candidate))
1362 {
1363 error = "second post-write telemetry frame did not confirm requested state";
1364 emitVerificationInstrumentation(instrumentation::VerificationOutcome::Failed, error, verifiedSeq);
1365 return false;
1366 }
1367
1368 emitVerificationInstrumentation(instrumentation::VerificationOutcome::Passed,
1369 "verified by telemetry frame " + std::to_string(verifiedSeq),
1370 verifiedSeq);
1371 return true;
1372}
1373
1374static bool outputCommand(unsigned firmwareIndex, bool enabled, std::string &cmd)
1375{
1376 if (firmwareIndex < 2 || firmwareIndex > 11) return false;
1377 cmd = enabled ? ">O" : ">C";
1378 cmd += std::to_string(firmwareIndex) + "#";
1379 return true;
1380}
1381
1382static bool dewModeCommand(unsigned channel, DewMode mode, std::string &cmd)
1383{
1384 if (channel != 6 && channel != 7) return false;
1385 unsigned prefix = channel == 6 ? 10 : 20;
1386 unsigned suffix = 0;
1387 switch (mode)
1388 {
1389 case DewMode::Automatic:
1390 suffix = 0;
1391 break;
1392 case DewMode::Manual:
1393 suffix = 1;
1394 break;
1395 case DewMode::Switch:
1396 suffix = 2;
1397 break;
1398 }
1399 cmd = ">M" + std::to_string(prefix + suffix) + "#";
1400 return true;
1401}
1402
1403static bool dewPercentCommand(unsigned channel, unsigned percent, std::string &cmd)
1404{
1405 if ((channel != 6 && channel != 7) || percent > 100) return false;
1406 char cmdChar = channel == 6 ? 'X' : 'Y';
1407 cmd = std::string(">") + cmdChar + std::to_string(percent) + "#";
1408 return true;
1409}
1410
1411static bool dewEnableCommand(unsigned channel, bool enabled, std::string &cmd)
1412{
1413 if (channel == 6)
1414 {
1415 cmd = enabled ? ">Z11#" : ">Z10#";
1416 return true;
1417 }
1418 if (channel == 7)
1419 {
1420 cmd = enabled ? ">Z21#" : ">Z20#";
1421 return true;
1422 }
1423 return false;
1424}
1425
1426int GeminiProtocol::syncCommand(const std::string &command)
1427{
1428 if (!acceptingWrites_.load(std::memory_order_acquire)) return 3;
1429 const bool timingEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1430 const auto timingStart = timingEnabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
1431 bool ok = false;
1432 {
1433 std::lock_guard<std::mutex> wlock(writeMutex_);
1434 ok = writeAllLocked(command);
1435 }
1436 if (timingEnabled) emitTimingInstrumentation(instrumentation::TimingOperation::SyncWrite, timingStart, ok);
1437 if (!ok) notifyCommunicationFailure("serial write failed");
1438 return ok ? 0 : 4;
1439}
1440
1441#ifdef GEMINI_TESTING
1442int GeminiProtocol::syncWriteForTesting(const std::string &command)
1443{
1444 acceptingWrites_.store(true, std::memory_order_release);
1445 return syncCommand(command);
1446}
1447
1448size_t GeminiProtocol::queuedCommandCountForTesting()
1449{
1450 std::lock_guard<std::mutex> qlock(queueMutex_);
1451 return queue_.size();
1452}
1453
1454void GeminiProtocol::injectStatusForTesting(const StatusFrame &status)
1455{
1456 storeStatusFrame(status, true);
1457}
1458
1459void GeminiProtocol::emitConnectionEventForTesting(ConnectionEvent event)
1460{
1461 dispatchConnectionEvent(event);
1462}
1463
1464bool GeminiProtocol::parseFirmwarePayloadForTesting(const std::string &frame, std::string &raw, int &numeric) const
1465{
1466 return parseFirmwareFrame(frame, raw, numeric);
1467}
1468#endif
1469
1470int GeminiProtocol::setOutputSync(unsigned firmwareIndex, bool enabled)
1471{
1472 std::string cmd;
1473 if (!outputCommand(firmwareIndex, enabled, cmd)) return 1;
1474 return syncCommand(cmd);
1475}
1476
1477int GeminiProtocol::setDewModeSync(unsigned channel, DewMode mode)
1478{
1479 std::string cmd;
1480 if (!dewModeCommand(channel, mode, cmd)) return 1;
1481 return syncCommand(cmd);
1482}
1483
1484int GeminiProtocol::setDewManualPercentSync(unsigned channel, unsigned percent)
1485{
1486 std::string cmd;
1487 if (!dewPercentCommand(channel, percent, cmd)) return 1;
1488 return syncCommand(cmd);
1489}
1490
1491int GeminiProtocol::setDewEnabledSync(unsigned channel, bool enabled)
1492{
1493 std::string cmd;
1494 if (!dewEnableCommand(channel, enabled, cmd)) return 1;
1495 return syncCommand(cmd);
1496}
1497
1498int GeminiProtocol::setOutput(unsigned firmwareIndex, bool enabled)
1499{
1500 std::string cmd;
1501 if (!outputCommand(firmwareIndex, enabled, cmd)) return 1;
1502 return enqueueCommand(cmd);
1503}
1504
1505int GeminiProtocol::setDewMode(unsigned channel, DewMode mode)
1506{
1507 std::string cmd;
1508 if (!dewModeCommand(channel, mode, cmd)) return 1;
1509 return enqueueCommand(cmd);
1510}
1511
1512int GeminiProtocol::setDewManualPercent(unsigned channel, unsigned percent)
1513{
1514 std::string cmd;
1515 if (!dewPercentCommand(channel, percent, cmd)) return 1;
1516 return enqueueCommand(cmd);
1517}
1518
1519int GeminiProtocol::setDewEnabled(unsigned channel, bool enabled)
1520{
1521 std::string cmd;
1522 if (!dewEnableCommand(channel, enabled, cmd)) return 1;
1523 return enqueueCommand(cmd);
1524}
1525
1527{
1528 return enqueueCommand(">G#");
1529}
1530
1531int GeminiProtocol::enqueueCommand(const std::string &command)
1532{
1533 std::lock_guard<std::mutex> qlock(queueMutex_);
1534 if (!acceptingWrites_.load(std::memory_order_acquire))
1535 {
1536 const bool warningEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1537 if (warningEnabled)
1538 {
1539 const std::string message = "writes not accepted, rejecting: " + command;
1540 emitWarningInstrumentation(instrumentation::Level::Verbose,
1542 message);
1543 }
1544 return 3;
1545 }
1546 if (queue_.size() >= kQueueCap)
1547 {
1548 const bool warningEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1549 if (warningEnabled)
1550 {
1551 const std::string message = "queue full, rejecting: " + command;
1552 emitWarningInstrumentation(instrumentation::Level::Verbose,
1554 message);
1555 }
1556 return 2;
1557 }
1558 queue_.push_back(command);
1559 queueCv_.notify_one();
1560 return 0;
1561}
1562
1563void GeminiProtocol::readerLoop(int serialFd, int wakeFd)
1564{
1565 std::string acc;
1566 acc.reserve(256);
1567 std::string failureReason;
1568 while (running_.load(std::memory_order_acquire) &&
1569 !sessionStopRequested_.load(std::memory_order_acquire))
1570 {
1571 if (serialFd < 0 && wakeFd < 0) break;
1572 struct timeval tv = {1, 0};
1573 fd_set set;
1574 FD_ZERO(&set);
1575 int maxFd = -1;
1576 if (serialFd >= 0)
1577 {
1578 FD_SET(serialFd, &set);
1579 maxFd = std::max(maxFd, serialFd);
1580 }
1581 if (wakeFd >= 0)
1582 {
1583 FD_SET(wakeFd, &set);
1584 maxFd = std::max(maxFd, wakeFd);
1585 }
1586 int rc = select(maxFd + 1, &set, nullptr, nullptr, &tv);
1587 if (rc < 0)
1588 {
1589 if (errno == EINTR) continue;
1590 failureReason = std::string("serial select failed: ") + std::strerror(errno);
1591 break;
1592 }
1593 if (rc == 0) continue;
1594 if (wakeFd >= 0 && FD_ISSET(wakeFd, &set))
1595 {
1596 drainReaderWake(wakeFd);
1597 if (!running_.load(std::memory_order_acquire) ||
1598 sessionStopRequested_.load(std::memory_order_acquire))
1599 {
1600 break;
1601 }
1602 }
1603 if (serialFd < 0 || !FD_ISSET(serialFd, &set)) continue;
1604 char buf;
1605 ssize_t n = ::read(serialFd, &buf, 1);
1606 if (n < 0)
1607 {
1608 if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue;
1609 failureReason = std::string("serial read failed: ") + std::strerror(errno);
1610 break;
1611 }
1612 if (n == 0)
1613 {
1614 failureReason = "serial read returned end-of-file";
1615 break;
1616 }
1617 if (buf == '#')
1618 {
1619 if (!acc.empty()) emitFrameInstrumentation(instrumentation::FrameDirection::Rx, acc);
1620 if (acc.size() >= 2 && acc[0] == '*' && acc[1] == 'G')
1621 {
1622 std::string payload = acc.substr(2);
1623 StatusFrame parsed;
1624 std::string err;
1625 if (parseStatusPayload(payload, parsed, err))
1626 {
1627 storeStatusFrame(parsed, true);
1628 }
1629 else
1630 {
1631 const bool anomalyEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1632 if (anomalyEnabled)
1633 {
1634 const std::string detail = "status parse error: " + err;
1635 const std::string raw = "*G" + payload + "#";
1636 emitParserAnomalyInstrumentation(instrumentation::Level::Verbose,
1638 detail,
1639 raw);
1640 }
1641 }
1642 }
1643 else if (!acc.empty())
1644 {
1645 const bool anomalyEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1646 if (anomalyEnabled)
1647 {
1648 const std::string raw = acc + "#";
1649 emitParserAnomalyInstrumentation(instrumentation::Level::Verbose,
1651 "ignored frame",
1652 raw);
1653 }
1654 }
1655 acc.clear();
1656 }
1657 else if (acc.empty() && isAsciiWhitespace(buf))
1658 {
1659 continue;
1660 }
1661 else if (acc.size() < 4096)
1662 {
1663 acc += buf;
1664 }
1665 else
1666 {
1668 {
1669 emitParserAnomalyInstrumentation(instrumentation::Level::Verbose,
1671 "oversized frame discarded",
1672 acc);
1673 }
1674 acc.clear();
1675 }
1676 }
1677
1678 if (!failureReason.empty() &&
1679 running_.load(std::memory_order_acquire) &&
1680 !sessionStopRequested_.load(std::memory_order_acquire))
1681 {
1682 notifyCommunicationFailure(failureReason);
1683 }
1684}
1685
1686void GeminiProtocol::writerLoop()
1687{
1688 while (running_.load(std::memory_order_acquire) &&
1689 !sessionStopRequested_.load(std::memory_order_acquire))
1690 {
1691 std::unique_lock<std::mutex> qlock(queueMutex_);
1692 queueCv_.wait(qlock,
1693 [&] { return !running_.load(std::memory_order_acquire)
1694 || sessionStopRequested_.load(std::memory_order_acquire)
1695 || !queue_.empty();
1696 });
1697 if (!running_.load(std::memory_order_acquire) ||
1698 sessionStopRequested_.load(std::memory_order_acquire))
1699 {
1700 break;
1701 }
1702 if (queue_.empty()) continue;
1703 std::string cmd = queue_.front();
1704 queue_.pop_front();
1705 qlock.unlock();
1706 const bool timingEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1707 const auto timingStart = timingEnabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
1708 bool ok = false;
1709 {
1710 std::lock_guard<std::mutex> wlock(writeMutex_);
1711 ok = writeAllLocked(cmd);
1712 }
1713 if (timingEnabled) emitTimingInstrumentation(instrumentation::TimingOperation::AsyncWrite, timingStart, ok);
1714 if (!ok)
1715 {
1717 {
1718 const std::string message = "async write failed for command: " + cmd;
1719 emitWarningInstrumentation(instrumentation::Level::Error,
1721 message);
1722 }
1723 notifyCommunicationFailure("serial write failed");
1724 break;
1725 }
1726 }
1727 std::lock_guard<std::mutex> qlock(queueMutex_);
1728 if (!queue_.empty())
1729 {
1731 {
1732 const std::string message = "discarded " + std::to_string(queue_.size()) + " queued commands on session stop";
1733 emitWarningInstrumentation(instrumentation::Level::Info,
1735 message);
1736 }
1737 queue_.clear();
1738 }
1739}
1740
1741int GeminiProtocol::connect(const std::string &port, int settleMs)
1742{
1743 return connectInternal(port, settleMs, ConnectionEvent::Connected);
1744}
1745
1746int GeminiProtocol::connectInternal(const std::string &port, int settleMs, ConnectionEvent successEvent)
1747{
1748 const bool connectTimingEnabled = instrumentationEnabled(instrumentation::Level::Verbose);
1749 const auto connectTimingStart = connectTimingEnabled ? std::chrono::steady_clock::now() :
1750 std::chrono::steady_clock::time_point{};
1751 auto finishConnect = [&](int rc)
1752 {
1753 if (connectTimingEnabled)
1754 {
1755 emitTimingInstrumentation(instrumentation::TimingOperation::Connect, connectTimingStart, rc == 0);
1756 }
1757 return rc;
1758 };
1759
1760 lastPort_ = port;
1761 lastSettleMs_ = settleMs;
1762 acceptingWrites_.store(false, std::memory_order_release);
1763 connected_.store(false, std::memory_order_release);
1764 communicationFailureNotified_.store(false, std::memory_order_release);
1765 {
1766 std::lock_guard<std::mutex> lock(cacheMutex_);
1767 cachedStatus_ = StatusFrame{};
1768 frameSeq_ = 0;
1769 lastFrameTime_ = {};
1770 }
1771 if (!open(port, settleMs))
1772 {
1774 {
1775 const std::string message = "connect: open(" + port + ") failed";
1776 emitWarningInstrumentation(instrumentation::Level::Error,
1778 message);
1779 }
1780 return finishConnect(5);
1781 }
1782
1783 auto start = std::chrono::steady_clock::now();
1784 auto deadline = start + std::chrono::seconds(8);
1785 auto remainingMs = [&]
1786 {
1787 auto now = std::chrono::steady_clock::now();
1788 if (now >= deadline) return 0;
1789 return (int)std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count();
1790 };
1791
1792 // Step 1: handshake
1793 std::string resp;
1794 {
1795 std::lock_guard<std::mutex> wlock(writeMutex_);
1796 drainInputLocked();
1797 if (!writeAllLocked(">H#"))
1798 {
1799 close();
1800 return finishConnect(2);
1801 }
1802 if (!readTokenFrameOnce('H', resp, std::min(2000, remainingMs())))
1803 {
1804 close();
1805 return finishConnect(2);
1806 }
1807 }
1808 if (resp != "*HGeminiPowerBoxPlusAdv3")
1809 {
1811 {
1812 const std::string message = "connect: handshake mismatch: '" + resp + "'";
1813 emitWarningInstrumentation(instrumentation::Level::Error,
1815 message);
1817 {
1818 emitParserAnomalyInstrumentation(instrumentation::Level::Protocol,
1820 "handshake mismatch",
1821 resp + "#");
1822 }
1823 }
1824 close();
1825 return finishConnect(2);
1826 }
1827
1828 // Step 2: firmware
1829 std::string rawFw;
1830 int numericFw = 0;
1831 {
1832 std::lock_guard<std::mutex> wlock(writeMutex_);
1833 drainInputLocked();
1834 if (!writeAllLocked(">V#"))
1835 {
1836 close();
1837 return finishConnect(3);
1838 }
1839 if (!readTokenFrameOnce('V', resp, std::min(2000, remainingMs())))
1840 {
1841 close();
1842 return finishConnect(3);
1843 }
1844 }
1845 if (!parseFirmwareFrame(resp, rawFw, numericFw))
1846 {
1847 close();
1848 return finishConnect(3);
1849 }
1850 if (numericFw < 308)
1851 {
1853 {
1854 const std::string message = "connect: firmware " + std::to_string(numericFw) + " < 308, rejected";
1855 emitWarningInstrumentation(instrumentation::Level::Error,
1857 message);
1858 }
1859 close();
1860 return finishConnect(3);
1861 }
1862 if (numericFw < 309)
1863 {
1865 {
1866 const std::string message = "warning: firmware " + std::to_string(numericFw) +
1867 " < 309; DS18B20 detection fix not present, recommend 309+";
1868 emitWarningInstrumentation(instrumentation::Level::Info,
1870 message);
1871 }
1872 }
1873
1874 // Step 3: G + first frame
1875 {
1876 std::lock_guard<std::mutex> lock(cacheMutex_);
1877 cachedStatus_ = StatusFrame{};
1878 frameSeq_ = 0;
1879 lastFrameTime_ = {};
1880 }
1881 {
1882 std::lock_guard<std::mutex> wlock(writeMutex_);
1883 drainInputLocked();
1884 if (!writeAllLocked(">G#"))
1885 {
1886 close();
1887 return finishConnect(4);
1888 }
1889 if (!readFrameOnce(resp, std::min(4000, remainingMs())))
1890 {
1891 close();
1892 return finishConnect(4);
1893 }
1894 }
1895 if (resp.size() < 4 || resp[0] != '*' || resp[1] != 'G')
1896 {
1898 {
1899 const std::string message = "connect: first G frame not received";
1900 emitWarningInstrumentation(instrumentation::Level::Error,
1902 message);
1904 {
1905 emitParserAnomalyInstrumentation(instrumentation::Level::Protocol,
1907 message,
1908 resp.empty() ? std::string() : resp + "#");
1909 }
1910 }
1911 close();
1912 return finishConnect(4);
1913 }
1914 StatusFrame first;
1915 std::string err;
1916 if (!parseStatusPayload(resp.substr(2), first, err))
1917 {
1919 {
1920 const std::string message = "connect: first frame parse error: " + err;
1921 emitWarningInstrumentation(instrumentation::Level::Error,
1923 message);
1925 {
1926 emitParserAnomalyInstrumentation(instrumentation::Level::Protocol,
1928 message,
1929 resp + "#");
1930 }
1931 }
1932 close();
1933 return finishConnect(4);
1934 }
1935 if (!openWakePipe())
1936 {
1937 close();
1938 return finishConnect(5);
1939 }
1940
1941 // Reset session flags and spawn worker threads
1942 sessionStopRequested_.store(false, std::memory_order_release);
1943 reconnectPending_.store(false, std::memory_order_release);
1944 recoveryAttempted_.store(false, std::memory_order_release);
1945 running_.store(true, std::memory_order_release);
1946 {
1947 std::lock_guard<std::mutex> qlock(queueMutex_);
1948 queue_.clear();
1949 }
1950 acceptingWrites_.store(true, std::memory_order_release);
1951 connected_.store(true, std::memory_order_release);
1952 connectionGeneration_.fetch_add(1, std::memory_order_acq_rel);
1953 {
1954 std::lock_guard<std::mutex> lock(firmwareMutex_);
1955 firmwareVersionRaw_ = rawFw;
1956 firmwareVersionNumeric_ = numericFw;
1957 haveFirmwareVersion_ = true;
1958 }
1959 TelemetrySnapshot firstSnapshot = storeStatusFrame(first, false);
1960 const int readerSerialFd = fd_;
1961 const int readerWakeFd = wakeReadFd_;
1962 reader_ = std::thread([this, readerSerialFd, readerWakeFd] { readerLoop(readerSerialFd, readerWakeFd); });
1963 writer_ = std::thread([this] { writerLoop(); });
1964 dispatchTelemetrySnapshot(firstSnapshot);
1965 dispatchConnectionEvent(successEvent);
1966 return finishConnect(0);
1967}
1968
1970{
1971 // Called by the supervisor main thread. This may be called either after
1972 // a reader-reported communication failure (reader already exited) or
1973 // proactively (reader still running). In both cases we set the stop flag,
1974 // signal the wake pipe, join both worker threads, and only then close the
1975 // serial descriptor. This guarantees neither thread can access the fd
1976 // after close() or after the fd number is reused by a subsequent open().
1977 acceptingWrites_.store(false, std::memory_order_release);
1978 {
1979 std::lock_guard<std::mutex> qlock(queueMutex_);
1980 sessionStopRequested_.store(true, std::memory_order_release);
1981 }
1982 signalReaderWake();
1983 connected_.store(false, std::memory_order_release);
1984 queueCv_.notify_all();
1985 frameCv_.notify_all();
1986 if (reader_.joinable()) reader_.join();
1987 if (writer_.joinable()) writer_.join();
1988 {
1989 std::lock_guard<std::mutex> wlock(writeMutex_);
1990 close();
1991 }
1992 closeWakePipe();
1993 {
1994 std::lock_guard<std::mutex> qlock(queueMutex_);
1995 if (!queue_.empty())
1996 {
1998 {
1999 const std::string message = "discarded " + std::to_string(queue_.size()) + " queued commands on reconnect";
2000 emitWarningInstrumentation(instrumentation::Level::Info,
2002 message);
2003 }
2004 queue_.clear();
2005 }
2006 }
2007 reconnectPending_.store(true, std::memory_order_release);
2008
2009 int rc = -1;
2010 const int delaysMs[3] = {0, 1000, 2000};
2011 for (int attempt = 0; attempt < 3; ++attempt)
2012 {
2013 if (delaysMs[attempt] > 0)
2014 {
2015 if (!sleepCancelable(delaysMs[attempt]))
2016 {
2017 emitReconnectInstrumentation(attempt + 1, 3,
2018 std::chrono::milliseconds(delaysMs[attempt]),
2019 attempt == 2,
2021 reconnectPending_.store(false, std::memory_order_release);
2022 return 6;
2023 }
2024 }
2025 if (!running_.load(std::memory_order_acquire) || cancelled())
2026 {
2027 // SIGINT happened during reconnect; abort.
2028 emitReconnectInstrumentation(attempt + 1, 3,
2029 std::chrono::milliseconds(delaysMs[attempt]),
2030 attempt == 2,
2032 reconnectPending_.store(false, std::memory_order_release);
2033 return 6;
2034 }
2035 emitReconnectInstrumentation(attempt + 1, 3,
2036 std::chrono::milliseconds(delaysMs[attempt]),
2037 attempt == 2,
2039 // The connect() method opens the port itself.
2040 rc = connectInternal(lastPort_, lastSettleMs_, ConnectionEvent::ReconnectSuccess);
2041 if (rc == 0)
2042 {
2043 emitReconnectInstrumentation(attempt + 1, 3,
2044 std::chrono::milliseconds(delaysMs[attempt]),
2045 attempt == 2,
2047 reconnectPending_.store(false, std::memory_order_release);
2048 return 0;
2049 }
2050 emitReconnectInstrumentation(attempt + 1, 3,
2051 std::chrono::milliseconds(delaysMs[attempt]),
2052 attempt == 2,
2054 }
2055 emitReconnectInstrumentation(3, 3,
2056 std::chrono::milliseconds(delaysMs[2]),
2057 true,
2059 reconnectPending_.store(false, std::memory_order_release);
2060 return rc ? rc : 7;
2061}
2062
2064{
2065 const bool wasConnected = connected_.exchange(false, std::memory_order_acq_rel);
2066 acceptingWrites_.store(false, std::memory_order_release);
2067 {
2068 std::lock_guard<std::mutex> qlock(queueMutex_);
2069 sessionStopRequested_.store(true, std::memory_order_release);
2070 running_.store(false, std::memory_order_release);
2071 }
2072 signalReaderWake();
2073 queueCv_.notify_all();
2074 frameCv_.notify_all();
2075 if (reader_.joinable())
2076 {
2077 reader_.join();
2078 }
2079 if (writer_.joinable())
2080 {
2081 writer_.join();
2082 }
2083 {
2084 std::lock_guard<std::mutex> wlock(writeMutex_);
2085 close();
2086 }
2087 closeWakePipe();
2088 {
2089 std::lock_guard<std::mutex> qlock(queueMutex_);
2090 if (!queue_.empty())
2091 {
2093 {
2094 const std::string message = "discarded " + std::to_string(queue_.size()) + " queued commands on shutdown";
2095 emitWarningInstrumentation(instrumentation::Level::Info,
2097 message);
2098 }
2099 queue_.clear();
2100 }
2101 }
2102 if (wasConnected) dispatchConnectionEvent(ConnectionEvent::Disconnected);
2103}
2104
2105} // namespace geminipbh
bool latestStatus(StatusFrame &out, unsigned long long &frameSeq)
Definition protocol.cpp:876
void setInstrumentationLevel(instrumentation::Level level)
Definition protocol.cpp:213
InstrumentationListenerHandle registerInstrumentationListener(InstrumentationListener listener)
Definition protocol.cpp:228
int setDewMode(unsigned channel, DewMode mode)
std::atomic< bool > recoveryAttempted_
Definition protocol.h:203
bool waitForFrameAfter(unsigned long long minSeq, int timeoutMs)
bool firmwareVersion(std::string &raw, int &numeric) const
Definition protocol.cpp:714
bool readFirmware(std::string &raw, int &numeric)
Definition protocol.cpp:709
DirectQueryStatus queryIdentity(std::string &identity, int timeoutMs, std::string *detail=nullptr)
Definition protocol.cpp:657
int setDewModeSync(unsigned channel, DewMode mode)
bool removeInstrumentationListener(InstrumentationListenerHandle handle)
Definition protocol.cpp:234
int setDewManualPercentSync(unsigned channel, unsigned percent)
std::atomic< bool > reconnectPending_
Definition protocol.h:202
int setOutputSync(unsigned firmwareIndex, bool enabled)
DirectQueryStatus queryFirmware(std::string &raw, int &numeric, int timeoutMs, std::string *detail=nullptr)
Definition protocol.cpp:681
instrumentation::Level instrumentationLevel() const
Definition protocol.cpp:218
int setOutput(unsigned firmwareIndex, bool enabled)
bool waitForVerifiedState(unsigned long long preWriteSeq, StatusVerifier verifier, int timeoutMs, std::string &error, unsigned long long &verifiedSeq)
int setDewManualPercent(unsigned channel, unsigned percent)
std::function< bool(const StatusFrame &)> StatusVerifier
Definition protocol.h:119
bool open(const std::string &port, int settleMs=0)
Definition protocol.cpp:92
bool lastOpenWasBusy() const
Definition protocol.cpp:208
instrumentation::Dispatcher::Listener InstrumentationListener
Definition protocol.h:54
bool parseStatusPayload(const std::string &payload, StatusFrame &status, std::string &error) const
Definition protocol.cpp:818
std::uint64_t TelemetryCallbackHandle
Definition protocol.h:51
std::atomic< bool > acceptingWrites_
Definition protocol.h:204
int setDewEnabledSync(unsigned channel, bool enabled)
TelemetryCallbackHandle registerTelemetryCallback(TelemetryCallback callback)
Definition protocol.cpp:885
bool removeTelemetryCallback(TelemetryCallbackHandle handle)
Definition protocol.cpp:896
std::function< void(ConnectionEvent)> ConnectionCallback
Definition protocol.h:52
std::atomic< bool > sessionStopRequested_
Definition protocol.h:201
std::atomic< bool > running_
Definition protocol.h:200
int setDewEnabled(unsigned channel, bool enabled)
std::function< void(const TelemetrySnapshot &)> TelemetryCallback
Definition protocol.h:50
instrumentation::Dispatcher::ListenerHandle InstrumentationListenerHandle
Definition protocol.h:55
std::uint64_t ConnectionCallbackHandle
Definition protocol.h:53
bool instrumentationEnabled(instrumentation::Level level) const
Definition protocol.cpp:223
bool removeConnectionCallback(ConnectionCallbackHandle handle)
Definition protocol.cpp:930
ConnectionCallbackHandle registerConnectionCallback(ConnectionCallback callback)
Definition protocol.cpp:919
int connect(const std::string &port, int settleMs=0)
ListenerHandle registerListener(Listener listener)
bool removeListener(ListenerHandle handle)
bool enabled(Level eventLevel) const
int errno
int fd
Event makeEvent(Level level, EventType type, Payload payload)
std::variant< std::monostate, bool, std::int64_t, std::uint64_t, double, std::string > Value
@ error
throw a parse_error exception in case of a tag
uint8_t cmd[4]
Definition pwc-ioctl.h:2
ConnectionCallbackEntry(ConnectionCallbackHandle handle, ConnectionCallback callback)
Definition protocol.cpp:63
TelemetryCallbackEntry(TelemetryCallbackHandle handle, TelemetryCallback callback)
Definition protocol.cpp:49
std::array< bool, 4 > dc
std::array< bool, 6 > usb