diff --git a/lib/furble/Camera.cpp b/lib/furble/Camera.cpp index cdf7b2f9b..d9c149aed 100644 --- a/lib/furble/Camera.cpp +++ b/lib/furble/Camera.cpp @@ -11,6 +11,21 @@ Camera::~Camera() { m_Client = nullptr; } +time_t Camera::toUnixTime(const timesync_t ×ync) { + struct tm tm = { + .tm_sec = static_cast(timesync.second), + .tm_min = static_cast(timesync.minute), + .tm_hour = static_cast(timesync.hour), + .tm_mday = static_cast(timesync.day), + .tm_mon = static_cast(timesync.month - 1), + .tm_year = static_cast(timesync.year - 1900), + .tm_wday = 0, + .tm_yday = 0, + .tm_isdst = -1, + }; + return mktime(&tm); +} + void Camera::onConnect(NimBLEClient *pClient) { ESP_LOGI(LOG_TAG, "Connected, adjusting transmit power to %d", m_Power); // Set BLE transmit power after connection is established. diff --git a/lib/furble/Camera.h b/lib/furble/Camera.h index 91a4c9b1d..4e7ed836a 100644 --- a/lib/furble/Camera.h +++ b/lib/furble/Camera.h @@ -1,6 +1,7 @@ #ifndef CAMERA_H #define CAMERA_H +#include #include #include @@ -32,6 +33,7 @@ class Camera: public NimBLEClientCallbacks { SONY = 7, FUJIFILM_SECURE = 8, RICOH = 9, + PANASONIC_LUMIX = 10, }; enum class PairType : uint8_t { @@ -67,6 +69,11 @@ class Camera: public NimBLEClientCallbacks { unsigned int centisecond; } timesync_t; + /** + * Convert a time sync structure to Unix epoch seconds. + */ + static time_t toUnixTime(const timesync_t ×ync); + Camera(); ~Camera(); diff --git a/lib/furble/CameraList.cpp b/lib/furble/CameraList.cpp index 606fd1639..e5d103cb3 100644 --- a/lib/furble/CameraList.cpp +++ b/lib/furble/CameraList.cpp @@ -6,6 +6,7 @@ #include "FauxNY.h" #include "FujifilmBasic.h" #include "FujifilmSecure.h" +#include "Lumix.h" #include "Nikon.h" #include "Ricoh.h" #include "Sony.h" @@ -185,6 +186,10 @@ void CameraList::load(void) { m_ConnectList.push_back( std::make_unique(static_cast(dbuffer), dbytes)); break; + case Camera::Type::PANASONIC_LUMIX: + m_ConnectList.push_back( + std::make_unique(static_cast(dbuffer), dbytes)); + break; } } m_Prefs.end(); @@ -243,6 +248,9 @@ bool CameraList::match(const NimBLEAdvertisedDevice *pDevice) { } else if (FujifilmSecure::matches(pDevice)) { m_ConnectList.push_back(std::make_unique(pDevice)); return true; + } else if (Lumix::matches(pDevice)) { + m_ConnectList.push_back(std::make_unique(pDevice)); + return true; } return false; diff --git a/lib/furble/CanonEOSSmart.cpp b/lib/furble/CanonEOSSmart.cpp index 96bb993ae..7604ad04d 100644 --- a/lib/furble/CanonEOSSmart.cpp +++ b/lib/furble/CanonEOSSmart.cpp @@ -193,19 +193,6 @@ void CanonEOSSmart::focusRelease(void) { void CanonEOSSmart::updateGeoData(const gps_t &gps, const timesync_t ×ync) { if (m_GeoEnabled) { - struct tm time_str = { - .tm_sec = static_cast(timesync.second), - .tm_min = static_cast(timesync.minute), - .tm_hour = static_cast(timesync.hour), - .tm_mday = static_cast(timesync.day), - .tm_mon = static_cast(timesync.month - 1), - .tm_year = static_cast(timesync.year - 1900), - .tm_wday = 0, - .tm_yday = 0, - .tm_isdst = -1, - }; - - time_t timestamp = mktime(&time_str); canon_geo_t geo = { .header = 0x04, .latitude_direction = gps.latitude < 0.0 ? 'S' : 'N', @@ -214,7 +201,7 @@ void CanonEOSSmart::updateGeoData(const gps_t &gps, const timesync_t ×ync) .longitude = static_cast(std::abs(gps.longitude)), .elevation_sign = gps.altitude < 0.0 ? '-' : '+', .elevation = static_cast(std::abs(gps.altitude)), - .timestamp = static_cast(timestamp), + .timestamp = static_cast(toUnixTime(timesync)), }; if ((m_Geo != nullptr) && m_Geo->canWrite()) { m_Geo->writeValue(reinterpret_cast(&geo), sizeof(geo), true); diff --git a/lib/furble/Lumix.cpp b/lib/furble/Lumix.cpp new file mode 100644 index 000000000..fcc0a9be9 --- /dev/null +++ b/lib/furble/Lumix.cpp @@ -0,0 +1,218 @@ +#include +#include +#include +#include +#include + +#include "Device.h" +#include "Lumix.h" + +namespace Furble { + +const NimBLEUUID Lumix::SESSION_SVC_UUID {0x054ac620, 0x3214, 0x11e6, 0xac0d0002a5d5c51b}; +const NimBLEUUID Lumix::SESSION_INIT_UUID {0x8d08a420, 0x3213, 0x11e6, 0x8aca0002a5d5c51b}; +const NimBLEUUID Lumix::DEVICE_NAME_UUID {0xcd7a71a0, 0x3213, 0x11e6, 0x8f560002a5d5c51b}; +const NimBLEUUID Lumix::CONTROL_SVC_UUID {0x7be5faae, 0x475b, 0x11e7, 0xa91992ebcb67fe33}; +const NimBLEUUID Lumix::CONTROL_UUID {0x7be5fd56, 0x475b, 0x11e7, 0xa91992ebcb67fe33}; +const NimBLEUUID Lumix::LOCATION_SVC_UUID {0x1d74afe0, 0x3214, 0x11e6, 0x8ab40002a5d5c51b}; +const NimBLEUUID Lumix::LOCATION_CHR_UUID {0xdaff1bc0, 0x3216, 0x11e6, 0x91c80002a5d5c51b}; +const NimBLEUUID Lumix::CLOCK_SVC_UUID {0x34738720, 0x3214, 0x11e6, 0xb66b0002a5d5c51b}; +const NimBLEUUID Lumix::CLOCK_CHR_UUID {0xead55e60, 0x3216, 0x11e6, 0xa42e0002a5d5c51b}; + +// Seconds from 1970-01-01 (Unix epoch) to 1980-01-06 (GPS epoch). +static constexpr uint32_t GPS_EPOCH_OFFSET = 315964800; + +Lumix::Lumix(const void *data, size_t len) : Camera(Type::PANASONIC_LUMIX, PairType::SAVED) { + if (len != sizeof(lumix_t)) + abort(); + + const lumix_t *lumix = static_cast(data); + m_Name = std::string(lumix->name); + m_Address = NimBLEAddress(lumix->address, lumix->type); +} + +Lumix::Lumix(const NimBLEAdvertisedDevice *pDevice) : Camera(Type::PANASONIC_LUMIX, PairType::NEW) { + m_Name = pDevice->getName(); + if (m_Name.empty()) + m_Name = "LUMIX"; + m_Address = pDevice->getAddress(); + ESP_LOGI(LOG_TAG, "Lumix Name = %s", m_Name.c_str()); + ESP_LOGI(LOG_TAG, "Lumix Address = %s", m_Address.toString().c_str()); +} + +bool Lumix::matches(const NimBLEAdvertisedDevice *pDevice) { + if (pDevice->haveManufacturerData() && pDevice->getManufacturerData().size() >= sizeof(adv_t)) { + const adv_t adv = pDevice->getManufacturerData(); + if (adv.company_id != ADV_PANASONIC_ID) + return false; + } else { + return false; + } + return pDevice->isAdvertisingService(SESSION_SVC_UUID); +} + +bool Lumix::_connect(void) { + m_Progress = 10; + ESP_LOGI(LOG_TAG, "Lumix connecting"); + if (!m_Client->connect(m_Address)) { + ESP_LOGI(LOG_TAG, "Lumix connection failed"); + return false; + } + m_Progress = 30; + + // MEI0 handshake + NimBLERemoteService *pSvc = m_Client->getService(SESSION_SVC_UUID); + if (pSvc == nullptr) { + ESP_LOGI(LOG_TAG, "Lumix session service unavailable"); + return false; + } + + NimBLERemoteCharacteristic *pChr = pSvc->getCharacteristic(SESSION_INIT_UUID); + if (pChr == nullptr || !pChr->canWrite()) { + ESP_LOGI(LOG_TAG, "Lumix session-init characteristic unavailable"); + return false; + } + if (!pChr->writeValue(MEI0.data(), MEI0.size(), true)) { + ESP_LOGI(LOG_TAG, "Lumix MEI0 write failed"); + return false; + } + m_Progress = 50; + + // Device name + pChr = pSvc->getCharacteristic(DEVICE_NAME_UUID); + if (pChr == nullptr || !pChr->canWrite()) { + ESP_LOGI(LOG_TAG, "Lumix device-name characteristic unavailable"); + return false; + } + // Send identity + const std::string name = Device::getStringID(); + if (!pChr->writeValue(name.c_str(), name.length(), true)) { + ESP_LOGI(LOG_TAG, "Lumix device-name write failed"); + return false; + } + m_Progress = 70; + + // Remote control service. + pSvc = m_Client->getService(CONTROL_SVC_UUID); + if (pSvc == nullptr) { + ESP_LOGI(LOG_TAG, "Lumix control service unavailable"); + return false; + } + m_Control = pSvc->getCharacteristic(CONTROL_UUID); + if (m_Control == nullptr || !m_Control->canWrite()) { + ESP_LOGI(LOG_TAG, "Lumix control characteristic unavailable"); + return false; + } + + // Location and clock services (optional). + pSvc = m_Client->getService(LOCATION_SVC_UUID); + if (pSvc != nullptr) { + m_Location = pSvc->getCharacteristic(LOCATION_CHR_UUID); + } else { + ESP_LOGI(LOG_TAG, "Lumix location service unavailable"); + } + pSvc = m_Client->getService(CLOCK_SVC_UUID); + if (pSvc != nullptr) { + m_Clock = pSvc->getCharacteristic(CLOCK_CHR_UUID); + } else { + ESP_LOGI(LOG_TAG, "Lumix clock service unavailable"); + } + + m_Progress = 100; + ESP_LOGI(LOG_TAG, "Lumix connected"); + return true; +} + +void Lumix::_disconnect(void) { + m_Control = nullptr; + m_Location = nullptr; + m_Clock = nullptr; + if (m_Client != nullptr && m_Client->isConnected()) + m_Client->disconnect(); +} + +bool Lumix::writeCommand(uint8_t command) { + if (!isConnected() || m_Control == nullptr || !m_Control->canWrite()) { + ESP_LOGW(LOG_TAG, "Lumix control write skipped: command=0x%02x connected=%d control=%p", + command, isConnected(), m_Control); + return false; + } + if (!m_Control->writeValue(&command, sizeof(command), true)) { + ESP_LOGW(LOG_TAG, "Lumix control write failed: command=0x%02x", command); + return false; + } + return true; +} + +void Lumix::shutterPress(void) { + writeCommand(CMD_SHUTTER_PRESS); +} + +void Lumix::shutterRelease(void) { + writeCommand(CMD_SHUTTER_RELEASE); +} + +void Lumix::focusPress(void) { + writeCommand(CMD_FOCUS_PRESS); +} + +void Lumix::focusRelease(void) { + writeCommand(CMD_FOCUS_RELEASE); +} + +void Lumix::updateGeoData(const gps_t &gps, const timesync_t ×ync) { + if (!isConnected()) { + ESP_LOGW(LOG_TAG, "Lumix GPS skipped: not connected"); + return; + } + if (m_Location == nullptr || !m_Location->canWrite()) { + ESP_LOGW(LOG_TAG, "Lumix GPS characteristic unavailable"); + return; + } + + geo_t geo = { + .gps_time = static_cast(toUnixTime(timesync)) - GPS_EPOCH_OFFSET, + .latitude = static_cast(gps.latitude * 10000000.0), + .longitude = static_cast(gps.longitude * 10000000.0), + .altitude = static_cast(gps.altitude), + .fix = 0x41, // 'A' — NMEA valid fix + .reserved = 0x00, + }; + bool rc = m_Location->writeValue(reinterpret_cast(&geo), sizeof(geo), true); + + // Synchronise the camera clock when a write characteristic is available. + if (rc && m_Clock != nullptr && m_Clock->canWrite()) { + clock_t clock = { + .year = static_cast(timesync.year), + .month = static_cast(timesync.month), + .day = static_cast(timesync.day), + .hour = static_cast(timesync.hour), + .minute = static_cast(timesync.minute), + .second = static_cast(timesync.second), + .zero = 0x00, + .tz_offset = 0, + }; + m_Clock->writeValue(reinterpret_cast(&clock), sizeof(clock), true); + } + + ESP_LOGI(LOG_TAG, "Lumix GPS lat=%.7f lon=%.7f alt=%.1f utc=%04u-%02u-%02u %02u:%02u:%02u => %s", + gps.latitude, gps.longitude, gps.altitude, timesync.year, timesync.month, timesync.day, + timesync.hour, timesync.minute, timesync.second, rc ? "ok" : "failed"); +} + +size_t Lumix::getSerialisedBytes(void) const { + return sizeof(lumix_t); +} + +bool Lumix::serialise(void *buffer, size_t bytes) const { + if (bytes != sizeof(lumix_t)) + return false; + lumix_t *x = static_cast(buffer); + strncpy(x->name, m_Name.c_str(), MAX_NAME); + x->name[MAX_NAME - 1] = '\0'; + x->address = static_cast(m_Address); + x->type = m_Address.getType(); + return true; +} + +} // namespace Furble diff --git a/lib/furble/Lumix.h b/lib/furble/Lumix.h new file mode 100644 index 000000000..0d299861e --- /dev/null +++ b/lib/furble/Lumix.h @@ -0,0 +1,115 @@ +#ifndef LUMIX_H +#define LUMIX_H + +#include + +#include +#include + +#include "Camera.h" + +namespace Furble { +/** + * Panasonic LUMIX. + * + * Reverse engineering from S5II and BGH1 HCI snoop logs. + * + * Protocol references: + * - https://github.com/tobiasbrummer/lux-lat-long-log + * - https://github.com/Aikhjarto/LUMIX-G9II-Remote-Control + */ +class Lumix: public Camera { + public: + Lumix(const void *data, size_t len); + Lumix(const NimBLEAdvertisedDevice *pDevice); + + /** + * Determine if the advertised BLE device is a Panasonic LUMIX. + */ + static bool matches(const NimBLEAdvertisedDevice *pDevice); + + void shutterPress(void) override final; + void shutterRelease(void) override final; + void focusPress(void) override final; + void focusRelease(void) override final; + void updateGeoData(const gps_t &gps, const timesync_t ×ync) override final; + + size_t getSerialisedBytes(void) const override final; + bool serialise(void *buffer, size_t bytes) const override final; + + private: + typedef struct _lumix_t { + char name[MAX_NAME]; /** Human readable device name. */ + uint64_t address; /** Device MAC address. */ + uint8_t type; /** Address type. */ + } lumix_t; + + // Panasonic manufacturer-specific advertising data (company ID + payload). + typedef struct __attribute__((packed)) _adv_t { + uint16_t company_id; /** Bluetooth SIG company ID. */ + uint8_t flags; /** State/flags (maybe pairing mode?). */ + uint8_t addr[5]; /** Bottom 5 bytes of the address. */ + } adv_t; + + static const uint16_t ADV_PANASONIC_ID = 0x003a; + + // Session service (advertised by LUMIX cameras) + static const NimBLEUUID SESSION_SVC_UUID; + // Session-init (MEI0 handshake) and controller device name + static const NimBLEUUID SESSION_INIT_UUID; + static const NimBLEUUID DEVICE_NAME_UUID; + // Remote control service and command characteristic + static const NimBLEUUID CONTROL_SVC_UUID; + static const NimBLEUUID CONTROL_UUID; + + // Location service (GPS push) and Clock service (time sync) + static const NimBLEUUID LOCATION_SVC_UUID; + static const NimBLEUUID LOCATION_CHR_UUID; + static const NimBLEUUID CLOCK_SVC_UUID; + static const NimBLEUUID CLOCK_CHR_UUID; + + // GPS push payload (16 bytes, little-endian). + typedef struct __attribute__((packed)) _geo_t { + uint32_t gps_time; /** Seconds since 1980-01-06 (GPS epoch). */ + int32_t latitude; /** Latitude x 1e7. */ + int32_t longitude; /** Longitude x 1e7. */ + uint16_t altitude; /** Altitude in metres. */ + uint8_t fix; /** Fix status, 0x41 = 'A' (valid). */ + uint8_t reserved; /** Constant 0x00. */ + } geo_t; + + // Clock-sync payload (10 bytes, little-endian). + typedef struct __attribute__((packed)) _clock_t { + uint16_t year; + uint8_t month; + uint8_t day; + uint8_t hour; + uint8_t minute; + uint8_t second; + uint8_t zero; + int16_t tz_offset; /** UTC offset in minutes. */ + } clock_t; + + // Single-byte remote control commands + static constexpr uint8_t CMD_FOCUS_PRESS = 0x01; + static constexpr uint8_t CMD_FOCUS_RELEASE = 0x02; + static constexpr uint8_t CMD_SHUTTER_PRESS = 0x04; + static constexpr uint8_t CMD_SHUTTER_RELEASE = 0x05; + + NimBLERemoteCharacteristic *m_Control = nullptr; + NimBLERemoteCharacteristic *m_Location = nullptr; + NimBLERemoteCharacteristic *m_Clock = nullptr; + + // MEI0 session-init payload: magic "MEI0" + fixed flags + post-amble. + // Post-amble contents are unknown at this time. + static constexpr std::array MEI0 = {0x4d, 0x45, 0x49, 0x30, 0x01, 0x00, 0x10, 0x00, + 0x80, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x00}; + + bool _connect(void) override final; + void _disconnect(void) override final; + + bool writeCommand(uint8_t command); +}; +} // namespace Furble + +#endif // LUMIX_H diff --git a/lib/furble/Ricoh.cpp b/lib/furble/Ricoh.cpp index 1dbd69841..03981e3bb 100644 --- a/lib/furble/Ricoh.cpp +++ b/lib/furble/Ricoh.cpp @@ -56,12 +56,6 @@ double bswapd64(double x) { return val; } -bool validTimesync(const Camera::timesync_t ×ync) { - return timesync.year >= 2000 && timesync.year <= 2099 && timesync.month >= 1 - && timesync.month <= 12 && timesync.day >= 1 && timesync.day <= 31 && timesync.hour <= 23 - && timesync.minute <= 59 && timesync.second <= 60 && timesync.centisecond <= 99; -} - const char *powerName(uint8_t value) { switch (value) { case 0: @@ -386,15 +380,6 @@ void Ricoh::updateGeoData(const gps_t &gps, const timesync_t ×ync) { ESP_LOGW(LOG_TAG, "Ricoh GPS characteristic unavailable"); return; } - if (!std::isfinite(gps.latitude) || !std::isfinite(gps.longitude) || !std::isfinite(gps.altitude) - || !validTimesync(timesync)) { - ESP_LOGW(LOG_TAG, - "Ricoh GPS invalid: lat=%.7f lon=%.7f alt=%.1f " - "utc=%04u-%02u-%02u %02u:%02u:%02u.%02u", - gps.latitude, gps.longitude, gps.altitude, timesync.year, timesync.month, timesync.day, - timesync.hour, timesync.minute, timesync.second, timesync.centisecond); - return; - } const uint32_t nowMs = static_cast(esp_timer_get_time() / 1000ULL); const bool moved = !m_HasGpsWrite diff --git a/src/FurbleGPS.cpp b/src/FurbleGPS.cpp index a15f34e14..411f40242 100644 --- a/src/FurbleGPS.cpp +++ b/src/FurbleGPS.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include "icons.h" @@ -171,11 +172,19 @@ void GPS::update(void) { m_HasFix = false; } - if (m_HasFix) { + const double lat = m_GPS.location.lat(); + const double lon = m_GPS.location.lng(); + const double alt = m_GPS.altitude.meters(); + const unsigned int year = m_GPS.date.year(); + const bool sane = std::isfinite(lat) && std::isfinite(lon) && std::isfinite(alt) + && std::fabs(lat) <= 90.0 && std::fabs(lon) <= 180.0 && year >= 2000 + && year <= 2099; + + if (m_HasFix && sane) { Camera::gps_t dgps = { - m_GPS.location.lat(), - m_GPS.location.lng(), - m_GPS.altitude.meters(), + lat, + lon, + alt, m_GPS.satellites.value(), }; Camera::timesync_t timesync = {