From 67018acd251eec1e886bb73c43c1b8e0b9252c67 Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Sat, 10 Oct 2026 12:20:54 -0700 Subject: [PATCH] Format floats with the shortest round-trip digits Property::setValue(float) published %f text (21.400000). It now publishes the shortest decimal that reads back as the same float, laid out like python-sdk's float text: 21.4, 40.0, 1e-05, 1e16. homie_format_float() and homie_format_double() in homie_datatype.h do the formatting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vz59zZzRfxFkWjW16egZoy --- CHANGELOG.md | 4 ++ README.md | 2 +- homie/include/ebus/homie/homie_datatype.h | 10 +++ homie/src/Property.cpp | 2 +- homie/src/homie_datatype.cpp | 67 +++++++++++++++++++ .../test_homie_datatype.cpp | 57 ++++++++++++++++ test/test_link_engine/test_link_engine.cpp | 2 +- .../test_settable_dispatch.cpp | 2 +- 8 files changed, 142 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e6c88..16f65c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `cpp-sdk` are recorded here. Format follows [Keep a Chang ## [Unreleased] +### Changed + +- `Property::setValue(float)` publishes the shortest decimal that reads back as the same float, laid out like python-sdk's float text: `21.4`, `40.0`, `1e-05`, `1e16` instead of `21.400000`. The formatters are `homie_format_float()` and `homie_format_double()` in `ebus/homie/homie_datatype.h`. + ## [0.6.0] - 2026-10-10 ### Changed diff --git a/README.md b/README.md index 66a3e96..ac6e378 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Version 0.6.0, alpha. Known limits: - The POSIX demo build has no TLS; it connects to plain-TCP brokers only. - After a broker loses its retained messages, esp32-sdk's reconnect does not restore `$description`; it does not yet call `Device::forgetDescriptionHash()` ([After a reconnect](doc/core.md#after-a-reconnect)). - The driver contract (`NodeEntity`, `NodeProperty`) is still in esp32-sdk ([why](doc/core.md#still-in-esp32-sdk)). -- The code-first API has rough edges found while writing the POSIX demo: registering a settable property needs a live connection, the controller has no change callback, and float values are always formatted with `%f`. +- The code-first API has rough edges found while writing the POSIX demo: registering a settable property needs a live connection, and the controller has no change callback. - esp32-sdk is not public yet; links to it will resolve once it is. ## Quick start: the POSIX demo diff --git a/homie/include/ebus/homie/homie_datatype.h b/homie/include/ebus/homie/homie_datatype.h index 44888dc..a74be55 100644 --- a/homie/include/ebus/homie/homie_datatype.h +++ b/homie/include/ebus/homie/homie_datatype.h @@ -91,3 +91,13 @@ bool homie_parse_number_format(const char* format, HomieNumberFormat* out); // nothing in tree needs one, since a stepped format on a counter that large is not a // configuration anyone writes. bool homie_validate_number(double value, const char* format, double* coerced); + +// Format a float payload: the shortest decimal that reads back as the same float, laid +// out like Python's repr() without the exponent '+' (fixed notation with at least one +// fraction digit for exponents -4..15, else d.ddde-XX), so "21.4", "40.0", "1e-05", +// "1e16". This is python-sdk's float text. Writes NUL-terminated text to out (n >= 32); +// a non-finite value is written as "nan", "inf" or "-inf", which is not a valid payload. +void homie_format_float(float value, char* out, size_t n); + +// The same for a double: the shortest decimal that reads back as the same double. +void homie_format_double(double value, char* out, size_t n); diff --git a/homie/src/Property.cpp b/homie/src/Property.cpp index b0924d5..e8a46e9 100644 --- a/homie/src/Property.cpp +++ b/homie/src/Property.cpp @@ -82,7 +82,7 @@ void Property::setValue(int64_t value) { } void Property::setValue(float value) { _floatValue = value; - snprintf(_value, sizeof(_value), "%f", value); + homie_format_float(value, _value, sizeof(_value)); _has_value = true; } void Property::setValue(const char* value) { diff --git a/homie/src/homie_datatype.cpp b/homie/src/homie_datatype.cpp index b88fea0..16f3f94 100644 --- a/homie/src/homie_datatype.cpp +++ b/homie/src/homie_datatype.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -288,3 +289,69 @@ bool homie_validate_duration(const char* payload) { } return any; } + +// --- float formatting --------------------------------------------------------- + +// Shortest round trip: the fewest significant digits whose text reads back as `value`, +// through strtof() for a float (nine digits always suffice) or strtod() for a double +// (seventeen). +static void format_shortest(double value, bool is_float, char* out, size_t n) { + if (!isfinite(value)) { + snprintf(out, n, "%g", value); + return; + } + char sci[32]; + const int max_prec = is_float ? 8 : 16; + for (int prec = 0; prec <= max_prec; ++prec) { + snprintf(sci, sizeof(sci), "%.*e", prec, value); + if (is_float ? strtof(sci, NULL) == (float)value : strtod(sci, NULL) == value) break; + } + // sci is "[-]d[.ddd]e(+|-)XX": split it into sign, digits and the decimal exponent. + const char* p = sci; + bool negative = (*p == '-'); + if (negative) ++p; + char digits[20]; + int ndigits = 0; + for (; *p != 'e'; ++p) { + if (*p != '.') digits[ndigits++] = *p; + } + int exp10 = atoi(p + 1); + while (ndigits > 1 && digits[ndigits - 1] == '0') --ndigits; + + size_t len = 0; + char buf[48]; + if (negative) buf[len++] = '-'; + if (exp10 >= -4 && exp10 < 16) { + if (exp10 < 0) { + buf[len++] = '0'; + buf[len++] = '.'; + for (int i = -1; i > exp10; --i) buf[len++] = '0'; + for (int i = 0; i < ndigits; ++i) buf[len++] = digits[i]; + } else { + for (int i = 0; i <= exp10; ++i) buf[len++] = (i < ndigits) ? digits[i] : '0'; + buf[len++] = '.'; + if (ndigits > exp10 + 1) { + for (int i = exp10 + 1; i < ndigits; ++i) buf[len++] = digits[i]; + } else { + buf[len++] = '0'; + } + } + buf[len] = '\0'; + } else { + buf[len++] = digits[0]; + if (ndigits > 1) { + buf[len++] = '.'; + for (int i = 1; i < ndigits; ++i) buf[len++] = digits[i]; + } + snprintf(buf + len, sizeof(buf) - len, "e%s%02d", exp10 < 0 ? "-" : "", abs(exp10)); + } + snprintf(out, n, "%s", buf); +} + +void homie_format_float(float value, char* out, size_t n) { + format_shortest(value, true, out, n); +} + +void homie_format_double(double value, char* out, size_t n) { + format_shortest(value, false, out, n); +} diff --git a/test/test_homie_datatype/test_homie_datatype.cpp b/test/test_homie_datatype/test_homie_datatype.cpp index c6e028a..c348e1e 100644 --- a/test/test_homie_datatype/test_homie_datatype.cpp +++ b/test/test_homie_datatype/test_homie_datatype.cpp @@ -674,6 +674,57 @@ static void test_duration_repeated_component_is_rejected(void) { TEST_ASSERT_FALSE(homie_validate_duration("PT1H2H")); } + +// ── float formatting ─────────────────────────────────────────────────────────────────── + +static void expect_float(float value, const char* text) { + char out[32]; + homie_format_float(value, out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING(text, out); +} + +static void test_float_is_formatted_with_the_shortest_round_trip_digits(void) { + expect_float(21.4f, "21.4"); + expect_float(21.37f, "21.37"); + expect_float(-3.5f, "-3.5"); + expect_float(123456789.0f, "123456790.0"); +} + +static void test_whole_float_keeps_one_fraction_digit(void) { + expect_float(40.0f, "40.0"); + expect_float(0.0f, "0.0"); + expect_float(-0.0f, "-0.0"); +} + +static void test_float_uses_an_exponent_outside_1e_minus4_to_1e16(void) { + expect_float(0.0001f, "0.0001"); + expect_float(1e-5f, "1e-05"); + expect_float(1e15f, "1000000000000000.0"); + expect_float(1e16f, "1e16"); + expect_float(3.4028235e38f, "3.4028235e38"); +} + +static void test_double_is_formatted_with_the_shortest_round_trip_digits(void) { + char out[32]; + homie_format_double(0.1, out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING("0.1", out); + homie_format_double(12345678.9, out, sizeof(out)); // more digits than a float holds + TEST_ASSERT_EQUAL_STRING("12345678.9", out); + homie_format_double(1e22, out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING("1e22", out); +} + +static void test_formatted_float_is_a_valid_float_payload(void) { + const float values[] = {21.4f, 1e-5f, 1e16f, -0.0f, 1.17549435e-38f}; + for (float v : values) { + char out[32]; + double parsed = 0; + homie_format_float(v, out, sizeof(out)); + TEST_ASSERT_TRUE_MESSAGE(homie_parse_float_payload(out, &parsed), out); + TEST_ASSERT_TRUE((float)parsed == v); + } +} + int main(int, char**) { UNITY_BEGIN(); @@ -806,5 +857,11 @@ int main(int, char**) { RUN_TEST(test_duration_components_out_of_order_are_rejected); RUN_TEST(test_duration_repeated_component_is_rejected); + + RUN_TEST(test_float_is_formatted_with_the_shortest_round_trip_digits); + RUN_TEST(test_whole_float_keeps_one_fraction_digit); + RUN_TEST(test_float_uses_an_exponent_outside_1e_minus4_to_1e16); + RUN_TEST(test_double_is_formatted_with_the_shortest_round_trip_digits); + RUN_TEST(test_formatted_float_is_a_valid_float_payload); return UNITY_END(); } diff --git a/test/test_link_engine/test_link_engine.cpp b/test/test_link_engine/test_link_engine.cpp index 1502df6..897db88 100644 --- a/test/test_link_engine/test_link_engine.cpp +++ b/test/test_link_engine/test_link_engine.cpp @@ -121,7 +121,7 @@ static void test_local_retraction_keeps_the_last_good_value(void) { temp.forget_value(); tick(l, 1000); TEST_ASSERT_TRUE(l.source(0).has_value); - TEST_ASSERT_EQUAL_STRING("21.000000", l.source(0).value); + TEST_ASSERT_EQUAL_STRING("21.0", l.source(0).value); TEST_ASSERT_EQUAL_INT(1, published(LINE_T)); } diff --git a/test/test_settable_dispatch/test_settable_dispatch.cpp b/test/test_settable_dispatch/test_settable_dispatch.cpp index fffeed3..90513a2 100644 --- a/test/test_settable_dispatch/test_settable_dispatch.cpp +++ b/test/test_settable_dispatch/test_settable_dispatch.cpp @@ -129,7 +129,7 @@ static void test_target_is_published_first_with_the_exact_payload(void) { TEST_ASSERT_EQUAL_STRING("40.2", t.at(0).payload); TEST_ASSERT_TRUE(t.at(0).retained); TEST_ASSERT_EQUAL_STRING(p->topic(), t.at(1).topic); - TEST_ASSERT_EQUAL_STRING("40.000000", t.at(1).payload); // stepped and coerced + TEST_ASSERT_EQUAL_STRING("40.0", t.at(1).payload); // stepped and coerced } static void test_refused_set_restores_the_value_and_publishes_nothing(void) {