From 5e94966499d2b22a29aa85be6feb1f4fe333578f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:05:53 -0700 Subject: [PATCH 1/7] chore: bump version to 2.6.1 --- pyproject.toml | 2 +- uv.lock | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e97a58..261160b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "2.6.0" +version = "2.6.1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/uv.lock b/uv.lock index 95804b8..08a204f 100644 --- a/uv.lock +++ b/uv.lock @@ -130,31 +130,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8 wheels = [ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, @@ -426,27 +438,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, @@ -1292,7 +1310,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "2.6.0" +version = "2.6.1" source = { editable = "." } dependencies = [ { name = "httpx" }, From d75b2593b1b0ccb5200e05f05b174c1bddb5dd35 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:07:11 -0700 Subject: [PATCH 2/7] fix(mqtt): enforce min snapshot interval, tighten TLS/reconnect SpanMqttClient: - snapshot_interval and set_snapshot_interval() now raise ValueError below 1.0s, removing the <=0 immediate-dispatch branch that could spawn unbounded per-message dispatch tasks on a busy broker. - Connection-callback exceptions logged at WARNING (matches _dispatch_snapshot); the old exception() level implied a fatal error that the bridge handles cleanly. - _wait_for_circuit_names uses time.monotonic() instead of the deprecated asyncio.get_event_loop().time() pattern. AsyncMqttBridge: - Build ssl.SSLContext from fetched PEM via cadata and pass it with tls_set_context() instead of writing the CA to a temp file. Eliminates the file cleanup lifecycle and its crash-time leak. - _reconnect_loop catches all exceptions (not only OSError) so transport-specific failures like WebsocketConnectionError or ssl.SSLError no longer kill the background task silently. - Abnormal disconnects (reason_code.is_failure) log at WARNING. HomieDeviceConsumer: - _build_circuit: explicit -0.0 suppression replaces the cryptic `-raw or 0.0` idiom. - _derive_dsm_state: grid-exchanging check uses an epsilon (1.0 W) instead of != 0.0 float comparison, so DSM_OFF_GRID is reachable when lugs readings hover near zero. --- README.md | 2 + src/span_panel_api/mqtt/client.py | 30 +++-- src/span_panel_api/mqtt/connection.py | 181 ++++++++++++-------------- src/span_panel_api/mqtt/homie.py | 14 +- tests/conftest.py | 8 +- tests/test_mqtt_client_connection.py | 2 +- tests/test_mqtt_connect_flow.py | 15 ++- tests/test_mqtt_debounce.py | 134 +++++++++---------- 8 files changed, 185 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index eb77735..d881da7 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ await client.connect() `set_snapshot_interval()` controls how often push-mode snapshot callbacks fire. Lower values mean lower latency; higher values reduce CPU usage on constrained hardware. Dirty-node caching (v2.5.0) further reduces per-scan cost by skipping unchanged nodes. +The minimum is **1.0 seconds**; passing a smaller value (including 0) raises `ValueError`. This protects subscribers from unbounded dispatch on a busy broker. + ```python # Reduce snapshot frequency to every 2 seconds client.set_snapshot_interval(2.0) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index a412087..c061eae 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -11,6 +11,7 @@ from collections.abc import Awaitable, Callable import contextlib import logging +import time from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError @@ -30,6 +31,10 @@ _CIRCUIT_NAMES_TIMEOUT_S = 10.0 _CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25 +# Minimum debounce interval. Prevents unbounded per-message dispatch tasks +# that could overwhelm subscribers on a busy broker. +_MIN_SNAPSHOT_INTERVAL_S = 1.0 + class SpanMqttClient: """MQTT transport — implements all span-panel-api protocols.""" @@ -42,6 +47,8 @@ def __init__( snapshot_interval: float = 1.0, panel_http_port: int = 80, ) -> None: + if snapshot_interval < _MIN_SNAPSHOT_INTERVAL_S: + raise ValueError(f"snapshot_interval must be >= {_MIN_SNAPSHOT_INTERVAL_S}s, got {snapshot_interval}") self._host = host self._serial_number = serial_number self._broker_config = broker_config @@ -322,13 +329,8 @@ def _on_message(self, topic: str, payload: str) -> None: self._ready_event.set() # Dispatch snapshot callbacks if streaming - if self._streaming and homie.is_ready() and self._loop is not None: - if self._snapshot_interval <= 0: - # No debounce — dispatch immediately (backward compat) - self._create_dispatch_task() - elif self._snapshot_timer is None: - # Schedule debounced dispatch - self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot) + if self._streaming and homie.is_ready() and self._loop is not None and self._snapshot_timer is None: + self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot) def _on_connection_change(self, connected: bool) -> None: """Handle MQTT connection state change (called from asyncio loop). @@ -366,7 +368,7 @@ def _on_connection_change(self, connected: bool) -> None: try: cb(connected) except Exception: # pylint: disable=broad-exception-caught - _LOGGER.exception("Connection callback raised") + _LOGGER.warning("Connection callback raised", exc_info=True) async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. @@ -377,8 +379,8 @@ async def _wait_for_circuit_names(self, timeout: float) -> None: timeout elapses (non-fatal — entities will use fallback names). """ homie = self._require_homie() - deadline = asyncio.get_event_loop().time() + timeout - while asyncio.get_event_loop().time() < deadline: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: missing = homie.circuit_nodes_missing_names() if not missing: _LOGGER.debug("All circuit names received") @@ -419,8 +421,14 @@ def set_snapshot_interval(self, interval: float) -> None: """Update the snapshot debounce interval at runtime. Args: - interval: Seconds between snapshot dispatches. 0 = no debounce. + interval: Seconds between snapshot dispatches. Minimum 1.0s to + prevent per-message dispatch tasks from overwhelming subscribers. + + Raises: + ValueError: If ``interval`` is below the minimum. """ + if interval < _MIN_SNAPSHOT_INTERVAL_S: + raise ValueError(f"snapshot_interval must be >= {_MIN_SNAPSHOT_INTERVAL_S}s, got {interval}") self._snapshot_interval = interval # Cancel any pending timer so the new interval takes effect on next message self._cancel_snapshot_timer() diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 52f6838..1327502 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -13,9 +13,7 @@ from collections.abc import Callable from functools import partial import logging -from pathlib import Path import ssl -import tempfile from typing import TYPE_CHECKING import paho.mqtt.client as paho @@ -42,6 +40,20 @@ _LOGGER = logging.getLogger(__name__) +def _build_ssl_context(ca_pem: str) -> ssl.SSLContext: + """Build an SSLContext that trusts only the provided panel CA. + + The panel issues a private CA and a server cert signed by it. We do + not want to trust system CAs for this connection, so the context is + built fresh rather than via ``ssl.create_default_context()``. + """ + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.check_hostname = True + ctx.load_verify_locations(cadata=ca_pem) + return ctx + + class AsyncMqttBridge: """Event-loop-driven paho-mqtt wrapper with async callback dispatch. @@ -77,7 +89,6 @@ def __init__( self._connected = False self._client: AsyncMQTTClient | None = None self._connect_event: asyncio.Event | None = None - self._ca_cert_path: Path | None = None self._misc_timer: asyncio.TimerHandle | None = None self._should_reconnect = False @@ -117,96 +128,74 @@ async def connect(self) -> None: # Fetch CA cert from panel for TLS _LOGGER.debug("BRIDGE: Fetching CA cert from %s (use_tls=%s)", self._panel_host, self._use_tls) - ca_pem: str | None = None - ca_cert_path: Path | None = None + ssl_context: ssl.SSLContext | None = None if self._use_tls: try: ca_pem = await download_ca_cert(self._panel_host, port=self._panel_http_port) except (OSError, SpanPanelConnectionError, SpanPanelTimeoutError) as exc: raise SpanPanelConnectionError(f"Failed to fetch CA certificate from {self._panel_host}") from exc - - try: - self._client = AsyncMQTTClient( - callback_api_version=CallbackAPIVersion.VERSION2, - transport=self._transport, - reconnect_on_failure=False, + # Build the SSLContext from PEM data in memory — no temp file. + ssl_context = _build_ssl_context(ca_pem) + + self._client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + transport=self._transport, + reconnect_on_failure=False, + ) + self._client.setup() + + self._client.username_pw_set(self._username, self._password) + + # Wire socket callbacks (async versions by default) + self._client.on_socket_close = self._async_on_socket_close + self._client.on_socket_unregister_write = self._async_on_socket_unregister_write + + # Wire MQTT callbacks (run directly on event loop — no thread dispatch) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + if ssl_context is not None: + self._client.tls_set_context(ssl_context) + + # Connect in executor (blocking: DNS, TCP, TLS handshake). + # During executor connect, socket callbacks bridge to the event + # loop via call_soon_threadsafe. + def _blocking_connect() -> None: + if self._client is None: + raise RuntimeError("MQTT client not initialised before connect") + self._client.connect( + host=self._host, + port=self._port, + keepalive=MQTT_KEEPALIVE_S, ) - self._client.setup() - - self._client.username_pw_set(self._username, self._password) - - # Wire socket callbacks (async versions by default) - self._client.on_socket_close = self._async_on_socket_close - self._client.on_socket_unregister_write = self._async_on_socket_unregister_write - - # Wire MQTT callbacks (run directly on event loop — no thread dispatch) - self._client.on_connect = self._on_connect - self._client.on_disconnect = self._on_disconnect - self._client.on_message = self._on_message - - # TLS setup + connect in executor (blocking: temp file write, - # load_verify_locations, DNS, TCP, and TLS handshake). - # During executor connect, socket callbacks bridge to the event - # loop via call_soon_threadsafe. - def _blocking_tls_and_connect() -> None: - """Write CA cert to temp file, configure TLS, and connect.""" - nonlocal ca_cert_path - if self._client is None: - raise RuntimeError("MQTT client not initialised before connect") - if self._use_tls and ca_pem is not None: - tmp = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with # noqa: SIM115 - mode="w", suffix=".pem", delete=False - ) - tmp.write(ca_pem) - tmp.close() - ca_cert_path = Path(tmp.name) - self._client.tls_set( - ca_certs=str(ca_cert_path), - cert_reqs=ssl.CERT_REQUIRED, - tls_version=ssl.PROTOCOL_TLS_CLIENT, - ) - self._client.connect( - host=self._host, - port=self._port, - keepalive=MQTT_KEEPALIVE_S, - ) + try: + self._client.on_socket_open = self._on_socket_open_sync + self._client.on_socket_register_write = self._on_socket_register_write_sync + _LOGGER.debug("BRIDGE: Running connect in executor to %s:%s", self._host, self._port) try: - self._client.on_socket_open = self._on_socket_open_sync - self._client.on_socket_register_write = self._on_socket_register_write_sync - _LOGGER.debug("BRIDGE: Running TLS+connect in executor to %s:%s", self._host, self._port) - try: - await self._loop.run_in_executor(None, _blocking_tls_and_connect) - except OSError as exc: - raise SpanPanelConnectionError( - f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}" - ) from exc - _LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...") - finally: - # Switch to async-only socket callbacks now that we are - # back on the event loop thread. - self._client.on_socket_open = self._async_on_socket_open - self._client.on_socket_register_write = self._async_on_socket_register_write - - # Wait for CONNACK - try: - await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) - except asyncio.TimeoutError as exc: - await self.disconnect() - raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc - - if not self._connected: - raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}") + await self._loop.run_in_executor(None, _blocking_connect) + except OSError as exc: + raise SpanPanelConnectionError(f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}") from exc + _LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...") + finally: + # Switch to async-only socket callbacks now that we are + # back on the event loop thread. + self._client.on_socket_open = self._async_on_socket_open + self._client.on_socket_register_write = self._async_on_socket_register_write + + # Wait for CONNACK + try: + await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) + except asyncio.TimeoutError as exc: + await self.disconnect() + raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc - self._initial_connect_done = True - # Keep cert alive until disconnect — paho may reference it - self._ca_cert_path = ca_cert_path + if not self._connected: + raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}") - except Exception: - # Clean up temp CA cert file on failure only - if ca_cert_path is not None: - self._remove_cert_file(ca_cert_path) - raise + self._initial_connect_done = True async def disconnect(self) -> None: """Disconnect from the MQTT broker.""" @@ -229,25 +218,11 @@ async def disconnect(self) -> None: self._client = None self._initial_connect_done = False - cert_path = self._ca_cert_path - self._ca_cert_path = None - if cert_path is not None: - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, partial(self._remove_cert_file, cert_path)) - def subscribe(self, topic: str, qos: int = 0) -> None: """Subscribe to a topic. Must be called after connect().""" if self._client is not None: self._client.subscribe(topic, qos=qos) - @staticmethod - def _remove_cert_file(path: Path) -> None: - """Remove a temporary CA certificate file (safe to call from any thread).""" - try: - path.unlink() - except OSError: - _LOGGER.debug("Failed to remove temp CA cert file: %s", path) - def publish(self, topic: str, payload: str, qos: int = 1) -> None: """Publish a message. Must be called after connect().""" if self._client is not None: @@ -379,7 +354,10 @@ def _on_disconnect( ) -> None: """Handle disconnect from broker.""" self._connected = False - _LOGGER.debug("MQTT disconnected: %s", reason_code) + if reason_code.is_failure: + _LOGGER.warning("MQTT disconnected abnormally: %s", reason_code) + else: + _LOGGER.debug("MQTT disconnected: %s", reason_code) # Signal connect event if still waiting (socket closed before CONNACK) if self._connect_event is not None and not self._connect_event.is_set(): @@ -420,8 +398,11 @@ async def _reconnect_loop(self) -> None: self._client.on_socket_open = self._on_socket_open_sync self._client.on_socket_register_write = self._on_socket_register_write_sync await self._loop.run_in_executor(None, self._client.reconnect) - except OSError: - _LOGGER.debug("Reconnect failed, retrying in %ss", delay) + except Exception: # pylint: disable=broad-exception-caught + # paho can raise OSError, socket.gaierror, WebsocketConnectionError, + # ssl.SSLError, and others depending on transport. Never let the + # reconnect loop die — just log and keep backing off. + _LOGGER.warning("Reconnect failed, retrying in %ss", delay, exc_info=True) finally: if self._client is not None: self._client.on_socket_open = self._async_on_socket_open diff --git a/src/span_panel_api/mqtt/homie.py b/src/span_panel_api/mqtt/homie.py index ccd0776..88767e8 100644 --- a/src/span_panel_api/mqtt/homie.py +++ b/src/span_panel_api/mqtt/homie.py @@ -31,6 +31,11 @@ _LOGGER = logging.getLogger(__name__) +# Threshold below which grid power is considered "not exchanging" when no +# authoritative bess/grid-state is available. Real lugs readings never land +# exactly on 0.0; 1 W is well below sensor noise. +_GRID_POWER_EPSILON_W = 1.0 + def _parse_bool(value: str) -> bool: """Parse a Homie boolean string.""" @@ -230,9 +235,10 @@ def _build_circuit(self, node_id: str, device_type: str = "circuit", relative_po """Build a circuit snapshot from accumulated properties.""" circuit_id = normalize_circuit_id(node_id) - # active-power is in watts; negate so positive = consumption + # active-power is in watts; negate so positive = consumption. + # Guard against -0.0 creeping in when raw_power_w is 0.0. raw_power_w = _parse_float(self._acc.get_prop(node_id, "active-power")) - instant_power_w = -raw_power_w or 0.0 + instant_power_w = 0.0 if raw_power_w == 0.0 else -raw_power_w # Energy: exported-energy = consumption (panel exports TO circuit) consumed_wh = _parse_float(self._acc.get_prop(node_id, "exported-energy")) @@ -387,7 +393,9 @@ def _derive_dsm_state(self, core_node: str | None, grid_power: float, power_flow return "DSM_ON_GRID" if dps in ("BATTERY", "PV", "GENERATOR"): - grid_exchanging = grid_power != 0.0 or (power_flow_grid is not None and power_flow_grid != 0.0) + grid_exchanging = abs(grid_power) > _GRID_POWER_EPSILON_W or ( + power_flow_grid is not None and abs(power_flow_grid) > _GRID_POWER_EPSILON_W + ) return "DSM_ON_GRID" if grid_exchanging else "DSM_OFF_GRID" return "UNKNOWN" diff --git a/tests/conftest.py b/tests/conftest.py index 0e1af39..8a80b68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ import asyncio import json -import tempfile from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch @@ -109,14 +108,9 @@ def _reconnect() -> int: with ( patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls, patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"), - patch("span_panel_api.mqtt.connection.tempfile") as mock_tempfile, + patch("span_panel_api.mqtt.connection._build_ssl_context", return_value=MagicMock()), patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA), ): - # Make tempfile return a mock file object - mock_tmp = MagicMock() - mock_tmp.name = f"{tempfile.gettempdir()}/fake_ca.pem" - mock_tempfile.NamedTemporaryFile.return_value = mock_tmp - mock_client = cls.return_value mock_client.connect.side_effect = _connect mock_client.reconnect.side_effect = _reconnect diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index 4dc1b0e..581b5a6 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -176,7 +176,7 @@ def bad(_connected: bool) -> None: client.register_connection_callback(bad) client.register_connection_callback(good_calls.append) - with caplog.at_level(logging.ERROR): + with caplog.at_level(logging.WARNING): client._on_connection_change(True) assert good_calls == [True] diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 88cdc09..881ab34 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -68,7 +68,8 @@ async def test_connect_configures_tls(self, mqtt_client_mock: MagicMock) -> None bridge = _make_bridge() await bridge.connect() - mqtt_client_mock.tls_set.assert_called_once() + mqtt_client_mock.tls_set_context.assert_called_once() + mqtt_client_mock.tls_set.assert_not_called() @pytest.mark.asyncio async def test_connect_does_not_set_lwt(self, mqtt_client_mock: MagicMock) -> None: @@ -93,6 +94,7 @@ async def test_connect_no_tls(self, mqtt_client_mock: MagicMock) -> None: assert bridge.is_connected() is True mqtt_client_mock.tls_set.assert_not_called() + mqtt_client_mock.tls_set_context.assert_not_called() @pytest.mark.asyncio async def test_disconnect_after_connect(self, mqtt_client_mock: MagicMock) -> None: @@ -257,7 +259,7 @@ async def test_no_reconnect_before_initial_connect(self, mqtt_client_mock: Magic # --------------------------------------------------------------------------- -def _make_span_client(snapshot_interval: float = 0) -> SpanMqttClient: +def _make_span_client(snapshot_interval: float = 1.0) -> SpanMqttClient: config = MqttClientConfig( broker_host="broker.local", username="user", @@ -355,9 +357,14 @@ async def test_streaming_dispatches_snapshot(self, mqtt_client_mock: MagicMock) unregister = client.register_snapshot_callback(callback) await client.start_streaming() - # Trigger a property message while streaming + # Trigger a property message while streaming — timer scheduled client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/some-prop", "42") - await asyncio.sleep(0.05) + assert client._snapshot_timer is not None + + # Fire the debounce directly (default 1.0s interval would slow the test) + client._fire_snapshot() + await asyncio.sleep(0) + await asyncio.sleep(0) assert len(snapshots) > 0 callback.assert_called() diff --git a/tests/test_mqtt_debounce.py b/tests/test_mqtt_debounce.py index ae3df75..558937d 100644 --- a/tests/test_mqtt_debounce.py +++ b/tests/test_mqtt_debounce.py @@ -4,7 +4,7 @@ - Multiple rapid messages → single dispatch - Snapshot fires after configured interval - close() cancels pending timer -- interval=0 dispatches immediately (backward compat) +- interval < 1.0s raises ValueError - set_snapshot_interval() runtime changes """ @@ -45,12 +45,17 @@ async def _connect_client(client: SpanMqttClient, mqtt_client_mock: MagicMock) - class TestSnapshotDebounce: - """Test debounce timer behavior with snapshot_interval > 0.""" + """Test debounce timer behavior with snapshot_interval >= 1.0s. + + These tests drive the timer callback directly rather than waiting for + real wall-clock timers to fire — enforcing the 1.0s minimum would + otherwise make every test slow. + """ @pytest.mark.asyncio async def test_multiple_messages_single_dispatch(self, mqtt_client_mock: MagicMock) -> None: - """Multiple rapid MQTT messages should produce only one snapshot dispatch.""" - client = _make_client(snapshot_interval=0.2) + """Multiple rapid MQTT messages should schedule only one timer.""" + client = _make_client(snapshot_interval=1.0) await _connect_client(client, mqtt_client_mock) snapshots: list[object] = [] @@ -62,11 +67,14 @@ async def test_multiple_messages_single_dispatch(self, mqtt_client_mock: MagicMo for i in range(10): client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", str(i * 100)) - # Before timer fires: no snapshots yet + # Before timer fires: no snapshots yet, but a single timer exists assert len(snapshots) == 0 + assert client._snapshot_timer is not None - # Wait for debounce timer to fire - await asyncio.sleep(0.35) + # Fire the debounce directly + client._fire_snapshot() + await asyncio.sleep(0) + await asyncio.sleep(0) # Exactly one snapshot dispatched assert len(snapshots) == 1 @@ -76,9 +84,9 @@ async def test_multiple_messages_single_dispatch(self, mqtt_client_mock: MagicMo await client.close() @pytest.mark.asyncio - async def test_snapshot_fires_after_interval(self, mqtt_client_mock: MagicMock) -> None: - """Snapshot dispatches after the configured interval, not immediately.""" - client = _make_client(snapshot_interval=0.3) + async def test_snapshot_does_not_fire_before_interval(self, mqtt_client_mock: MagicMock) -> None: + """Snapshot is only scheduled, not dispatched, until the timer fires.""" + client = _make_client(snapshot_interval=1.0) await _connect_client(client, mqtt_client_mock) snapshots: list[object] = [] @@ -88,12 +96,15 @@ async def test_snapshot_fires_after_interval(self, mqtt_client_mock: MagicMock) client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "1000") - # Not yet fired at half the interval - await asyncio.sleep(0.15) + # Timer scheduled but not yet fired + assert client._snapshot_timer is not None assert len(snapshots) == 0 - # Fired after full interval - await asyncio.sleep(0.25) + # Drive the timer + client._fire_snapshot() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert len(snapshots) == 1 await client.stop_streaming() @@ -147,7 +158,7 @@ async def test_stop_streaming_cancels_timer(self, mqtt_client_mock: MagicMock) - @pytest.mark.asyncio async def test_second_batch_after_timer_fires(self, mqtt_client_mock: MagicMock) -> None: """A new batch of messages after timer fires should start a new timer.""" - client = _make_client(snapshot_interval=0.15) + client = _make_client(snapshot_interval=1.0) await _connect_client(client, mqtt_client_mock) snapshots: list[object] = [] @@ -157,66 +168,42 @@ async def test_second_batch_after_timer_fires(self, mqtt_client_mock: MagicMock) # First batch client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") - await asyncio.sleep(0.25) + assert client._snapshot_timer is not None + client._fire_snapshot() + await asyncio.sleep(0) + await asyncio.sleep(0) assert len(snapshots) == 1 + assert client._snapshot_timer is None # Second batch client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "200") - await asyncio.sleep(0.25) + assert client._snapshot_timer is not None + client._fire_snapshot() + await asyncio.sleep(0) + await asyncio.sleep(0) assert len(snapshots) == 2 await client.stop_streaming() await client.close() -class TestSnapshotNoDebounce: - """Test interval=0 preserves immediate dispatch behavior.""" - - @pytest.mark.asyncio - async def test_zero_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: - """interval=0 should dispatch a snapshot for every message (no debounce).""" - client = _make_client(snapshot_interval=0) - await _connect_client(client, mqtt_client_mock) - - snapshots: list[object] = [] - callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) - client.register_snapshot_callback(callback) - await client.start_streaming() - - # Each message should trigger an immediate dispatch task - client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") - client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "200") - client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "300") - - # Let tasks complete - await asyncio.sleep(0.1) - - # Three separate dispatches - assert len(snapshots) == 3 - assert client._snapshot_timer is None - - await client.stop_streaming() - await client.close() - - @pytest.mark.asyncio - async def test_negative_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: - """Negative interval should behave like 0 (no debounce).""" - client = _make_client(snapshot_interval=-1.0) - await _connect_client(client, mqtt_client_mock) - - snapshots: list[object] = [] - callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) - client.register_snapshot_callback(callback) - await client.start_streaming() +class TestSnapshotIntervalValidation: + """Test that sub-minimum intervals are rejected.""" - client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") - await asyncio.sleep(0.05) + def test_zero_interval_rejected_in_init(self) -> None: + """interval=0 must raise ValueError at construction.""" + with pytest.raises(ValueError, match="snapshot_interval must be >="): + _make_client(snapshot_interval=0) - assert len(snapshots) == 1 - assert client._snapshot_timer is None + def test_negative_interval_rejected_in_init(self) -> None: + """Negative interval must raise ValueError at construction.""" + with pytest.raises(ValueError, match="snapshot_interval must be >="): + _make_client(snapshot_interval=-1.0) - await client.stop_streaming() - await client.close() + def test_sub_minimum_interval_rejected_in_init(self) -> None: + """Interval below 1.0s must raise ValueError at construction.""" + with pytest.raises(ValueError, match="snapshot_interval must be >="): + _make_client(snapshot_interval=0.5) class TestSetSnapshotInterval: @@ -243,23 +230,20 @@ async def test_set_snapshot_interval_cancels_timer(self, mqtt_client_mock: Magic await client.close() @pytest.mark.asyncio - async def test_set_interval_to_zero_switches_to_immediate(self, mqtt_client_mock: MagicMock) -> None: - """Changing from debounce to zero should switch to immediate dispatch.""" + async def test_set_interval_rejects_sub_minimum(self, mqtt_client_mock: MagicMock) -> None: + """set_snapshot_interval() must reject values below 1.0s.""" client = _make_client(snapshot_interval=2.0) await _connect_client(client, mqtt_client_mock) - snapshots: list[object] = [] - callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) - client.register_snapshot_callback(callback) - await client.start_streaming() - - # Switch to immediate mode - client.set_snapshot_interval(0) + with pytest.raises(ValueError, match="snapshot_interval must be >="): + client.set_snapshot_interval(0) + with pytest.raises(ValueError, match="snapshot_interval must be >="): + client.set_snapshot_interval(-1.0) + with pytest.raises(ValueError, match="snapshot_interval must be >="): + client.set_snapshot_interval(0.5) - client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") - await asyncio.sleep(0.05) - - assert len(snapshots) == 1 + # Interval remains unchanged + assert client._snapshot_interval == 2.0 await client.stop_streaming() await client.close() From bb79299576aa5d925748c0c044ee16cf5161a7a8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:07:28 -0700 Subject: [PATCH 3/7] fix(auth,exceptions): clearer return types and exception __str__ - get_fqdn() returns str | None so callers can distinguish "no FQDN configured" (HTTP 404 or missing ebusTlsFqdn field) from an explicit empty string. Callers that treated "" as "not registered" must now check for None. - SpanPanelAPIError: remove custom __str__ override that silently truncated args[1:]; the default Exception.__str__ is more useful. - register_v2(): docstring warns that each call accumulates a new registered-client entry on the panel, so callers should persist and reuse the returned V2AuthResponse rather than re-registering on every restart. --- src/span_panel_api/auth.py | 19 +++++++++++++++---- src/span_panel_api/exceptions.py | 3 --- tests/test_detection_auth.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index 6fe4454..4df1a89 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -47,6 +47,12 @@ async def register_v2( If ``passphrase`` is provided, it is sent as ``hopPassphrase``; omitting it enables door-bypass registration. + .. note:: + Every call creates a new registered client entry on the panel. Callers + should persist and reuse the returned ``V2AuthResponse`` rather than + re-registering on every restart — otherwise stale entries will + accumulate over the panel's lifetime. + Args: host: IP address or hostname of the SPAN Panel name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended @@ -310,7 +316,7 @@ async def get_fqdn( timeout: float = 10.0, port: int = 80, httpx_client: httpx.AsyncClient | None = None, -) -> str: +) -> str | None: """Retrieve the currently registered FQDN from the SPAN Panel. Args: @@ -321,7 +327,9 @@ async def get_fqdn( httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function. Returns: - The registered FQDN, or empty string if none is configured + The registered FQDN string, or ``None`` when no FQDN is configured + (HTTP 404 or missing ``ebusTlsFqdn`` field). An empty string is only + returned when the panel reports an explicit empty FQDN value. Raises: SpanPanelAuthError: Token invalid or expired @@ -344,13 +352,16 @@ async def get_fqdn( raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code})") if response.status_code == 404: - return "" + return None if response.status_code != 200: raise SpanPanelAPIError(f"Failed to get FQDN: HTTP {response.status_code}") data: dict[str, object] = response.json() - return _str(data.get("ebusTlsFqdn")) + raw = data.get("ebusTlsFqdn") + if raw is None: + return None + return str(raw) async def delete_fqdn( diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 6681ef4..24f1a56 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -28,9 +28,6 @@ def __init__(self, message: str, status_code: int | None = None) -> None: super().__init__(message) self.status_code = status_code - def __str__(self) -> str: - return self.args[0] if self.args else "" - class SpanPanelServerError(SpanPanelAPIError): """Server error (500).""" diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 6110732..8131bb5 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -668,7 +668,7 @@ async def test_get_fqdn_success(self): assert result == "panel.example.com" @pytest.mark.asyncio - async def test_get_fqdn_not_configured_returns_empty(self): + async def test_get_fqdn_not_configured_returns_none(self): mock_response = _mock_response(404) with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() @@ -679,6 +679,34 @@ async def test_get_fqdn_not_configured_returns_empty(self): result = await get_fqdn("192.168.65.70", "jwt-token") + assert result is None + + @pytest.mark.asyncio + async def test_get_fqdn_missing_field_returns_none(self): + mock_response = _mock_response(200, {}) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_fqdn("192.168.65.70", "jwt-token") + + assert result is None + + @pytest.mark.asyncio + async def test_get_fqdn_empty_string_preserved(self): + mock_response = _mock_response(200, {"ebusTlsFqdn": ""}) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_fqdn("192.168.65.70", "jwt-token") + assert result == "" @pytest.mark.asyncio From 42f386bf27de34200226a70fb14aa4bf9aed7812 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:07:43 -0700 Subject: [PATCH 4/7] feat(async_client): verify paho lock layout at import NullLock monkey-patches a hardcoded list of paho-mqtt's internal *_mutex attributes. A paho minor-version refactor that renames or adds locks would silently break the override. Add a bidirectional check at module import: every attribute we list must exist on paho.Client, and no other *_mutex attribute may be present. Raise RuntimeError on drift (not assert, so python -O does not bypass it). --- src/span_panel_api/mqtt/async_client.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/span_panel_api/mqtt/async_client.py b/src/span_panel_api/mqtt/async_client.py index ee6455b..d6532a8 100644 --- a/src/span_panel_api/mqtt/async_client.py +++ b/src/span_panel_api/mqtt/async_client.py @@ -11,6 +11,7 @@ from types import TracebackType from paho.mqtt.client import Client as MQTTClient +from paho.mqtt.enums import CallbackAPIVersion _PAHO_LOCK_ATTRS = ( "_in_callback_mutex", @@ -23,6 +24,29 @@ ) +def _verify_paho_lock_attrs() -> None: + """Verify paho-mqtt's lock layout matches the list we monkey-patch. + + Runs once at import. Raises ``RuntimeError`` if any expected attribute + is missing (paho renamed/removed one) or if paho grew a new lock we + don't yet patch. Running ``python -O`` does not bypass this check. + """ + probe = MQTTClient(callback_api_version=CallbackAPIVersion.VERSION2) + expected = set(_PAHO_LOCK_ATTRS) + found = {name for name in vars(probe) if name.endswith("_mutex")} + missing = expected - found + extra = found - expected + if missing or extra: + raise RuntimeError( + "paho-mqtt lock attributes changed — NullLock monkey-patch is out of date. " + f"missing={sorted(missing)}, extra={sorted(extra)}. " + "Update _PAHO_LOCK_ATTRS in span_panel_api.mqtt.async_client." + ) + + +_verify_paho_lock_attrs() + + class NullLock: """No-op lock for single-threaded event loop execution. From 4f4cafcd87f3b5103ed5479ab42d461216d20217 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:08:07 -0700 Subject: [PATCH 5/7] docs: CHANGELOG for 2.6.1 and drop stale simulation references - Record 2.6.1 changes in CHANGELOG. - Remove "MQTT and simulation transports" / "REST polling or MQTT push" wording from protocol.py and models.py module docstrings. The simulation and REST transports were removed in 2.0.0. --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ src/span_panel_api/models.py | 6 +++--- src/span_panel_api/protocol.py | 6 +++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a2c26..795fc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.1] - 04/2026 + +### Changed + +- **`snapshot_interval` minimum enforced at 1.0s** — `SpanMqttClient(snapshot_interval=...)` and `set_snapshot_interval()` now raise `ValueError` for values below 1.0 (including 0 and negatives). Previously, sub-second or zero values switched the client + into a per-message dispatch mode with no back-pressure, which could overwhelm subscribers on a busy broker. The `<= 0` "immediate dispatch" code path has been removed. +- **`get_fqdn()` returns `str | None`** — `None` now distinguishes "no FQDN configured" (HTTP 404 or missing field) from an explicit empty string. Callers that treated `""` as "not registered" must update to check for `None`. +- **Connection callback errors logged at WARNING** — `SpanMqttClient._on_connection_change` now logs callback exceptions via `_LOGGER.warning(..., exc_info=True)` instead of `_LOGGER.exception(...)`, consistent with `_dispatch_snapshot`. +- **Reconnect loop catches all exceptions** — `AsyncMqttBridge._reconnect_loop` no longer silently drops on non-`OSError` failures (e.g. `WebsocketConnectionError`, `ssl.SSLError`). All exceptions are logged at WARNING and the loop keeps backing off. +- **Abnormal MQTT disconnects logged at WARNING** — disconnects where `reason_code.is_failure` is true now log at WARNING; clean disconnects continue to log at DEBUG. + +### Fixed + +- **CA certificate no longer written to disk** — `AsyncMqttBridge.connect()` builds the `ssl.SSLContext` from the fetched PEM via `cadata`, eliminating the temp-file lifecycle (and the small leak window on unexpected process exit) that the prior + `tls_set(ca_certs=path)` path required. +- **Deprecated `asyncio.get_event_loop()` removed** — `_wait_for_circuit_names` now uses `time.monotonic()`. The previous code emitted a `DeprecationWarning` on Python 3.12+. +- **Negative-zero on circuit `instant_power_w`** — explicit guard replaces a cryptic `-raw or 0.0` idiom in `HomieDeviceConsumer._build_circuit`. +- **DSM grid-exchanging heuristic uses epsilon** — replaces `!= 0.0` float comparison with `abs(x) > 1.0 W`, so the `DSM_OFF_GRID` branch is actually reachable when no BESS is commissioned and lugs readings hover near zero. +- **`SpanPanelAPIError.__str__` override removed** — the override silently hid exception args beyond the first; default `Exception.__str__` is now used. +- **Paho lock-layout check at import** — `span_panel_api.mqtt.async_client` verifies on import that the `_PAHO_LOCK_ATTRS` list exactly matches paho's `*_mutex` attributes. Raises `RuntimeError` (not `assert`, so `python -O` does not bypass it) on drift. + +### Documentation + +- **`register_v2()`** — docstring now warns that each call creates a new client entry on the panel; callers should persist and reuse the returned `V2AuthResponse` rather than re-registering on every restart. +- **Stale simulation transport references removed** from `protocol.py` and `models.py` module docstrings. + ## [2.6.0] - 04/2026 ### Added diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 4279f5c..03d8368 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -1,8 +1,8 @@ """Transport-agnostic snapshot models for SPAN Panel state. -These dataclasses represent panel state regardless of how it was obtained -(REST polling or MQTT push). Energy and power sign conventions are -normalized at the transport boundary — consumers see a consistent view. +These dataclasses represent panel state as produced by the MQTT/Homie +transport. Energy and power sign conventions are normalized at the +transport boundary — consumers see a consistent view. All snapshots are immutable (frozen) and memory-efficient (slots). """ diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 90ced1e..11f7e97 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -1,8 +1,8 @@ """Protocol interfaces for SPAN Panel API transports. -Defines structural subtyping contracts (PEP 544) that both MQTT and -simulation transports implement. The integration codes against these -protocols — never against transport-specific classes. +Defines structural subtyping contracts (PEP 544) that the MQTT transport +implements. The integration codes against these protocols — never against +transport-specific classes. """ from __future__ import annotations From cbae478fda07045b0abf1ebfa11e30cc24687c08 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:24:58 -0700 Subject: [PATCH 6/7] fix(bridge): wrap SSL and paho-connect errors in SpanPanelConnectionError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect() documents SpanPanelConnectionError / SpanPanelTimeoutError as its only raises. Two paths leaked unexpected exception types to callers: - _build_ssl_context(ca_pem) can raise ssl.SSLError or ValueError when the panel returns a malformed CA PEM. - The executor connect wrapper caught only OSError, but paho raises transport-specific failures that do not inherit from OSError — notably WebsocketConnectionError when transport='websockets'. Both are now wrapped in SpanPanelConnectionError with context. --- src/span_panel_api/mqtt/connection.py | 13 +++++++++++-- tests/test_mqtt_connect_flow.py | 27 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 1327502..bea9df9 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -135,7 +135,12 @@ async def connect(self) -> None: except (OSError, SpanPanelConnectionError, SpanPanelTimeoutError) as exc: raise SpanPanelConnectionError(f"Failed to fetch CA certificate from {self._panel_host}") from exc # Build the SSLContext from PEM data in memory — no temp file. - ssl_context = _build_ssl_context(ca_pem) + # A malformed PEM raises ssl.SSLError or ValueError; wrap both + # so callers only see the documented SpanPanelConnectionError. + try: + ssl_context = _build_ssl_context(ca_pem) + except (ssl.SSLError, ValueError) as exc: + raise SpanPanelConnectionError(f"Failed to build SSL context for {self._panel_host}") from exc self._client = AsyncMQTTClient( callback_api_version=CallbackAPIVersion.VERSION2, @@ -176,7 +181,11 @@ def _blocking_connect() -> None: _LOGGER.debug("BRIDGE: Running connect in executor to %s:%s", self._host, self._port) try: await self._loop.run_in_executor(None, _blocking_connect) - except OSError as exc: + except Exception as exc: # pylint: disable=broad-exception-caught + # paho raises OSError for TCP failures and transport-specific + # errors (e.g. WebsocketConnectionError) that do not inherit + # from OSError. Wrap all of them uniformly so callers only + # see the documented SpanPanelConnectionError. raise SpanPanelConnectionError(f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}") from exc _LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...") finally: diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 881ab34..7536348 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -7,12 +7,14 @@ from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, MagicMock +import ssl +from unittest.mock import AsyncMock, MagicMock, patch import pytest from paho.mqtt.client import ConnectFlags, DisconnectFlags, MQTTMessage from paho.mqtt.reasoncodes import ReasonCode +from span_panel_api.exceptions import SpanPanelConnectionError from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.const import MQTT_RECONNECT_MIN_DELAY_S @@ -96,6 +98,29 @@ async def test_connect_no_tls(self, mqtt_client_mock: MagicMock) -> None: mqtt_client_mock.tls_set.assert_not_called() mqtt_client_mock.tls_set_context.assert_not_called() + @pytest.mark.asyncio + async def test_malformed_ca_pem_raises_connection_error(self, mqtt_client_mock: MagicMock) -> None: + """Malformed CA PEM must surface as SpanPanelConnectionError, not ssl.SSLError.""" + bridge = _make_bridge() + with patch( + "span_panel_api.mqtt.connection._build_ssl_context", + side_effect=ssl.SSLError("malformed PEM"), + ): + with pytest.raises(SpanPanelConnectionError, match="Failed to build SSL context"): + await bridge.connect() + + @pytest.mark.asyncio + async def test_non_oserror_connect_failure_wrapped(self, mqtt_client_mock: MagicMock) -> None: + """Non-OSError from paho.connect() (e.g. WebsocketConnectionError) wraps cleanly.""" + bridge = _make_bridge() + + class _FakeWebsocketError(Exception): + pass + + mqtt_client_mock.connect.side_effect = _FakeWebsocketError("ws handshake failed") + with pytest.raises(SpanPanelConnectionError, match="Cannot connect to MQTT broker"): + await bridge.connect() + @pytest.mark.asyncio async def test_disconnect_after_connect(self, mqtt_client_mock: MagicMock) -> None: bridge = _make_bridge() From a7704fd991817e5e8b2090f83454e8aca3b1ff54 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:29:40 -0700 Subject: [PATCH 7/7] revert(client): restore snapshot_interval=0 real-time dispatch The 1.0s floor was based on a misread of the integration constraint: the integration can be configured with scan interval 0 for real-time updates, so the library must not reject it. Restore the <=0 immediate-dispatch path and drop the ValueError validation from both __init__ and set_snapshot_interval(). Tests updated to cover real-time dispatch for interval=0 and interval<0. CHANGELOG and README reverted. --- CHANGELOG.md | 2 - README.md | 5 +- src/span_panel_api/mqtt/client.py | 26 ++++------ tests/test_mqtt_debounce.py | 84 ++++++++++++++++++++++--------- 4 files changed, 74 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 795fc75..892585b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- **`snapshot_interval` minimum enforced at 1.0s** — `SpanMqttClient(snapshot_interval=...)` and `set_snapshot_interval()` now raise `ValueError` for values below 1.0 (including 0 and negatives). Previously, sub-second or zero values switched the client - into a per-message dispatch mode with no back-pressure, which could overwhelm subscribers on a busy broker. The `<= 0` "immediate dispatch" code path has been removed. - **`get_fqdn()` returns `str | None`** — `None` now distinguishes "no FQDN configured" (HTTP 404 or missing field) from an explicit empty string. Callers that treated `""` as "not registered" must update to check for `None`. - **Connection callback errors logged at WARNING** — `SpanMqttClient._on_connection_change` now logs callback exceptions via `_LOGGER.warning(..., exc_info=True)` instead of `_LOGGER.exception(...)`, consistent with `_dispatch_snapshot`. - **Reconnect loop catches all exceptions** — `AsyncMqttBridge._reconnect_loop` no longer silently drops on non-`OSError` failures (e.g. `WebsocketConnectionError`, `ssl.SSLError`). All exceptions are logged at WARNING and the loop keeps backing off. diff --git a/README.md b/README.md index d881da7..2a95b7a 100644 --- a/README.md +++ b/README.md @@ -235,11 +235,14 @@ await client.connect() `set_snapshot_interval()` controls how often push-mode snapshot callbacks fire. Lower values mean lower latency; higher values reduce CPU usage on constrained hardware. Dirty-node caching (v2.5.0) further reduces per-scan cost by skipping unchanged nodes. -The minimum is **1.0 seconds**; passing a smaller value (including 0) raises `ValueError`. This protects subscribers from unbounded dispatch on a busy broker. +Passing `0` (or any non-positive value) disables debounce and dispatches a snapshot for every incoming property message — real-time mode, intended for fast consumers. ```python # Reduce snapshot frequency to every 2 seconds client.set_snapshot_interval(2.0) + +# Real-time dispatch — every property update triggers a callback +client.set_snapshot_interval(0) ``` ### Circuit Control diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index c061eae..2d0e89a 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -31,10 +31,6 @@ _CIRCUIT_NAMES_TIMEOUT_S = 10.0 _CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25 -# Minimum debounce interval. Prevents unbounded per-message dispatch tasks -# that could overwhelm subscribers on a busy broker. -_MIN_SNAPSHOT_INTERVAL_S = 1.0 - class SpanMqttClient: """MQTT transport — implements all span-panel-api protocols.""" @@ -47,8 +43,6 @@ def __init__( snapshot_interval: float = 1.0, panel_http_port: int = 80, ) -> None: - if snapshot_interval < _MIN_SNAPSHOT_INTERVAL_S: - raise ValueError(f"snapshot_interval must be >= {_MIN_SNAPSHOT_INTERVAL_S}s, got {snapshot_interval}") self._host = host self._serial_number = serial_number self._broker_config = broker_config @@ -329,8 +323,13 @@ def _on_message(self, topic: str, payload: str) -> None: self._ready_event.set() # Dispatch snapshot callbacks if streaming - if self._streaming and homie.is_ready() and self._loop is not None and self._snapshot_timer is None: - self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot) + if self._streaming and homie.is_ready() and self._loop is not None: + if self._snapshot_interval <= 0: + # Real-time mode — dispatch immediately, no debounce. + self._create_dispatch_task() + elif self._snapshot_timer is None: + # Schedule debounced dispatch + self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot) def _on_connection_change(self, connected: bool) -> None: """Handle MQTT connection state change (called from asyncio loop). @@ -421,14 +420,11 @@ def set_snapshot_interval(self, interval: float) -> None: """Update the snapshot debounce interval at runtime. Args: - interval: Seconds between snapshot dispatches. Minimum 1.0s to - prevent per-message dispatch tasks from overwhelming subscribers. - - Raises: - ValueError: If ``interval`` is below the minimum. + interval: Seconds between snapshot dispatches. ``0`` (or any + non-positive value) disables debounce and dispatches a + snapshot for every incoming property message — real-time + mode, intended for fast consumers. """ - if interval < _MIN_SNAPSHOT_INTERVAL_S: - raise ValueError(f"snapshot_interval must be >= {_MIN_SNAPSHOT_INTERVAL_S}s, got {interval}") self._snapshot_interval = interval # Cancel any pending timer so the new interval takes effect on next message self._cancel_snapshot_timer() diff --git a/tests/test_mqtt_debounce.py b/tests/test_mqtt_debounce.py index 558937d..cf26aaf 100644 --- a/tests/test_mqtt_debounce.py +++ b/tests/test_mqtt_debounce.py @@ -4,7 +4,7 @@ - Multiple rapid messages → single dispatch - Snapshot fires after configured interval - close() cancels pending timer -- interval < 1.0s raises ValueError +- interval <= 0 dispatches immediately (real-time mode) - set_snapshot_interval() runtime changes """ @@ -187,23 +187,54 @@ async def test_second_batch_after_timer_fires(self, mqtt_client_mock: MagicMock) await client.close() -class TestSnapshotIntervalValidation: - """Test that sub-minimum intervals are rejected.""" +class TestSnapshotRealtimeMode: + """interval <= 0 disables debounce for real-time dispatch.""" - def test_zero_interval_rejected_in_init(self) -> None: - """interval=0 must raise ValueError at construction.""" - with pytest.raises(ValueError, match="snapshot_interval must be >="): - _make_client(snapshot_interval=0) + @pytest.mark.asyncio + async def test_zero_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: + """interval=0 should dispatch a snapshot for every message.""" + client = _make_client(snapshot_interval=0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "200") + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "300") + + # Let dispatch tasks complete + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert len(snapshots) == 3 + assert client._snapshot_timer is None + + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_negative_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: + """Negative interval should behave like 0 (no debounce).""" + client = _make_client(snapshot_interval=-1.0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() - def test_negative_interval_rejected_in_init(self) -> None: - """Negative interval must raise ValueError at construction.""" - with pytest.raises(ValueError, match="snapshot_interval must be >="): - _make_client(snapshot_interval=-1.0) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + await asyncio.sleep(0) + await asyncio.sleep(0) - def test_sub_minimum_interval_rejected_in_init(self) -> None: - """Interval below 1.0s must raise ValueError at construction.""" - with pytest.raises(ValueError, match="snapshot_interval must be >="): - _make_client(snapshot_interval=0.5) + assert len(snapshots) == 1 + assert client._snapshot_timer is None + + await client.stop_streaming() + await client.close() class TestSetSnapshotInterval: @@ -230,20 +261,23 @@ async def test_set_snapshot_interval_cancels_timer(self, mqtt_client_mock: Magic await client.close() @pytest.mark.asyncio - async def test_set_interval_rejects_sub_minimum(self, mqtt_client_mock: MagicMock) -> None: - """set_snapshot_interval() must reject values below 1.0s.""" + async def test_set_interval_to_zero_switches_to_immediate(self, mqtt_client_mock: MagicMock) -> None: + """set_snapshot_interval(0) switches to real-time dispatch.""" client = _make_client(snapshot_interval=2.0) await _connect_client(client, mqtt_client_mock) - with pytest.raises(ValueError, match="snapshot_interval must be >="): - client.set_snapshot_interval(0) - with pytest.raises(ValueError, match="snapshot_interval must be >="): - client.set_snapshot_interval(-1.0) - with pytest.raises(ValueError, match="snapshot_interval must be >="): - client.set_snapshot_interval(0.5) + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + client.set_snapshot_interval(0) - # Interval remains unchanged - assert client._snapshot_interval == 2.0 + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert len(snapshots) == 1 await client.stop_streaming() await client.close()