This commit is contained in:
Joshua King
2026-10-02 12:23:58 -04:00
parent f0af1dbbcb
commit ad1e7e6d01
219 changed files with 488314 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// A fixed-size, little-endian UART frame shared by the camera and both eyes.
namespace gaze {
constexpr uint8_t kMagic0 = 0xA5;
constexpr uint8_t kMagic1 = 0x5A;
constexpr uint8_t kVersion = 1;
constexpr uint8_t kValid = 0x01;
constexpr size_t kFrameSize = 12;
constexpr uint32_t kTimeoutMs = 750;
struct Frame {
uint16_t sequence;
uint16_t x; // 0..1000, left to right in the camera image
uint16_t y; // 0..1000, top to bottom in the camera image
bool valid;
};
inline uint16_t crc16(const uint8_t *bytes, size_t length) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < length; ++i) {
crc ^= static_cast<uint16_t>(bytes[i]) << 8;
for (int bit = 0; bit < 8; ++bit) {
crc = (crc & 0x8000) ? static_cast<uint16_t>((crc << 1) ^ 0x1021)
: static_cast<uint16_t>(crc << 1);
}
}
return crc;
}
inline void encode(const Frame &frame, uint8_t out[kFrameSize]) {
out[0] = kMagic0;
out[1] = kMagic1;
out[2] = kVersion;
out[3] = frame.valid ? kValid : 0;
out[4] = static_cast<uint8_t>(frame.sequence);
out[5] = static_cast<uint8_t>(frame.sequence >> 8);
out[6] = static_cast<uint8_t>(frame.x);
out[7] = static_cast<uint8_t>(frame.x >> 8);
out[8] = static_cast<uint8_t>(frame.y);
out[9] = static_cast<uint8_t>(frame.y >> 8);
const uint16_t crc = crc16(out, kFrameSize - 2);
out[10] = static_cast<uint8_t>(crc);
out[11] = static_cast<uint8_t>(crc >> 8);
}
inline bool decode(const uint8_t bytes[kFrameSize], Frame *out) {
if (bytes[0] != kMagic0 || bytes[1] != kMagic1 ||
bytes[2] != kVersion || (bytes[3] & ~kValid) != 0) return false;
const uint16_t actual_crc = static_cast<uint16_t>(bytes[10]) |
(static_cast<uint16_t>(bytes[11]) << 8);
if (crc16(bytes, kFrameSize - 2) != actual_crc) return false;
const uint16_t x = static_cast<uint16_t>(bytes[6]) |
(static_cast<uint16_t>(bytes[7]) << 8);
const uint16_t y = static_cast<uint16_t>(bytes[8]) |
(static_cast<uint16_t>(bytes[9]) << 8);
if (x > 1000 || y > 1000) return false;
out->sequence = static_cast<uint16_t>(bytes[4]) |
(static_cast<uint16_t>(bytes[5]) << 8);
out->x = x;
out->y = y;
out->valid = (bytes[3] & kValid) != 0;
return true;
}
class Receiver {
public:
// Returns true only for a complete, valid, fresh frame.
bool feed(uint8_t byte, uint32_t now_ms, Frame *out) {
if (length_ == 0 && byte != kMagic0) return false;
if (length_ == 1 && byte != kMagic1) {
length_ = byte == kMagic0 ? 1 : 0;
return false;
}
bytes_[length_++] = byte;
if (length_ < kFrameSize) return false;
Frame decoded = {};
const bool well_formed = decode(bytes_, &decoded);
if (well_formed) {
length_ = 0;
} else {
++rejected_format_;
// Keep an embedded preamble, or a trailing first magic byte.
size_t start = 1;
while (start < kFrameSize &&
(bytes_[start] != kMagic0 ||
(start + 1 < kFrameSize && bytes_[start + 1] != kMagic1))) {
++start;
}
length_ = kFrameSize - start;
for (size_t i = 0; i < length_; ++i) bytes_[i] = bytes_[start + i];
}
if (!well_formed) return false;
if (has_sequence_ && static_cast<uint32_t>(now_ms - last_ms_) <= kTimeoutMs) {
const uint16_t advance = static_cast<uint16_t>(decoded.sequence - last_sequence_);
if (advance == 0 || advance >= 0x8000) {
++rejected_sequence_;
return false;
}
}
has_sequence_ = true;
last_sequence_ = decoded.sequence;
last_ms_ = now_ms;
*out = decoded;
return true;
}
bool recent(uint32_t now_ms) const {
return has_sequence_ && static_cast<uint32_t>(now_ms - last_ms_) <= kTimeoutMs;
}
uint32_t rejected_format() const { return rejected_format_; }
uint32_t rejected_sequence() const { return rejected_sequence_; }
private:
uint8_t bytes_[kFrameSize] = {};
size_t length_ = 0;
bool has_sequence_ = false;
uint16_t last_sequence_ = 0;
uint32_t last_ms_ = 0;
uint32_t rejected_format_ = 0;
uint32_t rejected_sequence_ = 0;
};
} // namespace gaze
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include "mbedtls/md.h"
namespace freddy_ota {
inline bool is_hex(const char *value, size_t length) {
if (!value || strlen(value) != length) return false;
for (size_t i = 0; i < length; ++i) {
const char c = value[i];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false;
}
return true;
}
inline void to_hex(const uint8_t *bytes, size_t size, char *out) {
constexpr char digits[] = "0123456789abcdef";
for (size_t i = 0; i < size; ++i) {
out[2 * i] = digits[bytes[i] >> 4];
out[2 * i + 1] = digits[bytes[i] & 15];
}
out[2 * size] = 0;
}
inline bool constant_time_equal(const char *a, const char *b, size_t size) {
unsigned difference = 0;
for (size_t i = 0; i < size; ++i)
difference |= static_cast<unsigned>(a[i] ^ b[i]);
return difference == 0;
}
// Authentication binds one upload to its device, one-time nonce, image digest,
// and exact binary length. The key is never sent over the network.
inline bool verify(const char *key, const char *device, const char *nonce,
const char *sha256, size_t length, const char *signature) {
if (!key || strlen(key) < 32 || !is_hex(nonce, 32) ||
!is_hex(sha256, 64) || !is_hex(signature, 64)) return false;
char message[160];
const int n = snprintf(message, sizeof(message), "%s\n%s\n%s\n%zu",
device, nonce, sha256, length);
if (n < 0 || static_cast<size_t>(n) >= sizeof(message)) return false;
const mbedtls_md_info_t *md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (!md) return false;
uint8_t digest[32];
if (mbedtls_md_hmac(md, reinterpret_cast<const uint8_t *>(key), strlen(key),
reinterpret_cast<const uint8_t *>(message), n,
digest) != 0) return false;
char expected[65];
to_hex(digest, sizeof(digest), expected);
return constant_time_equal(expected, signature, 64);
}
} // namespace freddy_ota
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Generated locally by firmware/tools/configure_ota.py; never commit credentials.
#if __has_include("../private/OtaSecrets.h")
#include "../private/OtaSecrets.h"
#else
#define FREDDY_WIFI_SSID ""
#define FREDDY_WIFI_PASSWORD ""
#define FREDDY_OTA_KEY ""
#endif
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <stdint.h>
#include <stdlib.h>
// One camera-side decision is broadcast to both eyes so brief model misses do
// not make their appearance and gaze repeatedly switch between TRACK and IDLE.
namespace tracking {
constexpr unsigned kConfirmFrames = 3;
constexpr uint32_t kTargetHoldMs = 1000;
struct Decision {
int x;
int y;
bool valid;
bool held;
};
class Gate {
public:
Decision update(bool detected, int x, int y, uint32_t now_ms) {
if (detected) {
if (candidate_count_ && abs(x - candidate_x_) <= 80 &&
abs(y - candidate_y_) <= 60) {
if (candidate_count_ < kConfirmFrames) ++candidate_count_;
} else {
candidate_count_ = 1;
}
candidate_x_ = x;
candidate_y_ = y;
if (candidate_count_ >= kConfirmFrames) {
locked_ = true;
last_confirmed_ms_ = now_ms;
locked_x_ = x;
locked_y_ = y;
return {x, y, true, false};
}
}
if (locked_ && static_cast<uint32_t>(now_ms - last_confirmed_ms_) <=
kTargetHoldMs) {
return {locked_x_, locked_y_, true, true};
}
locked_ = false;
if (!detected) {
candidate_count_ = 0;
candidate_x_ = -1;
candidate_y_ = -1;
return {500, 500, false, false};
}
return {x, y, false, false};
}
private:
unsigned candidate_count_ = 0;
int candidate_x_ = -1;
int candidate_y_ = -1;
bool locked_ = false;
int locked_x_ = 500;
int locked_y_ = 500;
uint32_t last_confirmed_ms_ = 0;
};
} // namespace tracking
+64
View File
@@ -0,0 +1,64 @@
#include "GazeProtocol.h"
#include <assert.h>
#include <stdio.h>
namespace {
bool feed_frame(gaze::Receiver *receiver, const uint8_t *bytes,
uint32_t now_ms, gaze::Frame *out) {
bool accepted = false;
for (size_t i = 0; i < gaze::kFrameSize; ++i) {
accepted |= receiver->feed(bytes[i], now_ms, out);
}
return accepted;
}
} // namespace
int main() {
gaze::Frame sent = {65535, 0, 1000, true};
uint8_t bytes[gaze::kFrameSize];
gaze::encode(sent, bytes);
gaze::Frame received = {};
assert(gaze::decode(bytes, &received));
assert(received.sequence == sent.sequence && received.x == 0 &&
received.y == 1000 && received.valid);
gaze::Receiver receiver;
assert(feed_frame(&receiver, bytes, 100, &received));
assert(receiver.recent(850));
assert(!receiver.recent(851));
assert(!feed_frame(&receiver, bytes, 200, &received)); // duplicate
assert(receiver.rejected_sequence() == 1);
sent.sequence = 0; // wrap-around
sent.x = 500;
gaze::encode(sent, bytes);
assert(feed_frame(&receiver, bytes, 200, &received));
assert(received.sequence == 0 && received.x == 500);
sent.sequence = 65535; // out of order
gaze::encode(sent, bytes);
assert(!feed_frame(&receiver, bytes, 300, &received));
assert(receiver.rejected_sequence() == 2);
assert(feed_frame(&receiver, bytes, 1000, &received)); // reboot after timeout
bytes[2] = 2; // unsupported version
assert(!gaze::decode(bytes, &received));
gaze::encode(sent, bytes);
bytes[6] ^= 1; // bad checksum
assert(!feed_frame(&receiver, bytes, 1100, &received));
assert(receiver.rejected_format() == 1);
sent.sequence = 1;
sent.valid = false;
gaze::encode(sent, bytes);
receiver.feed(0xA5, 1100, &received); // truncated preamble
receiver.feed(0x00, 1100, &received);
assert(feed_frame(&receiver, bytes, 1100, &received));
assert(!received.valid);
sent.x = 1001;
gaze::encode(sent, bytes);
assert(!gaze::decode(bytes, &received));
puts("gaze protocol: round trip, CRC, version, range, resync, sequence, timeout OK");
}
+35
View File
@@ -0,0 +1,35 @@
#include "TrackingGate.h"
#include <assert.h>
#include <stdio.h>
int main() {
tracking::Gate gate;
assert(!gate.update(false, 0, 0, 0).valid);
assert(!gate.update(true, 160, 100, 250).valid);
assert(!gate.update(true, 165, 105, 500).valid);
const auto first_lock = gate.update(true, 170, 110, 750);
assert(first_lock.valid && !first_lock.held && first_lock.x == 170);
// Two missed model results keep both eyes tracking the last known position.
const auto missed = gate.update(false, 0, 0, 1000);
assert(missed.valid && missed.held && missed.x == 170 && missed.y == 110);
assert(gate.update(false, 0, 0, 1250).valid);
const auto reacquired = gate.update(true, 173, 108, 1500);
assert(reacquired.valid && !reacquired.held && reacquired.x == 173);
// A new distant candidate cannot yank the gaze until confirmed.
const auto other = gate.update(true, 290, 200, 1750);
assert(other.valid && other.held && other.x == 173);
assert(gate.update(true, 292, 201, 2000).held);
const auto switched = gate.update(true, 294, 202, 2250);
assert(switched.valid && !switched.held && switched.x == 294);
// Once the person leaves, the brief hold expires and blue/IDLE returns.
assert(gate.update(false, 0, 0, 3250).valid);
assert(!gate.update(false, 0, 0, 3251).valid);
assert(!gate.update(true, 160, 100, 3500).valid);
assert(!gate.update(true, 162, 102, 3750).valid);
assert(gate.update(true, 164, 104, 4000).valid);
puts("tracking gate: confirmation, brief misses, candidate switch, expiry OK");
}