Skip to content

Commit fe040d8

Browse files
authored
Merge pull request #1 from adafruit/connection-manager
Create ConnectionManager
2 parents 8cc811e + 9c6adc7 commit fe040d8

30 files changed

+1458
-75
lines changed

.coveragerc

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: Unlicense
4+
5+
[report]
6+
exclude_lines =
7+
# pragma: no cover
8+
if not sys.implementation.name == "circuitpython":

.gitignore

+7
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,10 @@ _build
4646
.idea
4747
.vscode
4848
*~
49+
50+
# tox-specific files
51+
.tox
52+
build
53+
54+
# coverage-specific files
55+
.coverage

.pre-commit-config.yaml

+8-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44

55
repos:
66
- repo: https://github.com/python/black
7-
rev: 23.3.0
7+
rev: 24.2.0
88
hooks:
99
- id: black
10+
- repo: https://github.com/PyCQA/isort
11+
rev: 5.13.2
12+
hooks:
13+
- id: isort
14+
args: ["--profile", "black", "--filter-files"]
1015
- repo: https://github.com/fsfe/reuse-tool
1116
rev: v1.1.2
1217
hooks:
@@ -32,11 +37,11 @@ repos:
3237
types: [python]
3338
files: "^examples/"
3439
args:
35-
- --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code
40+
- --disable=consider-using-f-string,duplicate-code,missing-docstring,invalid-name,
3641
- id: pylint
3742
name: pylint (test code)
3843
description: Run pylint rules on "tests/*.py" files
3944
types: [python]
4045
files: "^tests/"
4146
args:
42-
- --disable=missing-docstring,consider-using-f-string,duplicate-code
47+
- --disable=consider-using-f-string,duplicate-code,missing-docstring,invalid-name,protected-access

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
The MIT License (MIT)
22

3-
Copyright (c) 2023 Justin Myers for Adafruit Industries
3+
Copyright (c) 2024 Justin Myers for Adafruit Industries
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.rst

+5-9
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,13 @@ This is easily achieved by downloading
3636
or individual libraries can be installed using
3737
`circup <https://github.com/adafruit/circup>`_.
3838

39-
40-
41-
.. todo:: Describe the Adafruit product this library works with. For PCBs, you can also add the
42-
image from the assets folder in the PCB's GitHub repo.
43-
4439
`Purchase one from the Adafruit shop <http://www.adafruit.com/products/>`_
4540

4641
Installing from PyPI
4742
=====================
4843
.. note:: This library is not available on PyPI yet. Install documentation is included
4944
as a standard element. Stay tuned for PyPI availability!
5045

51-
.. todo:: Remove the above note if PyPI version is/will be available at time of release.
52-
5346
On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
5447
PyPI <https://pypi.org/project/adafruit-circuitpython-connectionmanager/>`_.
5548
To install for current user:
@@ -99,8 +92,11 @@ Or the following command to update an existing version:
9992
Usage Example
10093
=============
10194

102-
.. todo:: Add a quick, simple example. It and other examples should live in the
103-
examples folder and be included in docs/examples.rst.
95+
This library is used internally by libraries like `Adafruit_CircuitPython_Requests
96+
<https://github.com/adafruit/Adafruit_CircuitPython_Requests>`_ and `Adafruit_CircuitPython_MiniMQTT
97+
<https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT>`_
98+
99+
Usage examples are within the `examples` subfolder of this library.
104100

105101
Documentation
106102
=============

README.rst.license

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
2-
SPDX-FileCopyrightText: Copyright (c) 2023 Justin Myers for Adafruit Industries
2+
SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
33
SPDX-License-Identifier: MIT

adafruit_connection_manager.py

+300
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
2+
# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries
3+
#
4+
# SPDX-License-Identifier: MIT
5+
"""
6+
`adafruit_connection_manager`
7+
================================================================================
8+
9+
A urllib3.poolmanager/urllib3.connectionpool-like library for managing sockets and connections
10+
11+
12+
* Author(s): Justin Myers
13+
14+
Implementation Notes
15+
--------------------
16+
17+
**Software and Dependencies:**
18+
19+
* Adafruit CircuitPython firmware for the supported boards:
20+
https://circuitpython.org/downloads
21+
22+
"""
23+
24+
# imports
25+
26+
__version__ = "0.0.0+auto.0"
27+
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager.git"
28+
29+
import errno
30+
import sys
31+
32+
# typing
33+
34+
35+
if not sys.implementation.name == "circuitpython":
36+
from typing import Optional, Tuple
37+
38+
from circuitpython_typing.socket import (
39+
CircuitPythonSocketType,
40+
InterfaceType,
41+
SocketpoolModuleType,
42+
SocketType,
43+
SSLContextType,
44+
)
45+
46+
47+
# ssl and pool helpers
48+
49+
50+
class _FakeSSLSocket:
51+
def __init__(self, socket: CircuitPythonSocketType, tls_mode: int) -> None:
52+
self._socket = socket
53+
self._mode = tls_mode
54+
self.settimeout = socket.settimeout
55+
self.send = socket.send
56+
self.recv = socket.recv
57+
self.close = socket.close
58+
self.recv_into = socket.recv_into
59+
60+
def connect(self, address: Tuple[str, int]) -> None:
61+
"""Connect wrapper to add non-standard mode parameter"""
62+
try:
63+
return self._socket.connect(address, self._mode)
64+
except RuntimeError as error:
65+
raise OSError(errno.ENOMEM) from error
66+
67+
68+
class _FakeSSLContext:
69+
def __init__(self, iface: InterfaceType) -> None:
70+
self._iface = iface
71+
72+
# pylint: disable=unused-argument
73+
def wrap_socket(
74+
self, socket: CircuitPythonSocketType, server_hostname: Optional[str] = None
75+
) -> _FakeSSLSocket:
76+
"""Return the same socket"""
77+
if hasattr(self._iface, "TLS_MODE"):
78+
return _FakeSSLSocket(socket, self._iface.TLS_MODE)
79+
80+
raise AttributeError("This radio does not support TLS/HTTPS")
81+
82+
83+
def create_fake_ssl_context(
84+
socket_pool: SocketpoolModuleType, iface: InterfaceType
85+
) -> _FakeSSLContext:
86+
"""Method to return a fake SSL context for when ssl isn't available to import
87+
88+
For example when using a:
89+
90+
* `Adafruit Ethernet FeatherWing <https://www.adafruit.com/product/3201>`_
91+
* `Adafruit AirLift – ESP32 WiFi Co-Processor Breakout Board
92+
<https://www.adafruit.com/product/4201>`_
93+
* `Adafruit AirLift FeatherWing – ESP32 WiFi Co-Processor
94+
<https://www.adafruit.com/product/4264>`_
95+
"""
96+
socket_pool.set_interface(iface)
97+
return _FakeSSLContext(iface)
98+
99+
100+
_global_socketpool = {}
101+
_global_ssl_contexts = {}
102+
103+
104+
def get_radio_socketpool(radio):
105+
"""Helper to get a socket pool for common boards
106+
107+
Currently supported:
108+
109+
* Boards with onboard WiFi (ESP32S2, ESP32S3, Pico W, etc)
110+
* Using the ESP32 WiFi Co-Processor (like the Adafruit AirLift)
111+
* Using a WIZ5500 (Like the Adafruit Ethernet FeatherWing)
112+
"""
113+
class_name = radio.__class__.__name__
114+
if class_name not in _global_socketpool:
115+
if class_name == "Radio":
116+
import ssl # pylint: disable=import-outside-toplevel
117+
118+
import socketpool # pylint: disable=import-outside-toplevel
119+
120+
pool = socketpool.SocketPool(radio)
121+
ssl_context = ssl.create_default_context()
122+
123+
elif class_name == "ESP_SPIcontrol":
124+
import adafruit_esp32spi.adafruit_esp32spi_socket as pool # pylint: disable=import-outside-toplevel
125+
126+
ssl_context = create_fake_ssl_context(pool, radio)
127+
128+
elif class_name == "WIZNET5K":
129+
import adafruit_wiznet5k.adafruit_wiznet5k_socket as pool # pylint: disable=import-outside-toplevel
130+
131+
# Note: SSL/TLS connections are not supported by the Wiznet5k library at this time
132+
ssl_context = create_fake_ssl_context(pool, radio)
133+
134+
else:
135+
raise AttributeError(f"Unsupported radio class: {class_name}")
136+
137+
_global_socketpool[class_name] = pool
138+
_global_ssl_contexts[class_name] = ssl_context
139+
140+
return _global_socketpool[class_name]
141+
142+
143+
def get_radio_ssl_context(radio):
144+
"""Helper to get ssl_contexts for common boards
145+
146+
Currently supported:
147+
148+
* Boards with onboard WiFi (ESP32S2, ESP32S3, Pico W, etc)
149+
* Using the ESP32 WiFi Co-Processor (like the Adafruit AirLift)
150+
* Using a WIZ5500 (Like the Adafruit Ethernet FeatherWing)
151+
"""
152+
class_name = radio.__class__.__name__
153+
get_radio_socketpool(radio)
154+
return _global_ssl_contexts[class_name]
155+
156+
157+
# main class
158+
159+
160+
class ConnectionManager:
161+
"""Connection manager for sharing open sockets (aka connections)."""
162+
163+
def __init__(
164+
self,
165+
socket_pool: SocketpoolModuleType,
166+
) -> None:
167+
self._socket_pool = socket_pool
168+
# Hang onto open sockets so that we can reuse them.
169+
self._available_socket = {}
170+
self._open_sockets = {}
171+
172+
def _free_sockets(self) -> None:
173+
available_sockets = []
174+
for socket, free in self._available_socket.items():
175+
if free:
176+
available_sockets.append(socket)
177+
178+
for socket in available_sockets:
179+
self.close_socket(socket)
180+
181+
def _get_key_for_socket(self, socket):
182+
try:
183+
return next(
184+
key for key, value in self._open_sockets.items() if value == socket
185+
)
186+
except StopIteration:
187+
return None
188+
189+
def close_socket(self, socket: SocketType) -> None:
190+
"""Close a previously opened socket."""
191+
if socket not in self._open_sockets.values():
192+
raise RuntimeError("Socket not managed")
193+
key = self._get_key_for_socket(socket)
194+
socket.close()
195+
del self._available_socket[socket]
196+
del self._open_sockets[key]
197+
198+
def free_socket(self, socket: SocketType) -> None:
199+
"""Mark a previously opened socket as available so it can be reused if needed."""
200+
if socket not in self._open_sockets.values():
201+
raise RuntimeError("Socket not managed")
202+
self._available_socket[socket] = True
203+
204+
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
205+
def get_socket(
206+
self,
207+
host: str,
208+
port: int,
209+
proto: str,
210+
session_id: Optional[str] = None,
211+
*,
212+
timeout: float = 1,
213+
is_ssl: bool = False,
214+
ssl_context: Optional[SSLContextType] = None,
215+
) -> CircuitPythonSocketType:
216+
"""Get a new socket and connect"""
217+
if session_id:
218+
session_id = str(session_id)
219+
key = (host, port, proto, session_id)
220+
if key in self._open_sockets:
221+
socket = self._open_sockets[key]
222+
if self._available_socket[socket]:
223+
self._available_socket[socket] = False
224+
return socket
225+
226+
raise RuntimeError(f"Socket already connected to {proto}//{host}:{port}")
227+
228+
if proto == "https:":
229+
is_ssl = True
230+
if is_ssl and not ssl_context:
231+
raise AttributeError(
232+
"ssl_context must be set before using adafruit_requests for https"
233+
)
234+
235+
addr_info = self._socket_pool.getaddrinfo(
236+
host, port, 0, self._socket_pool.SOCK_STREAM
237+
)[0]
238+
239+
try_count = 0
240+
socket = None
241+
last_exc = None
242+
while try_count < 2 and socket is None:
243+
try_count += 1
244+
if try_count > 1:
245+
if any(
246+
socket
247+
for socket, free in self._available_socket.items()
248+
if free is True
249+
):
250+
self._free_sockets()
251+
else:
252+
break
253+
254+
try:
255+
socket = self._socket_pool.socket(addr_info[0], addr_info[1])
256+
except OSError as exc:
257+
last_exc = exc
258+
continue
259+
except RuntimeError as exc:
260+
last_exc = exc
261+
continue
262+
263+
if is_ssl:
264+
socket = ssl_context.wrap_socket(socket, server_hostname=host)
265+
connect_host = host
266+
else:
267+
connect_host = addr_info[-1][0]
268+
socket.settimeout(timeout) # socket read timeout
269+
270+
try:
271+
socket.connect((connect_host, port))
272+
except MemoryError as exc:
273+
last_exc = exc
274+
socket.close()
275+
socket = None
276+
except OSError as exc:
277+
last_exc = exc
278+
socket.close()
279+
socket = None
280+
281+
if socket is None:
282+
raise RuntimeError(f"Error connecting socket: {last_exc}") from last_exc
283+
284+
self._available_socket[socket] = False
285+
self._open_sockets[key] = socket
286+
return socket
287+
288+
289+
# global helpers
290+
291+
292+
_global_connection_manager = None # pylint: disable=invalid-name
293+
294+
295+
def get_connection_manager(socket_pool: SocketpoolModuleType) -> None:
296+
"""Get the ConnectionManager singleton"""
297+
global _global_connection_manager # pylint: disable=global-statement
298+
if _global_connection_manager is None:
299+
_global_connection_manager = ConnectionManager(socket_pool)
300+
return _global_connection_manager

0 commit comments

Comments
 (0)