codecraftai-interop-ext-bluetooth 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codecraftai_interop_ext_bluetooth-1.2.0/LICENSE.md +204 -0
- codecraftai_interop_ext_bluetooth-1.2.0/PKG-INFO +84 -0
- codecraftai_interop_ext_bluetooth-1.2.0/README.rst +64 -0
- codecraftai_interop_ext_bluetooth-1.2.0/pyproject.toml +60 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/__init__.py +13 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothcoordinator.py +137 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothdevice.py +69 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothpresence.py +222 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothsearchwindow.py +277 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/deviceevidence.py +71 -0
- codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/py.typed +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Code Craft AI - Source Available License
|
|
2
|
+
Version 1.0, October 2026
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026, Code Craft AI LLC
|
|
5
|
+
|
|
6
|
+
Everyone is permitted to copy and distribute verbatim copies of *This License* document, but changing it is not allowed.
|
|
7
|
+
|
|
8
|
+
*This License* covers the Python Automation Kit, the full suite of software published by *Licensor* that consists of every `codecraftai-*` package, every `pychestrate` package, and the `pychestrate` server.
|
|
9
|
+
|
|
10
|
+
*The Software* is source available. Its source code is published so that anyone can read it, learn from it, and use it, but *This License* is not an open source license. *The Software* is free to use for *Non-Commercial Use*. *Commercial Use* is permitted in exchange for a *Usage Fee* that is based on a reasonable estimation of the income derived from *The Software*, as described in section 5. When a company migrates a product or service to *The Software* from another code base, the *Profit* from that product or service is not counted toward the *Usage Fee* for two years, as described in section 5.4. For assurance of costs, a company can disclose its usage and estimated returns to *Licensor* and negotiate a fixed license cost, as described in section 5.10.
|
|
11
|
+
|
|
12
|
+
## 1. Definitions
|
|
13
|
+
|
|
14
|
+
### 1.1 *This License* or *The License*
|
|
15
|
+
**This License** or **The License** refers to version 1.0 of the Code Craft AI - Source Available License.
|
|
16
|
+
|
|
17
|
+
### 1.2 Licensor
|
|
18
|
+
**Licensor** refers to Code Craft AI LLC.
|
|
19
|
+
|
|
20
|
+
### 1.3 The Software
|
|
21
|
+
**The Software** refers to the Python Automation Kit, the full suite of software that *Licensor* publishes under *This License*. The Python Automation Kit consists of the following components (each, a **Component**), together with their source code, documentation, and other materials:
|
|
22
|
+
|
|
23
|
+
1. Every Python package published by *Licensor* whose name begins with `codecraftai-`
|
|
24
|
+
2. Every Python package published by *Licensor* whose name begins with `pychestrate`
|
|
25
|
+
3. The `pychestrate` server
|
|
26
|
+
|
|
27
|
+
### 1.4 You
|
|
28
|
+
**You** refers to the individual or legal entity that exercises rights under *This License*. When an individual exercises rights under *This License* on behalf of a company, organization, or other legal entity, **You** refers to that entity.
|
|
29
|
+
|
|
30
|
+
### 1.5 Use
|
|
31
|
+
**Use** means to install, execute, load, access, operate, display, copy, modify, configure, customize, and otherwise use.
|
|
32
|
+
|
|
33
|
+
### 1.6 Non-Commercial Use
|
|
34
|
+
**Non-Commercial Use** means any *Use* of *The Software* that is not *Commercial Use*. Examples of *Non-Commercial Use* include personal use, hobby projects, study, teaching, academic research, evaluation, and use by a non-profit organization for its non-profit purposes.
|
|
35
|
+
|
|
36
|
+
### 1.7 Commercial Use
|
|
37
|
+
**Commercial Use** means any *Use* of *The Software* by or on behalf of a for-profit entity, and any *Use* of *The Software* in connection with a product, service, or activity that generates revenue. *Commercial Use* includes, without limitation, *Use* of *The Software* to develop, build, test, validate, deploy, operate, or support commercial products and services, and *Use* of *The Software* to provide services to others.
|
|
38
|
+
|
|
39
|
+
### 1.8 Commercial Activity
|
|
40
|
+
**Commercial Activity** means the products, services, and business operations in connection with which *You* make *Commercial Use* of *The Software*.
|
|
41
|
+
|
|
42
|
+
### 1.9 Fiscal Year
|
|
43
|
+
**Fiscal Year** means the annual accounting period that *You* use for financial reporting.
|
|
44
|
+
|
|
45
|
+
### 1.10 Profit
|
|
46
|
+
**Profit** means the net profit that *You* earn from the *Commercial Activity* during a *Fiscal Year*, determined under the accounting principles that *You* regularly use for financial reporting.
|
|
47
|
+
|
|
48
|
+
### 1.11 Contribution Estimate
|
|
49
|
+
**Contribution Estimate** means *Your* reasonable, good faith estimate of the portion of the *Profit* that *The Software* contributed to, expressed as a number from 0 to 1.
|
|
50
|
+
|
|
51
|
+
### 1.12 Derived Income
|
|
52
|
+
**Derived Income** means the portion of the *Profit* for a *Fiscal Year* that is derived from *The Software*, calculated as described in section 5.2.
|
|
53
|
+
|
|
54
|
+
### 1.13 Usage Fee
|
|
55
|
+
**Usage Fee** means the yearly fee for *Commercial Use* that is set by the table in section 5.3.
|
|
56
|
+
|
|
57
|
+
### 1.14 Migration
|
|
58
|
+
**Migration** means moving the work for one of *Your* products or services from a code base other than *The Software*, such as another framework, another library, or *Your* own code, to *The Software*.
|
|
59
|
+
|
|
60
|
+
### 1.15 Migration Period
|
|
61
|
+
**Migration Period** means the two (2) year period, described in section 5.4, during which the *Profit* from a product or service that *You* are migrating to *The Software* is not counted.
|
|
62
|
+
|
|
63
|
+
## 2. License Grant
|
|
64
|
+
|
|
65
|
+
Subject to the terms and conditions of *This License*, *Licensor* hereby grants to *You* a non-exclusive, worldwide, non-transferable license to *Use*, copy, modify, create derivative works of, and distribute *The Software*.
|
|
66
|
+
|
|
67
|
+
*Licensor* also grants to *You* a license under any patents that *Licensor* can license, and that are necessarily infringed by *The Software*, for the same purposes and subject to the same conditions as the license granted above.
|
|
68
|
+
|
|
69
|
+
*This License* covers the Python Automation Kit as a whole. *Use* of one, some, or all of the *Components* is *Use* of *The Software*.
|
|
70
|
+
|
|
71
|
+
The licenses granted by *This License* are free of charge for *Non-Commercial Use*. The licenses granted by *This License* for *Commercial Use* are conditioned on the payment of the *Usage Fee* described in section 5.
|
|
72
|
+
|
|
73
|
+
## 3. Conditions
|
|
74
|
+
|
|
75
|
+
### 3.1 License Notice
|
|
76
|
+
Every copy of *The Software* that *You* distribute, in original or modified form, must include a complete copy of *This License* and must keep all copyright, patent, trademark, and attribution notices that appear in *The Software*.
|
|
77
|
+
|
|
78
|
+
### 3.2 Recipients
|
|
79
|
+
Anyone who receives *The Software* from *You* receives it under the terms of *This License*. *You* may not impose terms on a recipient that conflict with *This License*, and *You* may not grant a recipient rights that *This License* does not grant to *You*.
|
|
80
|
+
|
|
81
|
+
### 3.3 Modified Versions
|
|
82
|
+
*You* must clearly mark any modified version of *The Software* as modified. *The Software* is published by *Licensor* under a specific identity, such as a package name. *You* may not publish a modified version of *The Software* under the identity that *Licensor* originally published it under. Every copy of *The Software* that *You* redistribute under its original identity must be an exact copy of *The Software* as *Licensor* published it.
|
|
83
|
+
|
|
84
|
+
### 3.4 Trademarks
|
|
85
|
+
*This License* does not grant any rights to use the names, logos, or trademarks of *Licensor*, except as required to comply with section 3.1 and to describe the origin of *The Software*.
|
|
86
|
+
|
|
87
|
+
## 4. Non-Commercial Use
|
|
88
|
+
|
|
89
|
+
*Non-Commercial Use* of *The Software* is free. No *Usage Fee*, registration, or report is required for *Non-Commercial Use*.
|
|
90
|
+
|
|
91
|
+
## 5. Commercial Use
|
|
92
|
+
|
|
93
|
+
### 5.1 Usage Fee Obligation
|
|
94
|
+
For each *Fiscal Year* in which *You* make *Commercial Use* of *The Software*, *You* must pay *Licensor* a *Usage Fee*. A single *Usage Fee* covers all of *Your* *Commercial Use* of the *Components*. *Usage Fees* are not owed separately for each *Component*.
|
|
95
|
+
|
|
96
|
+
### 5.2 Derived Income
|
|
97
|
+
The *Derived Income* for a *Fiscal Year* is calculated as follows:
|
|
98
|
+
|
|
99
|
+
(Contribution Estimate) * (Profit) = (Derived Income)
|
|
100
|
+
|
|
101
|
+
If *You* make *Commercial Use* of *The Software* for more than one product or service, calculate the *Derived Income* for each product or service using its own *Contribution Estimate* and *Profit*, and add the results together. The *Profit* from a product or service that is in its *Migration Period* is not counted, as described in section 5.4.
|
|
102
|
+
|
|
103
|
+
If the *Profit* from a product or service for a *Fiscal Year* is zero or less, then the *Derived Income* from that product or service for that *Fiscal Year* is zero.
|
|
104
|
+
|
|
105
|
+
### 5.3 Usage Fee
|
|
106
|
+
The *Usage Fee* for a *Fiscal Year* is set by the *Derived Income* for that *Fiscal Year*, as shown in the following table.
|
|
107
|
+
|
|
108
|
+
| Derived Income for the Fiscal Year | Usage Fee per Fiscal Year |
|
|
109
|
+
|------------------------------------|---------------------------|
|
|
110
|
+
| 1,000,000 or less | Free |
|
|
111
|
+
| More than 1,000,000, up to and including 10,000,000 | 50,000 |
|
|
112
|
+
| More than 10,000,000, up to and including 100,000,000 | 100,000 |
|
|
113
|
+
| More than 100,000,000, up to and including 1,000,000,000 | 250,000 |
|
|
114
|
+
| More than 1,000,000,000 | 500,000 |
|
|
115
|
+
|
|
116
|
+
All amounts in this section 5 are in United States dollars. If *You* report *Profit* in a different currency, convert the *Derived Income* to United States dollars using the average exchange rate for the *Fiscal Year*.
|
|
117
|
+
|
|
118
|
+
### 5.4 Migration Allowance
|
|
119
|
+
When *You* migrate a product or service to *The Software*, the *Profit* from that product or service is not counted when calculating the *Derived Income* during its *Migration Period* (the **Migration Allowance**).
|
|
120
|
+
|
|
121
|
+
The *Migration Period* for a product or service begins on the date that *You* first make *Commercial Use* of *The Software* for that product or service as part of its *Migration*, and ends two (2) years after that date.
|
|
122
|
+
|
|
123
|
+
The *Migration Allowance* applies to each product or service separately, and *You* may use it for every product or service that *You* migrate. Each product or service has only one *Migration Period*.
|
|
124
|
+
|
|
125
|
+
If a *Fiscal Year* is only partly within the *Migration Period* of a product or service, the *Profit* from that product or service for that *Fiscal Year* is reduced in proportion to the number of days of that *Fiscal Year* that fall within its *Migration Period*.
|
|
126
|
+
|
|
127
|
+
For example, a company tests product A with *The Software* and pays a *Usage Fee* based on the *Profit* from product A. On March 1, 2027, the company begins moving the testing of product B from a different test framework to *The Software*. The company does not count the *Profit* from product B in its *Derived Income* from March 1, 2027 until March 1, 2029. Starting March 1, 2029, the *Profit* from product B is counted. If the company later migrates product C, the *Profit* from product C is not counted for two years from the start of its own *Migration*.
|
|
128
|
+
|
|
129
|
+
### 5.5 Contribution Estimate
|
|
130
|
+
*You* are responsible for making the *Contribution Estimate*. A *Contribution Estimate* must be reasonable and made in good faith. The *Contribution Estimate* for a product or service covers all of the *Components* that *You* *Use* for that product or service. When making the *Contribution Estimate*, *You* should consider how important *The Software* is to the *Commercial Activity*, how much of the *Commercial Activity* depends on *The Software*, and the effort and cost that *You* would otherwise need to spend to accomplish what *The Software* accomplishes for *You*.
|
|
131
|
+
|
|
132
|
+
The following table is a guide for making the *Contribution Estimate*. When *Your* *Commercial Use* of *The Software* matches an entry in the table, the *Contribution Estimate* in that entry is a reasonable *Contribution Estimate*. When *Your* *Commercial Use* does not match an entry in the table, make the *Contribution Estimate* by comparing *Your* *Commercial Use* with the entries in the table. When more than one entry matches the same *Commercial Activity*, use the entry with the highest *Contribution Estimate*.
|
|
133
|
+
|
|
134
|
+
| Commercial Use of *The Software* | Contribution Estimate |
|
|
135
|
+
|----------------------------------|-----------------------|
|
|
136
|
+
| Quality Assurance (QA) and Continuous Integration (CI): *The Software* is used to test or validate the products or services of the *Commercial Activity*, or to run their build and test pipelines | 0.3 |
|
|
137
|
+
| Products Completely Based on *The Software*: the products or services of the *Commercial Activity* are built completely on *The Software* | 0.5 |
|
|
138
|
+
|
|
139
|
+
### 5.6 Examples
|
|
140
|
+
A company earns 1,000,000,000 in *Profit* from a product line during a *Fiscal Year*. The company uses *The Software* for the QA and CI of that product line, so the *Contribution Estimate* is 0.3.
|
|
141
|
+
|
|
142
|
+
0.3 * 1,000,000,000 = 300,000,000
|
|
143
|
+
|
|
144
|
+
The *Derived Income* of 300,000,000 is more than 100,000,000 and not more than 1,000,000,000, so the *Usage Fee* for that *Fiscal Year* is 250,000.
|
|
145
|
+
|
|
146
|
+
A company earns 4,000,000 in *Profit* from a product during a *Fiscal Year*. The product is built completely on *The Software*, so the *Contribution Estimate* is 0.5.
|
|
147
|
+
|
|
148
|
+
0.5 * 4,000,000 = 2,000,000
|
|
149
|
+
|
|
150
|
+
The *Derived Income* of 2,000,000 is more than 1,000,000 and not more than 10,000,000, so the *Usage Fee* for that *Fiscal Year* is 50,000.
|
|
151
|
+
|
|
152
|
+
A company earns 3,000,000 in *Profit* from a product line during a *Fiscal Year*. The company uses *The Software* for the QA and CI of that product line, so the *Contribution Estimate* is 0.3.
|
|
153
|
+
|
|
154
|
+
0.3 * 3,000,000 = 900,000
|
|
155
|
+
|
|
156
|
+
The *Derived Income* of 900,000 is 1,000,000 or less, so there is no *Usage Fee* for that *Fiscal Year*.
|
|
157
|
+
|
|
158
|
+
### 5.7 Reporting and Payment
|
|
159
|
+
Within one hundred twenty (120) days after the end of each *Fiscal Year* for which a *Usage Fee* is owed, *You* must pay the *Usage Fee* to *Licensor* and provide *Licensor* with a written statement that shows the *Profit*, the *Contribution Estimate*, the reasoning used to make the *Contribution Estimate*, the *Derived Income*, and the *Usage Fee*. *Usage Fees* are payable in United States dollars. If any *Profit* is not counted under section 5.4, the statement must also identify each product or service that is in a *Migration Period* and show the dates of its *Migration Period*. No statement is required for a *Fiscal Year* in which no *Usage Fee* is owed.
|
|
160
|
+
|
|
161
|
+
### 5.8 Records and Review
|
|
162
|
+
*You* must keep records that reasonably support each *Contribution Estimate*, each *Profit* figure, and the dates of any *Migration Period* for at least three (3) years after the end of the *Fiscal Year* that they apply to. No more than once in any twelve (12) month period, *Licensor* may ask in writing for the records that support a statement, and *You* must provide them within sixty (60) days of the request. *Licensor* will treat those records as confidential.
|
|
163
|
+
|
|
164
|
+
### 5.9 Disputes About the Usage Fee
|
|
165
|
+
If *Licensor* believes that a *Contribution Estimate*, a *Profit* figure, or the dates of a *Migration Period* are not reasonable, *Licensor* will notify *You* in writing and explain why. *You* and *Licensor* will then negotiate in good faith for at least sixty (60) days to agree on a reasonable *Usage Fee*. If *You* and *Licensor* cannot agree, either party may resolve the dispute as described in section 8.
|
|
166
|
+
|
|
167
|
+
### 5.10 Fixed License Cost
|
|
168
|
+
For assurance of costs, *You* may disclose to *Licensor* how *You* *Use* *The Software* and the returns that *You* estimate *You* will derive from it, and negotiate a fixed license cost with *Licensor*. A fixed license cost, and any other terms that *You* and *Licensor* agree to, must be set out in a writing signed by both *You* and *Licensor* (a **Fixed License Agreement**).
|
|
169
|
+
|
|
170
|
+
While a *Fixed License Agreement* is in effect, the fixed license cost replaces the *Usage Fee* for the *Commercial Use* that the *Fixed License Agreement* covers, and sections 5.7 through 5.9 apply to that *Commercial Use* only as the *Fixed License Agreement* provides. To the extent that a *Fixed License Agreement* conflicts with this section 5, the *Fixed License Agreement* controls.
|
|
171
|
+
|
|
172
|
+
*Licensor* will treat the information that *You* disclose under this section 5.10 as confidential.
|
|
173
|
+
|
|
174
|
+
## 6. Termination
|
|
175
|
+
|
|
176
|
+
If *You* violate *This License*, the licenses granted to *You* by *This License* end. If *Licensor* notifies *You* in writing of the violation and *You* correct the violation within thirty (30) days of receiving the notice, including by paying any *Usage Fee* that is owed, then the licenses granted to *You* are reinstated. This reinstatement is available to *You* only once. Termination does not release *You* from any *Usage Fee* that is owed for *Commercial Use* that took place before the termination.
|
|
177
|
+
|
|
178
|
+
## 7. No Warranty and Limitation of Liability
|
|
179
|
+
|
|
180
|
+
### 7.1 No Warranty
|
|
181
|
+
*THE SOFTWARE* IS PROVIDED "AS IS", WITHOUT ANY WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY, TITLE, NON-INFRINGEMENT, AND FITNESS FOR A PARTICULAR PURPOSE.
|
|
182
|
+
|
|
183
|
+
### 7.2 Limitation of Liability
|
|
184
|
+
TO THE EXTENT PERMITTED BY LAW, IN NO EVENT WILL *LICENSOR* BE LIABLE TO *YOU* FOR ANY DIRECT, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR EXEMPLARY DAMAGES OF ANY KIND ARISING OUT OF OR RELATED TO *THIS LICENSE* OR THE *USE* OF *THE SOFTWARE*, EVEN IF *LICENSOR* HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
185
|
+
|
|
186
|
+
### 7.3 Support
|
|
187
|
+
*This License* does not entitle *You* to support, maintenance, or other services. Support and services are available only under a separate written agreement with *Licensor*.
|
|
188
|
+
|
|
189
|
+
## 8. Governing Law and Jurisdiction
|
|
190
|
+
|
|
191
|
+
*This License* is governed by the laws of the State of Washington and the United States as though fully performed in Washington, without regard to provisions relating to conflicts of laws. The UN Convention for the International Sale of Goods and the Uniform Computer Information Transactions Act (UCITA) will not apply. The state and federal courts located in King County, Washington will have exclusive jurisdiction to adjudicate any disputes arising under *This License*, and *You* and *Licensor* agree to submit to the jurisdiction of those courts.
|
|
192
|
+
|
|
193
|
+
## 9. General
|
|
194
|
+
|
|
195
|
+
### 9.1 Severability
|
|
196
|
+
If any provision of *This License* is held to be unenforceable, that provision will be enforced to the maximum extent permitted, and the remaining provisions of *This License* will remain in effect.
|
|
197
|
+
|
|
198
|
+
### 9.2 Interpretation
|
|
199
|
+
The section headings in *This License* are for reference purposes only and do not modify or restrict any term of *This License*. *This License* is written in the English language, and the English text prevails over any translation.
|
|
200
|
+
|
|
201
|
+
### 9.3 Contact
|
|
202
|
+
Questions about *This License*, *Usage Fee* statements, payments, and requests to negotiate a *Fixed License Agreement* may be sent to *Licensor* using the contact information published at:
|
|
203
|
+
|
|
204
|
+
https://code-craft.ai
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codecraftai-interop-ext-bluetooth
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Code Craft AI Interop Extension for Bluetooth
|
|
5
|
+
License-Expression: LicenseRef-CodeCraftAI-Source-Available
|
|
6
|
+
License-File: LICENSE.md
|
|
7
|
+
Keywords: python
|
|
8
|
+
Author: Myron Walker
|
|
9
|
+
Author-email: myron.walker@gmail.com
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Requires-Dist: bumble (>=0.0.223,<0.0.224)
|
|
14
|
+
Requires-Dist: codecraftai-interop (<2.0.0)
|
|
15
|
+
Requires-Dist: codecraftai-landscaping (<2.0.0)
|
|
16
|
+
Project-URL: Issues, https://github.com/code-craft-ai-company/codecraftai-interop-ext-bluetooth/issues
|
|
17
|
+
Project-URL: Repository, https://github.com/code-craft-ai-company/codecraftai-interop-ext-bluetooth
|
|
18
|
+
Description-Content-Type: text/x-rst
|
|
19
|
+
|
|
20
|
+
==============================================
|
|
21
|
+
Code Craft AI Interop Extension: Bluetooth
|
|
22
|
+
==============================================
|
|
23
|
+
|
|
24
|
+
The ``codecraftai-interop-ext-bluetooth`` package provides Bluetooth protocol interop extensions and landscape extensions that allow for interoperability with Bluetooth devices during automation runs. It plugs into the ``codecraftai-interop`` and ``codecraftai-landscaping`` frameworks through the extension mechanism in ``codecraftai-extension``.
|
|
25
|
+
|
|
26
|
+
Highlights
|
|
27
|
+
==========
|
|
28
|
+
|
|
29
|
+
- Bluetooth protocol handlers under ``interop/bluetooth/``.
|
|
30
|
+
- Landscape extension hooks so Bluetooth devices surface as first-class landscape resources.
|
|
31
|
+
- Composable with other ``codecraftai-interop-ext-*`` packages through the shared extension mechanism.
|
|
32
|
+
|
|
33
|
+
Repository Structure
|
|
34
|
+
====================
|
|
35
|
+
|
|
36
|
+
- ``development/`` - Runtime environment scripts.
|
|
37
|
+
- ``repository-setup/`` - Repository homing and setup scripts.
|
|
38
|
+
- ``source/packages/`` - Package source code.
|
|
39
|
+
- ``source/testroots/ccai/`` - TestBase test roots.
|
|
40
|
+
- ``userguide/`` - Standards, conventions, and documentation.
|
|
41
|
+
|
|
42
|
+
Environment Setup
|
|
43
|
+
=================
|
|
44
|
+
|
|
45
|
+
When working in a Codex container:
|
|
46
|
+
|
|
47
|
+
.. code-block:: bash
|
|
48
|
+
|
|
49
|
+
./development/setup-agent-container
|
|
50
|
+
|
|
51
|
+
For local development:
|
|
52
|
+
|
|
53
|
+
.. code-block:: bash
|
|
54
|
+
|
|
55
|
+
./repository-setup/rehome-repository
|
|
56
|
+
./development/setup-envrionment
|
|
57
|
+
|
|
58
|
+
Running Tests
|
|
59
|
+
=============
|
|
60
|
+
|
|
61
|
+
Activate the virtual environment:
|
|
62
|
+
|
|
63
|
+
.. code-block:: bash
|
|
64
|
+
|
|
65
|
+
source .venv/bin/activate
|
|
66
|
+
|
|
67
|
+
Run the single-host test suite:
|
|
68
|
+
|
|
69
|
+
.. code-block:: bash
|
|
70
|
+
|
|
71
|
+
testbase testing run --root source/testroots/ccai --includes=ccai.tests.singlehost.interop --output=./.output
|
|
72
|
+
|
|
73
|
+
Standards and Conventions
|
|
74
|
+
=========================
|
|
75
|
+
|
|
76
|
+
- Naming conventions: ``userguide/10-02-naming-conventions.rst``
|
|
77
|
+
- Code organization: ``userguide/10-01-code-organization.rst``
|
|
78
|
+
- Coding standards: ``userguide/10-00-coding-standards.rst``
|
|
79
|
+
|
|
80
|
+
Additional Resources
|
|
81
|
+
====================
|
|
82
|
+
|
|
83
|
+
- User guide entry point: ``userguide/userguide.rst``
|
|
84
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
==============================================
|
|
2
|
+
Code Craft AI Interop Extension: Bluetooth
|
|
3
|
+
==============================================
|
|
4
|
+
|
|
5
|
+
The ``codecraftai-interop-ext-bluetooth`` package provides Bluetooth protocol interop extensions and landscape extensions that allow for interoperability with Bluetooth devices during automation runs. It plugs into the ``codecraftai-interop`` and ``codecraftai-landscaping`` frameworks through the extension mechanism in ``codecraftai-extension``.
|
|
6
|
+
|
|
7
|
+
Highlights
|
|
8
|
+
==========
|
|
9
|
+
|
|
10
|
+
- Bluetooth protocol handlers under ``interop/bluetooth/``.
|
|
11
|
+
- Landscape extension hooks so Bluetooth devices surface as first-class landscape resources.
|
|
12
|
+
- Composable with other ``codecraftai-interop-ext-*`` packages through the shared extension mechanism.
|
|
13
|
+
|
|
14
|
+
Repository Structure
|
|
15
|
+
====================
|
|
16
|
+
|
|
17
|
+
- ``development/`` - Runtime environment scripts.
|
|
18
|
+
- ``repository-setup/`` - Repository homing and setup scripts.
|
|
19
|
+
- ``source/packages/`` - Package source code.
|
|
20
|
+
- ``source/testroots/ccai/`` - TestBase test roots.
|
|
21
|
+
- ``userguide/`` - Standards, conventions, and documentation.
|
|
22
|
+
|
|
23
|
+
Environment Setup
|
|
24
|
+
=================
|
|
25
|
+
|
|
26
|
+
When working in a Codex container:
|
|
27
|
+
|
|
28
|
+
.. code-block:: bash
|
|
29
|
+
|
|
30
|
+
./development/setup-agent-container
|
|
31
|
+
|
|
32
|
+
For local development:
|
|
33
|
+
|
|
34
|
+
.. code-block:: bash
|
|
35
|
+
|
|
36
|
+
./repository-setup/rehome-repository
|
|
37
|
+
./development/setup-envrionment
|
|
38
|
+
|
|
39
|
+
Running Tests
|
|
40
|
+
=============
|
|
41
|
+
|
|
42
|
+
Activate the virtual environment:
|
|
43
|
+
|
|
44
|
+
.. code-block:: bash
|
|
45
|
+
|
|
46
|
+
source .venv/bin/activate
|
|
47
|
+
|
|
48
|
+
Run the single-host test suite:
|
|
49
|
+
|
|
50
|
+
.. code-block:: bash
|
|
51
|
+
|
|
52
|
+
testbase testing run --root source/testroots/ccai --includes=ccai.tests.singlehost.interop --output=./.output
|
|
53
|
+
|
|
54
|
+
Standards and Conventions
|
|
55
|
+
=========================
|
|
56
|
+
|
|
57
|
+
- Naming conventions: ``userguide/10-02-naming-conventions.rst``
|
|
58
|
+
- Code organization: ``userguide/10-01-code-organization.rst``
|
|
59
|
+
- Coding standards: ``userguide/10-00-coding-standards.rst``
|
|
60
|
+
|
|
61
|
+
Additional Resources
|
|
62
|
+
====================
|
|
63
|
+
|
|
64
|
+
- User guide entry point: ``userguide/userguide.rst``
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codecraftai-interop-ext-bluetooth"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "Code Craft AI Interop Extension for Bluetooth"
|
|
5
|
+
readme = "README.rst"
|
|
6
|
+
license = "LicenseRef-CodeCraftAI-Source-Available"
|
|
7
|
+
authors = [{name = "Myron Walker", email = "myron.walker@gmail.com"}]
|
|
8
|
+
keywords = [
|
|
9
|
+
"python"
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Operating System :: POSIX",
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.10,<4.0"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"codecraftai-interop<2.0.0",
|
|
18
|
+
"codecraftai-landscaping<2.0.0",
|
|
19
|
+
"bumble>=0.0.223,<0.0.224",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/code-craft-ai-company/codecraftai-interop-ext-bluetooth"
|
|
24
|
+
Issues = "https://github.com/code-craft-ai-company/codecraftai-interop-ext-bluetooth/issues"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = []
|
|
28
|
+
|
|
29
|
+
[tool.poetry]
|
|
30
|
+
packages = [{include="ccai", from="source/packages"}]
|
|
31
|
+
|
|
32
|
+
[tool.poetry.dependencies]
|
|
33
|
+
codecraftai-interop = {source = "codecraftai"}
|
|
34
|
+
codecraftai-landscaping = {source = "codecraftai"}
|
|
35
|
+
|
|
36
|
+
[[tool.poetry.source]]
|
|
37
|
+
name = "codecraftai"
|
|
38
|
+
url = "https://code-craft.ai/licensed-repository/simple/"
|
|
39
|
+
priority = "supplemental"
|
|
40
|
+
|
|
41
|
+
[[tool.uv.index]]
|
|
42
|
+
# Same licensed index as [[tool.poetry.source]]. Listed before PyPI so that
|
|
43
|
+
# codecraftai-* packages (direct and transitive) resolve from it; everything
|
|
44
|
+
# else falls through to PyPI. Credentials: UV_INDEX_CODECRAFTAI_USERNAME /
|
|
45
|
+
# UV_INDEX_CODECRAFTAI_PASSWORD, or `uv auth login`.
|
|
46
|
+
name = "codecraftai"
|
|
47
|
+
url = "https://code-craft.ai/licensed-repository/simple/"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
# Deliberately narrow. This gate exists for one reason: a bare `except:`
|
|
51
|
+
# swallows ConfigurationError and SemanticError, which derive from
|
|
52
|
+
# BaseException precisely so that they pass through ordinary handlers and end
|
|
53
|
+
# the run. Catching them hides the original cause behind whatever error
|
|
54
|
+
# happens next. See userguide/10-00-coding-standards.rst, exception-handling
|
|
55
|
+
# rules 5-7. Use `except Exception:` when you mean "any ordinary failure".
|
|
56
|
+
select = ["E722"]
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["poetry-core"]
|
|
60
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .bluetoothcoordinator import BluetoothCoordinator
|
|
2
|
+
from .bluetoothpresence import BluetoothPresence
|
|
3
|
+
from .bluetoothdevice import BluetoothDevice
|
|
4
|
+
from .bluetoothsearchwindow import BluetoothSearchWindow
|
|
5
|
+
from .deviceevidence import BluetoothDeviceEvidence
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BluetoothCoordinator",
|
|
9
|
+
"BluetoothPresence",
|
|
10
|
+
"BluetoothDevice",
|
|
11
|
+
"BluetoothSearchWindow",
|
|
12
|
+
"BluetoothDeviceEvidence",
|
|
13
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import asyncio
|
|
3
|
+
import threading
|
|
4
|
+
from concurrent.futures import Future
|
|
5
|
+
from bumble.device import Device
|
|
6
|
+
from bumble.transport import open_transport_or_link
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class BluetoothCoordinator:
|
|
11
|
+
"""
|
|
12
|
+
Coordinator for Bluetooth operations using Bumble.
|
|
13
|
+
Manages its own background thread and asyncio loop to provide a synchronous API.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, transport_address="usb:0", device_config=None):
|
|
16
|
+
self.transport_address = transport_address
|
|
17
|
+
self.device_config = device_config or {
|
|
18
|
+
"name": "MojoBluetooth",
|
|
19
|
+
"address": "F0:F1:F2:F3:F4:F5"
|
|
20
|
+
}
|
|
21
|
+
self.device = None
|
|
22
|
+
self.transport = None
|
|
23
|
+
|
|
24
|
+
self._loop = None
|
|
25
|
+
self._loop_thread = None
|
|
26
|
+
self._running = False
|
|
27
|
+
|
|
28
|
+
def startup(self):
|
|
29
|
+
"""
|
|
30
|
+
Starts the coordinator and its background loop.
|
|
31
|
+
"""
|
|
32
|
+
if self._running:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
logger.info(f"Starting Bluetooth Coordinator with transport: {self.transport_address}")
|
|
36
|
+
|
|
37
|
+
self._running = True
|
|
38
|
+
self._loop = asyncio.new_event_loop()
|
|
39
|
+
self._loop_thread = threading.Thread(target=self._run_loop, args=(self._loop,), daemon=True, name="am-bt-coordinator-loop")
|
|
40
|
+
self._loop_thread.start()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
self.submit_coroutine(self._async_startup())
|
|
44
|
+
logger.info("Bluetooth Coordinator started")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error(f"Failed to start Bluetooth coordinator: {e}")
|
|
47
|
+
self.shutdown()
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
def shutdown(self):
|
|
51
|
+
"""
|
|
52
|
+
Stops the coordinator and its background loop.
|
|
53
|
+
"""
|
|
54
|
+
logger.info("Stopping Bluetooth Coordinator")
|
|
55
|
+
|
|
56
|
+
if self._loop and self._loop.is_running():
|
|
57
|
+
try:
|
|
58
|
+
self.submit_coroutine(self._async_shutdown())
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.warning(f"Error during async shutdown: {e}")
|
|
61
|
+
|
|
62
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
63
|
+
|
|
64
|
+
if self._loop_thread and self._loop_thread.is_alive():
|
|
65
|
+
self._loop_thread.join(timeout=5.0)
|
|
66
|
+
if self._loop_thread.is_alive():
|
|
67
|
+
logger.warning("Bluetooth Coordinator loop thread did not exit cleanly")
|
|
68
|
+
|
|
69
|
+
self._running = False
|
|
70
|
+
self._loop = None
|
|
71
|
+
self._loop_thread = None
|
|
72
|
+
|
|
73
|
+
def start_scanning(self, filter_duplicates=True):
|
|
74
|
+
self.submit_coroutine(self._async_start_scanning(filter_duplicates))
|
|
75
|
+
|
|
76
|
+
def stop_scanning(self):
|
|
77
|
+
self.submit_coroutine(self._async_stop_scanning())
|
|
78
|
+
|
|
79
|
+
def submit_coroutine(self, coro, timeout=10.0):
|
|
80
|
+
"""
|
|
81
|
+
Submits a coroutine to the background loop and waits for the result.
|
|
82
|
+
"""
|
|
83
|
+
if not self._loop:
|
|
84
|
+
raise RuntimeError("Coordinator loop not running")
|
|
85
|
+
|
|
86
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
87
|
+
return future.result(timeout=timeout)
|
|
88
|
+
|
|
89
|
+
def _run_loop(self, loop):
|
|
90
|
+
asyncio.set_event_loop(loop)
|
|
91
|
+
loop.run_forever()
|
|
92
|
+
|
|
93
|
+
async def _async_startup(self):
|
|
94
|
+
# Initialize Bumble device
|
|
95
|
+
name = self.device_config.get("name", "MojoBluetooth")
|
|
96
|
+
address = self.device_config.get("address", "F0:F1:F2:F3:F4:F5")
|
|
97
|
+
|
|
98
|
+
# Open the transport first, then build the device wired to the
|
|
99
|
+
# transport's HCI source/sink. Device.with_hci constructs the Host
|
|
100
|
+
# internally; there is no separate attach/sink step in bumble.
|
|
101
|
+
self.transport = await open_transport_or_link(self.transport_address)
|
|
102
|
+
|
|
103
|
+
self.device = Device.with_hci(
|
|
104
|
+
name, address, self.transport.source, self.transport.sink
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await self.device.power_on()
|
|
108
|
+
|
|
109
|
+
async def _async_shutdown(self):
|
|
110
|
+
if self.device:
|
|
111
|
+
try:
|
|
112
|
+
await self.device.power_off()
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning(f"Error powering off device: {e}")
|
|
115
|
+
|
|
116
|
+
if self.transport:
|
|
117
|
+
try:
|
|
118
|
+
await self.transport.close()
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Error closing transport: {e}")
|
|
121
|
+
|
|
122
|
+
self.device = None
|
|
123
|
+
self.transport = None
|
|
124
|
+
|
|
125
|
+
async def _async_start_scanning(self, filter_duplicates=True):
|
|
126
|
+
if not self.device:
|
|
127
|
+
raise RuntimeError("Device not initialized")
|
|
128
|
+
|
|
129
|
+
logger.info("Starting scanning (async)")
|
|
130
|
+
await self.device.start_scanning(filter_duplicates=filter_duplicates)
|
|
131
|
+
|
|
132
|
+
async def _async_stop_scanning(self):
|
|
133
|
+
if not self.device:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
logger.info("Stopping scanning (async)")
|
|
137
|
+
await self.device.stop_scanning()
|
codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothdevice.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from .bluetoothcoordinator import BluetoothCoordinator
|
|
4
|
+
from .deviceevidence import BluetoothDeviceEvidence
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class BluetoothDevice:
|
|
9
|
+
"""
|
|
10
|
+
Represents a specific Bluetooth device.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, address: str, coordinator: BluetoothCoordinator):
|
|
13
|
+
self.address = address
|
|
14
|
+
self.coordinator = coordinator
|
|
15
|
+
self.connection = None
|
|
16
|
+
self._evidence: Optional[BluetoothDeviceEvidence] = None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def evidence(self) -> Optional[BluetoothDeviceEvidence]:
|
|
20
|
+
return self._evidence
|
|
21
|
+
|
|
22
|
+
def update_evidence(self, evidence: BluetoothDeviceEvidence):
|
|
23
|
+
"""
|
|
24
|
+
Updates the key evidence for the device (RSSI, name, etc.)
|
|
25
|
+
"""
|
|
26
|
+
self._evidence = evidence
|
|
27
|
+
|
|
28
|
+
def connect(self):
|
|
29
|
+
"""
|
|
30
|
+
Connects to the device.
|
|
31
|
+
"""
|
|
32
|
+
logger.info(f"Connecting to device {self.address}")
|
|
33
|
+
# Execute the connection on the coordinator's loop
|
|
34
|
+
self.connection = self.coordinator.submit_coroutine(self._async_connect())
|
|
35
|
+
logger.info(f"Connected to {self.address}")
|
|
36
|
+
return self.connection
|
|
37
|
+
|
|
38
|
+
async def _async_connect(self):
|
|
39
|
+
if not self.coordinator.device:
|
|
40
|
+
raise RuntimeError("Coordinator not initialized. Call startup() on coordinator first.")
|
|
41
|
+
return await self.coordinator.device.connect(self.address)
|
|
42
|
+
|
|
43
|
+
def disconnect(self):
|
|
44
|
+
"""
|
|
45
|
+
Disconnects from the device.
|
|
46
|
+
"""
|
|
47
|
+
if self.connection:
|
|
48
|
+
logger.info(f"Disconnecting from {self.address}")
|
|
49
|
+
self.coordinator.submit_coroutine(self._async_disconnect())
|
|
50
|
+
self.connection = None
|
|
51
|
+
|
|
52
|
+
async def _async_disconnect(self):
|
|
53
|
+
if self.connection:
|
|
54
|
+
await self.connection.disconnect()
|
|
55
|
+
|
|
56
|
+
def pair(self):
|
|
57
|
+
"""
|
|
58
|
+
Pairs with the device.
|
|
59
|
+
"""
|
|
60
|
+
logger.info(f"Pairing with {self.address}")
|
|
61
|
+
self.coordinator.submit_coroutine(self._async_pair())
|
|
62
|
+
|
|
63
|
+
async def _async_pair(self):
|
|
64
|
+
if not self.connection:
|
|
65
|
+
# Capture the connection: _async_connect() returns it but does not
|
|
66
|
+
# assign self.connection (only the sync connect() wrapper does), so
|
|
67
|
+
# without this the pair() call below dereferences None.
|
|
68
|
+
self.connection = await self._async_connect()
|
|
69
|
+
await self.connection.pair()
|
codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/bluetoothpresence.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
import weakref
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from bumble.core import AdvertisingData
|
|
10
|
+
|
|
11
|
+
from .bluetoothcoordinator import BluetoothCoordinator
|
|
12
|
+
from .bluetoothsearchwindow import BluetoothSearchWindow
|
|
13
|
+
from .bluetoothdevice import BluetoothDevice
|
|
14
|
+
from .deviceevidence import BluetoothDeviceEvidence
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Advertising data structure types that carry lists of service class UUIDs.
|
|
19
|
+
SERVICE_UUID_LIST_TYPES = (
|
|
20
|
+
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
|
21
|
+
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
|
22
|
+
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
|
23
|
+
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
|
24
|
+
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
|
25
|
+
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_local_name(data: AdvertisingData) -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Reads the advertised local name out of bumble advertising data, preferring the
|
|
32
|
+
complete local name over the shortened one.
|
|
33
|
+
|
|
34
|
+
:param data: The parsed advertising data of an advertisement.
|
|
35
|
+
|
|
36
|
+
:returns: The advertised name, or ``None`` when the advertisement carries none.
|
|
37
|
+
"""
|
|
38
|
+
name = data.get(AdvertisingData.COMPLETE_LOCAL_NAME)
|
|
39
|
+
if name is None:
|
|
40
|
+
name = data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_service_uuids(data: AdvertisingData) -> List[str]:
|
|
45
|
+
"""
|
|
46
|
+
Collects every distinct service class UUID advertised in bumble advertising data as a
|
|
47
|
+
hex string (``"1800"`` for 16-bit UUIDs, the full 32 hex digits for 128-bit ones).
|
|
48
|
+
|
|
49
|
+
An active scan hands over the advertising packet and the scan response as one
|
|
50
|
+
accumulated advertisement, so the same list can appear twice; each UUID is kept once.
|
|
51
|
+
|
|
52
|
+
:param data: The parsed advertising data of an advertisement.
|
|
53
|
+
|
|
54
|
+
:returns: The list of advertised service UUIDs, in first-seen order.
|
|
55
|
+
"""
|
|
56
|
+
service_uuids = []
|
|
57
|
+
for list_type in SERVICE_UUID_LIST_TYPES:
|
|
58
|
+
for uuid_list in data.get_all(list_type):
|
|
59
|
+
for service_uuid in uuid_list:
|
|
60
|
+
uuid_text = service_uuid.to_hex_str()
|
|
61
|
+
if uuid_text not in service_uuids:
|
|
62
|
+
service_uuids.append(uuid_text)
|
|
63
|
+
return service_uuids
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BluetoothPresence:
|
|
67
|
+
"""
|
|
68
|
+
Manages Bluetooth presence and discovery using a background thread (via Coordinator)
|
|
69
|
+
and provides a synchronous, thread-safe API.
|
|
70
|
+
"""
|
|
71
|
+
def __init__(self, coordinator: BluetoothCoordinator):
|
|
72
|
+
self._coordinator = coordinator
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
self._running = False
|
|
76
|
+
|
|
77
|
+
# Data storage
|
|
78
|
+
self._discovered_devices: Dict[str, BluetoothDevice] = {} # Persistent cache
|
|
79
|
+
self._search_windows: Dict[str, weakref.ref] = {}
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_running(self) -> bool:
|
|
83
|
+
return self._running
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def discovered_devices(self) -> Dict[str, BluetoothDevice]:
|
|
87
|
+
# Return a shallow copy of the mapping. The BluetoothDevice values are
|
|
88
|
+
# live shared handles -- each holds the coordinator's asyncio loop, a
|
|
89
|
+
# thread, and a lock -- so they must NOT be deep-copied (deepcopy raises
|
|
90
|
+
# TypeError trying to pickle the event loop's context).
|
|
91
|
+
with self._lock:
|
|
92
|
+
devices = dict(self._discovered_devices)
|
|
93
|
+
return devices
|
|
94
|
+
|
|
95
|
+
def start(self):
|
|
96
|
+
"""
|
|
97
|
+
Starts the Bluetooth presence manager.
|
|
98
|
+
"""
|
|
99
|
+
with self._lock:
|
|
100
|
+
if self._running:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self._running = True
|
|
104
|
+
|
|
105
|
+
# Initialize coordinator
|
|
106
|
+
try:
|
|
107
|
+
self._coordinator.startup()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Failed to start Bluetooth coordinator: {e}")
|
|
110
|
+
self._running = False
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
# Start continuous scanning
|
|
114
|
+
# We need to register the callback.
|
|
115
|
+
# Ideally this should be done safely on the loop.
|
|
116
|
+
# Using submit_coroutine to register callback is tricky because 'on' is not async usually.
|
|
117
|
+
# However, Bumble 'on' just appends to a list.
|
|
118
|
+
# We can define a coroutine to register it to be safe.
|
|
119
|
+
self._coordinator.submit_coroutine(self._register_callback_and_scan())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def stop(self):
|
|
123
|
+
"""
|
|
124
|
+
Stops the Bluetooth presence manager.
|
|
125
|
+
"""
|
|
126
|
+
with self._lock:
|
|
127
|
+
if not self._running:
|
|
128
|
+
return
|
|
129
|
+
self._running = False
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self._coordinator.stop_scanning()
|
|
133
|
+
self._coordinator.shutdown()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.warning(f"Error stopping coordinator: {e}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _register_callback_and_scan(self):
|
|
139
|
+
# Setup callback
|
|
140
|
+
if hasattr(self._coordinator.device, 'on'):
|
|
141
|
+
self._coordinator.device.on('advertisement', self._on_advertisement)
|
|
142
|
+
|
|
143
|
+
await self._coordinator._async_start_scanning()
|
|
144
|
+
|
|
145
|
+
def _on_advertisement(self, entry):
|
|
146
|
+
"""
|
|
147
|
+
Callback from Bumble when an advertisement is received.
|
|
148
|
+
Executed in the coordinator's loop thread.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
address = str(entry.address)
|
|
152
|
+
rssi = getattr(entry, 'rssi', 0)
|
|
153
|
+
|
|
154
|
+
# Extract basic info from entry if possible
|
|
155
|
+
name = None
|
|
156
|
+
service_uuids = []
|
|
157
|
+
|
|
158
|
+
if hasattr(entry, 'data'):
|
|
159
|
+
# bumble exposes the parsed structures through AdvertisingData.get /
|
|
160
|
+
# get_all; there is no get_local_name() or service_uuids attribute.
|
|
161
|
+
name = extract_local_name(entry.data)
|
|
162
|
+
service_uuids = extract_service_uuids(entry.data)
|
|
163
|
+
|
|
164
|
+
# Create Evidence
|
|
165
|
+
evidence = BluetoothDeviceEvidence(
|
|
166
|
+
address=address,
|
|
167
|
+
rssi=rssi,
|
|
168
|
+
name=name,
|
|
169
|
+
service_uuids=service_uuids
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
with self._lock:
|
|
173
|
+
if address not in self._discovered_devices:
|
|
174
|
+
# New device
|
|
175
|
+
device = BluetoothDevice(address, self._coordinator)
|
|
176
|
+
device.update_evidence(evidence)
|
|
177
|
+
self._discovered_devices[address] = device
|
|
178
|
+
logger.debug(f"Discovered new Bluetooth device: {address} ({name})")
|
|
179
|
+
else:
|
|
180
|
+
device = self._discovered_devices[address]
|
|
181
|
+
device.update_evidence(evidence)
|
|
182
|
+
|
|
183
|
+
# Notify active search windows
|
|
184
|
+
windows_to_notify = []
|
|
185
|
+
for wid, wref in list(self._search_windows.items()):
|
|
186
|
+
window = wref()
|
|
187
|
+
if window is None:
|
|
188
|
+
del self._search_windows[wid]
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if window.is_open():
|
|
192
|
+
windows_to_notify.append(window)
|
|
193
|
+
|
|
194
|
+
# Notify windows
|
|
195
|
+
for window in windows_to_notify:
|
|
196
|
+
window.add_device(address, device)
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Error handling advertisement: {e}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def discover_devices(self, limit_to: List[str]=None, window_length: float = 10.0,
|
|
203
|
+
monitor_interval: float = 0.5) -> BluetoothSearchWindow:
|
|
204
|
+
"""
|
|
205
|
+
Opens a search window for devices.
|
|
206
|
+
Returns a BluetoothSearchWindow.
|
|
207
|
+
"""
|
|
208
|
+
if not self._running:
|
|
209
|
+
raise RuntimeError("BluetoothPresence must be running. Call start().")
|
|
210
|
+
|
|
211
|
+
start = datetime.now()
|
|
212
|
+
end = start + timedelta(seconds=window_length)
|
|
213
|
+
wid = str(uuid.uuid4())
|
|
214
|
+
|
|
215
|
+
window = BluetoothSearchWindow(self, wid, start, end, monitor_interval, limit_to=limit_to)
|
|
216
|
+
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._search_windows[wid] = weakref.ref(window)
|
|
219
|
+
|
|
220
|
+
window.start()
|
|
221
|
+
|
|
222
|
+
return window
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
.. module:: bluetoothsearchwindow
|
|
4
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
5
|
+
:synopsis: bluetoothsearchwindow module.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Myron Walker"
|
|
11
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
12
|
+
__credits__ = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from typing import Dict, List, Optional, TYPE_CHECKING
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import weakref
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
# We will need a BluetoothDeviceDetail similar to UpnpDeviceDetail,
|
|
22
|
+
# for now using generic dict or we might need to create it.
|
|
23
|
+
# The user asked to "Make sure it is using a windows similar to the upnp code"
|
|
24
|
+
# so I will assume we might need a similar detail object, but for now I will use the
|
|
25
|
+
# existing BluetoothDevice or a new detail class if I create one.
|
|
26
|
+
# The prompt mentioned "Modify the BluetoothPresence object to utilize the Bumble package...
|
|
27
|
+
# Make sure the BluetoothPresence object has a way to scan for BLE devices and Bluetooth classic"
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ccai.interop.bluetooth.bluetoothpresence import BluetoothPresence
|
|
31
|
+
from ccai.interop.bluetooth.bluetoothdevice import BluetoothDevice
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_address_hint(hint: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Normalises a device address so that hints supplied by callers compare equal to the
|
|
37
|
+
address keys produced from bumble advertisements. bumble renders public addresses
|
|
38
|
+
with a type qualifier (``AA:BB:CC:DD:EE:FF/P``) while callers naturally supply the
|
|
39
|
+
bare address; letter case and surrounding whitespace are ignored as well.
|
|
40
|
+
|
|
41
|
+
:param hint: A device address, with or without bumble's ``/P`` qualifier.
|
|
42
|
+
|
|
43
|
+
:returns: The upper-cased bare address.
|
|
44
|
+
"""
|
|
45
|
+
normalized = hint.strip().upper()
|
|
46
|
+
if normalized.endswith("/P"):
|
|
47
|
+
normalized = normalized[:-2]
|
|
48
|
+
return normalized
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BluetoothSearchWindowState:
|
|
52
|
+
"""
|
|
53
|
+
Enumeration describing the lifecycle of a Bluetooth search window.
|
|
54
|
+
"""
|
|
55
|
+
INACTIVE = 0
|
|
56
|
+
ACTIVE = 1
|
|
57
|
+
COMPLETED = 2
|
|
58
|
+
TIMEOUT = 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BluetoothSearchWindow:
|
|
62
|
+
"""
|
|
63
|
+
The BluetoothSearchWindow class provides a synchronization and state managment device for synchronizing the state of Bluetooth search
|
|
64
|
+
activities.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, presence: "BluetoothPresence", id: str, start: datetime, end: datetime, monitor_interval: float, limit_to: List[str]=None):
|
|
68
|
+
"""
|
|
69
|
+
:param presence: The owning :class:`BluetoothPresence` instance.
|
|
70
|
+
:param id: Identifier for this search window.
|
|
71
|
+
:param start: Start time of the search window.
|
|
72
|
+
:param end: End time of the search window.
|
|
73
|
+
:param monitor_interval: Polling interval for window monitoring.
|
|
74
|
+
:param limit_to: Optional list of device addresses to limit the search.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
self._presence_ref = weakref.ref(presence)
|
|
78
|
+
self._id = id
|
|
79
|
+
self._start = start
|
|
80
|
+
self._end = end
|
|
81
|
+
self._monitor_interval = monitor_interval
|
|
82
|
+
self._limit_to = limit_to
|
|
83
|
+
|
|
84
|
+
self._still_waiting_for = None
|
|
85
|
+
if limit_to is not None:
|
|
86
|
+
self._still_waiting_for = [hint for hint in limit_to]
|
|
87
|
+
|
|
88
|
+
self._window_state = BluetoothSearchWindowState.INACTIVE
|
|
89
|
+
|
|
90
|
+
self._lock = threading.Lock()
|
|
91
|
+
self._wait_gate = threading.Event()
|
|
92
|
+
self._monitor_thread = None
|
|
93
|
+
|
|
94
|
+
self._devices_found = {}
|
|
95
|
+
self._matching_devices = {}
|
|
96
|
+
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def devices_found(self) -> Dict[str, "BluetoothDevice"]:
|
|
101
|
+
"""
|
|
102
|
+
The dictionary of devices found during the search window.
|
|
103
|
+
:returns: Copy of the devices found mapping.
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
rtnval = self._devices_found.copy()
|
|
107
|
+
return rtnval
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def id(self) -> str:
|
|
111
|
+
"""
|
|
112
|
+
The ID of the search window.
|
|
113
|
+
"""
|
|
114
|
+
return self._id
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def matching_devices(self) -> Dict[str, "BluetoothDevice"]:
|
|
118
|
+
"""
|
|
119
|
+
The dictionary of devices that matched the search window criteria.
|
|
120
|
+
"""
|
|
121
|
+
with self._lock:
|
|
122
|
+
rtnval = self._matching_devices.copy()
|
|
123
|
+
return rtnval
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def presence(self) -> Optional["BluetoothPresence"]:
|
|
127
|
+
"""
|
|
128
|
+
The :class:`BluetoothPresence` object that is associated with this search window.
|
|
129
|
+
Returns None if the presence object has been garbage collected.
|
|
130
|
+
"""
|
|
131
|
+
return self._presence_ref()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def add_device(self, identifier: str, device: "BluetoothDevice"):
|
|
135
|
+
"""
|
|
136
|
+
Add a device to the list of devices found during the search window.
|
|
137
|
+
|
|
138
|
+
:param identifier: The identifier (e.g. address) for the device being added.
|
|
139
|
+
:param device: The device object.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._devices_found[identifier] = device
|
|
144
|
+
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_active(self) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Check if the search window is active.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
with self._lock:
|
|
154
|
+
return self._window_state == BluetoothSearchWindowState.ACTIVE
|
|
155
|
+
|
|
156
|
+
def is_open(self) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if the search window is open (INACTIVE or ACTIVE).
|
|
159
|
+
"""
|
|
160
|
+
with self._lock:
|
|
161
|
+
return self._window_state in (BluetoothSearchWindowState.INACTIVE, BluetoothSearchWindowState.ACTIVE)
|
|
162
|
+
|
|
163
|
+
def start(self):
|
|
164
|
+
"""
|
|
165
|
+
Begin monitoring for search results in a background thread.
|
|
166
|
+
"""
|
|
167
|
+
self._lock.acquire()
|
|
168
|
+
try:
|
|
169
|
+
start_gate = threading.Event()
|
|
170
|
+
start_gate.clear()
|
|
171
|
+
|
|
172
|
+
self._monitor_thread = threading.Thread(target=self._monitor_thread_entry, name="am-bt-search-window-mon", args=(start_gate,), daemon=True)
|
|
173
|
+
self._monitor_thread.start()
|
|
174
|
+
finally:
|
|
175
|
+
self._lock.release()
|
|
176
|
+
|
|
177
|
+
start_gate.wait()
|
|
178
|
+
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def wait(self, timeout: float = None) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Wait for the search window to close.
|
|
185
|
+
|
|
186
|
+
:param timeout: The time in seconds to wait for the search window to close. If None, then wait indefinitely.
|
|
187
|
+
|
|
188
|
+
:returns: ``True`` if the window closed before timeout, otherwise ``False``.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
# Check initial state
|
|
192
|
+
with self._lock:
|
|
193
|
+
if self._window_state == BluetoothSearchWindowState.INACTIVE:
|
|
194
|
+
raise RuntimeError("The search window is not active.")
|
|
195
|
+
|
|
196
|
+
if self._window_state != BluetoothSearchWindowState.ACTIVE:
|
|
197
|
+
# Window is already completed or timed out
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
# Wait for completion without holding the lock
|
|
201
|
+
completed_successfully = self._wait_gate.wait(timeout=timeout)
|
|
202
|
+
|
|
203
|
+
return completed_successfully
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _monitor_thread_entry(self, sgate: threading.Event):
|
|
207
|
+
"""
|
|
208
|
+
The monitor thread entry point.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
with self._lock:
|
|
212
|
+
self._window_state = BluetoothSearchWindowState.ACTIVE
|
|
213
|
+
self._wait_gate.clear()
|
|
214
|
+
|
|
215
|
+
sgate.set()
|
|
216
|
+
|
|
217
|
+
while True:
|
|
218
|
+
with self._lock:
|
|
219
|
+
if self._window_state != BluetoothSearchWindowState.ACTIVE:
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
now = datetime.now()
|
|
223
|
+
|
|
224
|
+
if now > self._end:
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._window_state = BluetoothSearchWindowState.TIMEOUT
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
# Get current state atomically
|
|
230
|
+
with self._lock:
|
|
231
|
+
if self._still_waiting_for is not None:
|
|
232
|
+
checking_for = [hint for hint in self._still_waiting_for]
|
|
233
|
+
current_found = self._devices_found.copy()
|
|
234
|
+
else:
|
|
235
|
+
checking_for = None
|
|
236
|
+
current_found = {}
|
|
237
|
+
|
|
238
|
+
# Process hints
|
|
239
|
+
if checking_for is not None:
|
|
240
|
+
# Hints are device addresses. Compare them in normalised form so a bare
|
|
241
|
+
# address hint matches the qualified key bumble produces (see normalize_address_hint).
|
|
242
|
+
found_by_normalized_address = {}
|
|
243
|
+
for found_identifier, found_device in current_found.items():
|
|
244
|
+
normalized_identifier = normalize_address_hint(found_identifier)
|
|
245
|
+
found_by_normalized_address[normalized_identifier] = found_device
|
|
246
|
+
|
|
247
|
+
matched_hints = []
|
|
248
|
+
matched_devices = {}
|
|
249
|
+
|
|
250
|
+
for nxt_hint in checking_for:
|
|
251
|
+
normalized_hint = normalize_address_hint(nxt_hint)
|
|
252
|
+
if normalized_hint in found_by_normalized_address:
|
|
253
|
+
matched_hints.append(nxt_hint)
|
|
254
|
+
matched_devices[nxt_hint] = found_by_normalized_address[normalized_hint]
|
|
255
|
+
|
|
256
|
+
# Update shared state atomically
|
|
257
|
+
with self._lock:
|
|
258
|
+
if self._still_waiting_for is not None:
|
|
259
|
+
for matched_hint in matched_hints:
|
|
260
|
+
if matched_hint in self._still_waiting_for:
|
|
261
|
+
self._still_waiting_for.remove(matched_hint)
|
|
262
|
+
|
|
263
|
+
self._matching_devices.update(matched_devices)
|
|
264
|
+
|
|
265
|
+
if self._still_waiting_for is not None and len(self._still_waiting_for) == 0:
|
|
266
|
+
self._window_state = BluetoothSearchWindowState.COMPLETED
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
time.sleep(self._monitor_interval)
|
|
270
|
+
|
|
271
|
+
self._wait_gate.set()
|
|
272
|
+
|
|
273
|
+
# You might want to callback to presence here if needed,
|
|
274
|
+
# but for now we won't strictly enforce a search_window_closed callback
|
|
275
|
+
# unless we add it to Presence.
|
|
276
|
+
|
|
277
|
+
return
|
codecraftai_interop_ext_bluetooth-1.2.0/source/packages/ccai/interop/bluetooth/deviceevidence.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
.. module:: bluetoothdeviceevidence
|
|
4
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
5
|
+
:synopsis: Module containing the BluetoothDeviceEvidence object.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__author__ = "Myron Walker"
|
|
12
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
13
|
+
__credits__ = []
|
|
14
|
+
|
|
15
|
+
from typing import Dict, List, Optional, Any
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
class BluetoothDeviceEvidence:
|
|
19
|
+
"""
|
|
20
|
+
A :class:`BluetoothDeviceEvidence` object is used to track evidence of the current state of Bluetooth devices.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, address: str, rssi: int, name: Optional[str] = None,
|
|
24
|
+
service_uuids: Optional[List[str]] = None,
|
|
25
|
+
manufacturer_data: Optional[Dict[int, bytes]] = None,
|
|
26
|
+
timestamp: Optional[datetime] = None):
|
|
27
|
+
"""
|
|
28
|
+
Initialize evidence.
|
|
29
|
+
"""
|
|
30
|
+
self._address = address
|
|
31
|
+
self._rssi = rssi
|
|
32
|
+
self._name = name
|
|
33
|
+
self._service_uuids = service_uuids or []
|
|
34
|
+
self._manufacturer_data = manufacturer_data or {}
|
|
35
|
+
self._timestamp = timestamp if timestamp else datetime.now()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def address(self) -> str:
|
|
39
|
+
return self._address
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def rssi(self) -> int:
|
|
43
|
+
return self._rssi
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> Optional[str]:
|
|
47
|
+
return self._name
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def service_uuids(self) -> List[str]:
|
|
51
|
+
return self._service_uuids
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def manufacturer_data(self) -> Dict[int, bytes]:
|
|
55
|
+
return self._manufacturer_data
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def timestamp(self) -> datetime:
|
|
59
|
+
return self._timestamp
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"address": self._address,
|
|
64
|
+
"rssi": self._rssi,
|
|
65
|
+
"name": self._name,
|
|
66
|
+
"service_uuids": self._service_uuids,
|
|
67
|
+
"timestamp": self._timestamp.isoformat()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def __str__(self):
|
|
71
|
+
return f"<BluetoothDeviceEvidence address={self._address} rssi={self._rssi} name={self._name}>"
|
|
File without changes
|