pysamsung-dplug 0.1.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.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 porech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysamsung-dplug
3
+ Version: 0.1.0
4
+ Summary: Async client for old Samsung air conditioners using the DPLUG/AC14K protocol (TLS, port 2878)
5
+ Project-URL: Homepage, https://github.com/porech/pysamsung-dplug
6
+ Project-URL: Issues, https://github.com/porech/pysamsung-dplug/issues
7
+ Author: porech
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: 2878,ac14k,air conditioner,dplug,home assistant,hvac,samsung
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Home Automation
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pysamsung-dplug
19
+
20
+ Async Python client for **old Samsung air conditioners** that speak the legacy
21
+ **DPLUG / AC14K** protocol over TLS on **port 2878** (Wi-Fi modules such as
22
+ `SWL-B70F`, used by the AR\*\*HSFS generation, ~2013–2015).
23
+
24
+ These units were dropped by SmartThings. This library lets you control them
25
+ locally again — no cloud. It is the protocol layer used by the
26
+ [`samsung_ac_dplug`](https://github.com/porech/samsung_ac_dplug) Home Assistant
27
+ integration, but works standalone.
28
+
29
+ ## Features
30
+
31
+ - Mutual-TLS handshake with the bundled Samsung client certificate (legacy
32
+ TLS 1.0 / weak ciphers handled for you).
33
+ - Token acquisition (`GetToken`), authentication (`AuthToken`).
34
+ - Read full device state (`DeviceState`) and send commands (`DeviceControl`).
35
+ - Auto-discover the device id (`DeviceList`) and a passive `async_probe()`.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install pysamsung-dplug
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ import asyncio
47
+ from samsung_dplug import SamsungAcClient, build_ssl_context
48
+
49
+ async def main():
50
+ ctx = build_ssl_context() # uses the bundled certificate
51
+ client = SamsungAcClient("192.168.1.53", token="xxxxxxxx-....", ssl_context=ctx)
52
+ state = await client.async_get_state()
53
+ print("Power:", state["AC_FUN_POWER"], "Room:", state["AC_FUN_TEMPNOW"])
54
+ await client.async_set("AC_FUN_POWER", "On")
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ### Getting a token
60
+
61
+ The unit only issues a token at **power-on** (a physical proof-of-access step):
62
+
63
+ ```python
64
+ client = SamsungAcClient("192.168.1.53", ssl_context=ctx)
65
+ # turn the unit OFF, call this, then turn it ON within ~30 s:
66
+ token = await client.async_get_token()
67
+ ```
68
+
69
+ > `build_ssl_context()` does blocking file I/O; inside async frameworks run it in
70
+ > an executor (e.g. Home Assistant: `await hass.async_add_executor_job(build_ssl_context)`).
71
+
72
+ ## Protocol notes
73
+
74
+ Reverse-engineered from the official *Smart Air Conditioner* app and live
75
+ devices. The unit greets with `DPLUG-1.x`, requires mutual TLS, and uses an
76
+ XML request/response protocol. The DUID equals the Wi-Fi module MAC without
77
+ separators. See the Home Assistant integration repo for the full write-up,
78
+ including the undocumented `APConnectionConfig` Wi-Fi provisioning command.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,65 @@
1
+ # pysamsung-dplug
2
+
3
+ Async Python client for **old Samsung air conditioners** that speak the legacy
4
+ **DPLUG / AC14K** protocol over TLS on **port 2878** (Wi-Fi modules such as
5
+ `SWL-B70F`, used by the AR\*\*HSFS generation, ~2013–2015).
6
+
7
+ These units were dropped by SmartThings. This library lets you control them
8
+ locally again — no cloud. It is the protocol layer used by the
9
+ [`samsung_ac_dplug`](https://github.com/porech/samsung_ac_dplug) Home Assistant
10
+ integration, but works standalone.
11
+
12
+ ## Features
13
+
14
+ - Mutual-TLS handshake with the bundled Samsung client certificate (legacy
15
+ TLS 1.0 / weak ciphers handled for you).
16
+ - Token acquisition (`GetToken`), authentication (`AuthToken`).
17
+ - Read full device state (`DeviceState`) and send commands (`DeviceControl`).
18
+ - Auto-discover the device id (`DeviceList`) and a passive `async_probe()`.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install pysamsung-dplug
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ import asyncio
30
+ from samsung_dplug import SamsungAcClient, build_ssl_context
31
+
32
+ async def main():
33
+ ctx = build_ssl_context() # uses the bundled certificate
34
+ client = SamsungAcClient("192.168.1.53", token="xxxxxxxx-....", ssl_context=ctx)
35
+ state = await client.async_get_state()
36
+ print("Power:", state["AC_FUN_POWER"], "Room:", state["AC_FUN_TEMPNOW"])
37
+ await client.async_set("AC_FUN_POWER", "On")
38
+
39
+ asyncio.run(main())
40
+ ```
41
+
42
+ ### Getting a token
43
+
44
+ The unit only issues a token at **power-on** (a physical proof-of-access step):
45
+
46
+ ```python
47
+ client = SamsungAcClient("192.168.1.53", ssl_context=ctx)
48
+ # turn the unit OFF, call this, then turn it ON within ~30 s:
49
+ token = await client.async_get_token()
50
+ ```
51
+
52
+ > `build_ssl_context()` does blocking file I/O; inside async frameworks run it in
53
+ > an executor (e.g. Home Assistant: `await hass.async_add_executor_job(build_ssl_context)`).
54
+
55
+ ## Protocol notes
56
+
57
+ Reverse-engineered from the official *Smart Air Conditioner* app and live
58
+ devices. The unit greets with `DPLUG-1.x`, requires mutual TLS, and uses an
59
+ XML request/response protocol. The DUID equals the Wi-Fi module MAC without
60
+ separators. See the Home Assistant integration repo for the full write-up,
61
+ including the undocumented `APConnectionConfig` Wi-Fi provisioning command.
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pysamsung-dplug"
7
+ version = "0.1.0"
8
+ description = "Async client for old Samsung air conditioners using the DPLUG/AC14K protocol (TLS, port 2878)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "porech" }]
13
+ keywords = ["samsung", "air conditioner", "hvac", "dplug", "ac14k", "2878", "home assistant"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Home Automation",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/porech/pysamsung-dplug"
24
+ Issues = "https://github.com/porech/pysamsung-dplug/issues"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/samsung_dplug"]
28
+ artifacts = ["*.pem", "py.typed"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["src/samsung_dplug", "README.md", "LICENSE"]
@@ -0,0 +1,22 @@
1
+ """pysamsung-dplug: async client for old Samsung air conditioners (DPLUG / port 2878)."""
2
+ from .client import (
3
+ DEFAULT_PORT,
4
+ AuthError,
5
+ SamsungAcClient,
6
+ SamsungAcError,
7
+ async_probe,
8
+ build_ssl_context,
9
+ default_cert_path,
10
+ )
11
+
12
+ __all__ = [
13
+ "SamsungAcClient",
14
+ "SamsungAcError",
15
+ "AuthError",
16
+ "build_ssl_context",
17
+ "async_probe",
18
+ "default_cert_path",
19
+ "DEFAULT_PORT",
20
+ ]
21
+
22
+ __version__ = "0.1.0"
@@ -0,0 +1,131 @@
1
+ Bag Attributes
2
+ friendlyName: ac14k_m
3
+ localKeyID: 54 69 6D 65 20 31 34 35 35 34 39 32 33 35 32 36 39 36
4
+ Key Attributes: <No Attributes>
5
+ -----BEGIN PRIVATE KEY-----
6
+ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDeXvhcsqRFWfQt
7
+ Qr2TGW+ePJzrKQVNOZmCGFXrBmOKa2gcZvXqDe71upkCmbXxDZbsqU1nFox6WtKy
8
+ za+JE1EaWIjVFV/D0hnnF+CA56851rFjAx7YYVtd9TwJYV1lSfJaQBU/ecUys0SX
9
+ lKZJtjIoJ/PyLREE79TjOqTxXMXnDpiAt3oiwApZMweJ5z2QViqtRepkI33GDgYc
10
+ LzamIengSG6WZkEUr2roY0il4aVXf3IRVRX+5cJ2L5462kFPKm/UnrXrSsrLwbF9
11
+ ltSlNA8FgpHN3d9ZyqB3MB46oGYyxYYU7a+/R3RAx0joNfVPFe8riQXQcoNEgalb
12
+ c8f7N4HPAgMBAAECggEABL80QA5UMWLNMpYlI9m8Jz2V//MdONvM6hkI5H57a34F
13
+ d+2+vCNWAYrdL1AGsUGgAidPDq9NimMb8lMvtxZhedV//kR5id2XTfaVhUrs06hA
14
+ myN66hWR9LyCbpTUgJAGi2Soz3US/5USFsZGknZANdk8fOP3ZAqWmc8rrDdVxivg
15
+ Z3qjiqgIZg24XsZmnK/QJejP4FLMqm6YUouH//u9xSKvTwkg89qxvygW9xNBNfi/
16
+ LrBHip/k8LnynKRE2odQWt74HcTjbZW4rxXrJ0tqDSIh8bUB23mRjFh1k4aKXnz3
17
+ Y/CDsfxvAutVi85/zyxYaIT6daP+PxvywwgjVhYHIQKBgQDyMxmGdi5kk3ePv0lM
18
+ lC28gVNhgKfhsXL8xIzcd/UM3eEK4baA+AKI9p6ifZ8g90NUJGuHxCp8/9yOKcmk
19
+ tY5toE45nH4fH9Z3j9NtWHMhXJDGWV+DjeiWmshbUqd7/OoIl1vig1npQqX+PXJR
20
+ pwDHnjkQbkyum4k8/IruHx813wKBgQDrCqK0rBkMaarr5eyOJ9BxhCej8i5kCzm7
21
+ XSaNgXtpBIQ4Y4r412M2JWaSLnDxlAc0iUhNGnIn4zkEP2HzX5JU4Yto9YAlRZnu
22
+ NQSvuVgyLiBCbS7WrRAlsNpTeCU3m+c5QNXBzBlHCiTdw3WS4bINOftsB3xnlJ+D
23
+ y/0YZozSEQKBgQCgWV5z3Dh40/0bSVyA+7WQENsgOWpsjOwBFyvfJvgxLZC5gJgw
24
+ qIIdJZH/KEY7MBj+UyJx/1jV6xudb2MVzjHeuHwxvj7t4kk+XRVwVlfa5YrgFvma
25
+ glBTrWQquf0ypE5Zo8PsomPbgAmf2hSepH9qqYFENJJGI6lnnBdq8WXbZwKBgQCR
26
+ p3ye5At9wrnWCB0pFwk4X4JFOd5/xukW8CnlBTmaId9iJmXHwYpM0q6Wpkr9mhNA
27
+ /lYc2eemSkxaEoE71Z0UFtVSzNiFwHUcxiRKVVyPdEAvigO9q2/XO5qAoXLG3ElV
28
+ FJWizD1Z5bJk7yycQlsZkTX6g0UX12VmwnHsvhhEUQKBgF0AVToAk+/OPxlA3N4A
29
+ Xn624Ktxzy/58NSLUfQ57AtL2zivoJzfmhUwgYkPsp+63Wklpcq7X7Q2NB7WscC4
30
+ rICqHxNow/KSzwuR6L3u/kewvlsrgTIM2Pp//+QdTK9GGU3HHAZKaNiB8m20k1Bs
31
+ NTANFxBk7alY0G7ZUhuzWkg6
32
+ -----END PRIVATE KEY-----
33
+ Bag Attributes
34
+ friendlyName: ac14k_m
35
+ localKeyID: 54 69 6D 65 20 31 34 35 35 34 39 32 33 35 32 36 39 36
36
+ subject=/C=KR/O=Samsung Electronics/CN=AC14K_M/emailAddress=AC14K_M@samsung.com
37
+ issuer=/C=KR/O=Samsung Electronics/CN=RemoteAccessCA(CE)
38
+ -----BEGIN CERTIFICATE-----
39
+ MIIDmzCCAoOgAwIBAgIBCTANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQGEwJLUjEc
40
+ MBoGA1UECgwTU2Ftc3VuZyBFbGVjdHJvbmljczEbMBkGA1UEAwwSUmVtb3RlQWNj
41
+ ZXNzQ0EoQ0UpMCIYDzE5NjAwMTAxMDAwMDAwWhgPMjA2MDAxMDEwMDAwMDBaMGEx
42
+ CzAJBgNVBAYTAktSMRwwGgYDVQQKExNTYW1zdW5nIEVsZWN0cm9uaWNzMRAwDgYD
43
+ VQQDFAdBQzE0S19NMSIwIAYJKoZIhvcNAQkBFhNBQzE0S19NQHNhbXN1bmcuY29t
44
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3l74XLKkRVn0LUK9kxlv
45
+ njyc6ykFTTmZghhV6wZjimtoHGb16g3u9bqZApm18Q2W7KlNZxaMelrSss2viRNR
46
+ GliI1RVfw9IZ5xfggOevOdaxYwMe2GFbXfU8CWFdZUnyWkAVP3nFMrNEl5SmSbYy
47
+ KCfz8i0RBO/U4zqk8VzF5w6YgLd6IsAKWTMHiec9kFYqrUXqZCN9xg4GHC82piHp
48
+ 4EhulmZBFK9q6GNIpeGlV39yEVUV/uXCdi+eOtpBTypv1J6160rKy8GxfZbUpTQP
49
+ BYKRzd3fWcqgdzAeOqBmMsWGFO2vv0d0QMdI6DX1TxXvK4kF0HKDRIGpW3PH+zeB
50
+ zwIDAQABo3MwcTAdBgNVHQ4EFgQUXzEjosLzA6xbR1KAqnmAp3BNM6MwHwYDVR0j
51
+ BBgwFoAU/12TkC/BOF7xDaZZWJ+DGN6nMxcwDAYDVR0TBAUwAwEB/zAhBgNVHREE
52
+ GjAYggtzYW1zdW5nLmNvbYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBBQUAA4IBAQBW
53
+ 0mStlbdvrHqDJ+KOKVf0C/y9FKTODqo/6/wJNZeZ+8ezPza4nFq70MwQYTpSbZhz
54
+ 5w8bQP9fwSAoa2Vki8ZwcSd85Vi2tHz9O4C7d7zBA3FU8AL3NoEMFv6OGWGPnTY5
55
+ mG/Hn+LxuwQddlysfbRDds1LBY8DBUJNAmIeeWqA5Eg8DW6xJUwHeXUElJpSXHW6
56
+ XGvpWgAhXqoIf6TirdCrPY6+IzV/FcuVtBDGi+JoxgrMfMLgLEVjeSY96DJinHgZ
57
+ RT0FkA5e06Z+fqHh9Btu+aed+kuGSmya/A5wStOkGeKEbezbbN2gtW07lN6VxX3J
58
+ OCgygA+hmnBVnRDA8Jzu
59
+ -----END CERTIFICATE-----
60
+ Bag Attributes
61
+ friendlyName: CN=RemoteAccessCA(CE),O=Samsung Electronics,C=KR
62
+ subject=/C=KR/O=Samsung Electronics/CN=RemoteAccessCA(CE)
63
+ issuer=/C=KR/O=Samsung Electronics/CN=CECA
64
+ -----BEGIN CERTIFICATE-----
65
+ MIIDUTCCAjmgAwIBAgIBADANBgkqhkiG9w0BAQUFADA6MQswCQYDVQQGEwJLUjEc
66
+ MBoGA1UECgwTU2Ftc3VuZyBFbGVjdHJvbmljczENMAsGA1UEAwwEQ0VDQTAiGA8x
67
+ OTYwMDEwMTAwMDAwMFoYDzIwNjAwMTAxMDAwMDAwWjBIMQswCQYDVQQGEwJLUjEc
68
+ MBoGA1UECgwTU2Ftc3VuZyBFbGVjdHJvbmljczEbMBkGA1UEAwwSUmVtb3RlQWNj
69
+ ZXNzQ0EoQ0UpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtatz9GvV
70
+ qbV395Whnad9MC9TEOiXuwnw37QHvQUwOTFgc6AenX5SORfb4UTw+0ApFNba9DlY
71
+ Xx/K9E5b5DGasDVGGTn+z+6MPB7GuAjkP+WSRwHMjrHRNqrBOr1YJUw3SIbMkRoT
72
+ 460k9AD9DQDBORRtGBGwcBw6BvdasA+/L3Q63aJ7pDoj3qxocdcgk/zFq0OrxFDL
73
+ PMTL7a+a9DS8G10K73XGgES0RBwwhlXXVuLUprD6RgbeLHFsPpIq5vzzEpAYMCF6
74
+ vkZKjDGEW7JVTgUu0E37niN3NQv1gIXlJusDH6RWfFQxENZsdFkT/l+kTuY283Ga
75
+ 2Ei1HsW3Xpt88QIDAQABo1AwTjAdBgNVHQ4EFgQU/12TkC/BOF7xDaZZWJ+DGN6n
76
+ MxcwHwYDVR0jBBgwFoAURwF9jkihypJa2u6zRwKrZwRlACswDAYDVR0TBAUwAwEB
77
+ /zANBgkqhkiG9w0BAQUFAAOCAQEAZkjxN4O92e1RTaXx1mpazyT98sJVl46R51s1
78
+ CTPq35HVfTiBOAu0C5MR6a9vIIFJScy5h69VN4OwDDbMhe/k3m6EfAutlL7lRrre
79
+ OT853HJahxdavzaXJ7tcrI/yDJI0X5GbQ8W74mmDt2/5rXsaB+h+NrToGqf6Hvf/
80
+ m7ZhUnCAt0hhLmltxTVYS25s9KoiIH0rXOb9cqUFsmBMEG2pHWC5AiSc0cXJm+kU
81
+ 3z0B2GS+4IjGdVr3FTPzzTXrpqq/X1cIVKAum5WfsFMS0CRvqTVNVwYg52n69T2B
82
+ NPCCEpp9rsIieZ58jsnc506Uc+1Vp+NmBI2A/ecypZxSb6v9gg==
83
+ -----END CERTIFICATE-----
84
+ Bag Attributes
85
+ friendlyName: CN=CECA,O=Samsung Electronics,C=KR
86
+ subject=/C=KR/O=Samsung Electronics/CN=CECA
87
+ issuer=/C=KR/O=Samsung Electronics/CN=ROOTCA
88
+ -----BEGIN CERTIFICATE-----
89
+ MIIDRTCCAi2gAwIBAgIBBDANBgkqhkiG9w0BAQUFADA8MQswCQYDVQQGEwJLUjEc
90
+ MBoGA1UECgwTU2Ftc3VuZyBFbGVjdHJvbmljczEPMA0GA1UEAwwGUk9PVENBMCIY
91
+ DzE5NjAwMTAxMDAwMDAwWhgPMjA2MDAxMDEwMDAwMDBaMDoxCzAJBgNVBAYTAktS
92
+ MRwwGgYDVQQKDBNTYW1zdW5nIEVsZWN0cm9uaWNzMQ0wCwYDVQQDDARDRUNBMIIB
93
+ IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtv1WJJ7tTs/aa1ZZRjMPPLeb
94
+ n/Ev0Y28CSBj/6P031/veZSg/2z65QZUvPjv8MZnIgNoMpxMGbPPO4Dxj+QJthBk
95
+ WydWRPguPyE+w3U4SdayZXWpLZTpKfHco3CklFwEqZtG/wTxHD1oOvtT0e2g5c79
96
+ hNQt9lQ4Wwzqa3MvQd0JyeB4syy2zRLo5NjJZl1BVn2oTt4xGCjjtAXtAqqHEbEf
97
+ pcvB3hPdIpFe6M8zuN22kROKaQ5i4XP4CyEpbFlgKRcWBGQFX3I5f5TdD3Yw1Ril
98
+ OLLL9wFsJ+iWLka9tAIcJKCNOf48p7aXm6COFwmjtCNu4wjQozwi6cycKUgxNQID
99
+ AQABo1AwTjAdBgNVHQ4EFgQURwF9jkihypJa2u6zRwKrZwRlACswHwYDVR0jBBgw
100
+ FoAU7andrmFFrxYM8+93lrn/Fq47sXMwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B
101
+ AQUFAAOCAQEATexseQBXSfUR7fFTFxq6aAvHWIN+h3QLeN1sq8KCM4fbdkH3lOUP
102
+ rKW3w1ag62bnJVNjT4xPtzH/DyrqlzQUPTb7S0PfIXt2mu/VURnrmuXidS2grNwv
103
+ eu10gURZaz9N2UZEhY7E80tUZwcjAV+YP8+x3/iRQSrWvcMma/r01eUnwrF4xaE9
104
+ EYtJ/jTRre8MpEH/lg06m+rZf9Lk/yhG6at0YnUAIytThqFV4Cj8T8jBX+KG8BCo
105
+ VyUsFyrO+D6X98gMdTZnLqC1P1iWuxyrOWZTgsf44f5GXzmLqe5KLPvkDb4MywTa
106
+ nXrSOPSkcIgvS6WYw2Rii+e6lfVzqmhAmg==
107
+ -----END CERTIFICATE-----
108
+ Bag Attributes
109
+ friendlyName: CN=ROOTCA,O=Samsung Electronics,C=KR
110
+ subject=/C=KR/O=Samsung Electronics/CN=ROOTCA
111
+ issuer=/C=KR/O=Samsung Electronics/CN=ROOTCA
112
+ -----BEGIN CERTIFICATE-----
113
+ MIIDRzCCAi+gAwIBAgIBADANBgkqhkiG9w0BAQUFADA8MQswCQYDVQQGEwJLUjEc
114
+ MBoGA1UECgwTU2Ftc3VuZyBFbGVjdHJvbmljczEPMA0GA1UEAwwGUk9PVENBMCIY
115
+ DzE5NjAwMTAxMDAwMDAwWhgPMjA2MDAxMDEwMDAwMDBaMDwxCzAJBgNVBAYTAktS
116
+ MRwwGgYDVQQKDBNTYW1zdW5nIEVsZWN0cm9uaWNzMQ8wDQYDVQQDDAZST09UQ0Ew
117
+ ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCd67g2hzhbIeSBoFfeqbXi
118
+ tzbO4dCWeCigVfmwEDhR1SDA0MfHOVlFpvuFr3WyFPvQZ0ccNrsTpBs5YieI/jZi
119
+ FYWO0ktbqQorL1CIFqBL9kAF+34BYtpl98PgJ1grLOH5T3GugJA7Irw0plEFmOfs
120
+ IydlUIQHl3oqyMIWPa2nIZ/FGi3hAquEPrvzHZB+QO4c+6tV1WLIaCjn88xkYuwz
121
+ uGYxaqJpnGdqhjZRIuHb2DEZPlP1VGdTTAttno36CyWqeHrSC8fXCSu55Zk+1rbC
122
+ Py/phOJjSyce2qk0IebETAYLCLqU7ABJxUxrolMrP37OB+Kqe4RWovaeMcdcNOOt
123
+ AgMBAAGjUDBOMB0GA1UdDgQWBBTtqd2uYUWvFgzz73eWuf8WrjuxczAfBgNVHSME
124
+ GDAWgBTtqd2uYUWvFgzz73eWuf8WrjuxczAMBgNVHRMEBTADAQH/MA0GCSqGSIb3
125
+ DQEBBQUAA4IBAQBkwK95x8JCAnY0F2bMwG5+7QfY+ci8s8m1ODi3v19HECS6nG9j
126
+ SXgwihEtQ3HqvUler+n7aOeAZlgm+BymM2GvuicveYN/nevIvzlpMOn2L6xU19/H
127
+ zM2eoDVfS49+i/cwoi/A7fcZmIYggZho2UJR/GvKc79g6EAhT7/i5alBZF0enMsA
128
+ 9okzakb/aohQE9SzsEHnhVKpGAjvu0/TJK9WwX6mkiIEJY+mzQMWgEeQt6WWIgAb
129
+ gSX9NueH80tpZ9KqFnqnOoLxTAa7k0RPBRwyUO9CDhSnlWIEcsD9sqR2M+niOFnT
130
+ KBHcLDDiEU3llprD8FRV3unYrl0F0B2GGdRk
131
+ -----END CERTIFICATE-----
@@ -0,0 +1,204 @@
1
+ """Async client for the Samsung 'DPLUG' air-conditioner protocol (TLS, port 2878).
2
+
3
+ Reverse-engineered from the AC14K / DPLUG-1.x firmware used by older Samsung
4
+ air conditioners with the SWL-Bxxx Wi-Fi modules. Mutual TLS using the public
5
+ Samsung client certificate (bundled ac14k_m.pem), with legacy ciphers enabled.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import re
12
+ import ssl
13
+ from importlib import resources
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ DEFAULT_PORT = 2878
18
+ _TERM = b"\r\n"
19
+ _ATTR_RE = re.compile(r'Attr ID="([^"]*)" Type="([^"]*)" Value="([^"]*)"')
20
+ _DUID_RE = re.compile(r'Device DUID="([^"]*)"')
21
+ _TOKEN_RE = re.compile(r'Token="([^"]*)"')
22
+
23
+
24
+ class SamsungAcError(Exception):
25
+ """Generic protocol error."""
26
+
27
+
28
+ class AuthError(SamsungAcError):
29
+ """Authentication (token) rejected."""
30
+
31
+
32
+ def default_cert_path() -> str:
33
+ """Path to the bundled Samsung client certificate."""
34
+ return str(resources.files(__package__).joinpath("ac14k_m.pem"))
35
+
36
+
37
+ def build_ssl_context(cert_path: str | None = None) -> ssl.SSLContext:
38
+ """Build the legacy mutual-TLS context. Blocking (file I/O) -> run in executor."""
39
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
40
+ ctx.check_hostname = False
41
+ ctx.verify_mode = ssl.CERT_NONE
42
+ ctx.minimum_version = ssl.TLSVersion.TLSv1
43
+ ctx.set_ciphers("HIGH:!DH:!aNULL:@SECLEVEL=0")
44
+ ctx.load_cert_chain(cert_path or default_cert_path())
45
+ return ctx
46
+
47
+
48
+ async def async_probe(host: str, ssl_context: ssl.SSLContext, port: int = DEFAULT_PORT) -> bool:
49
+ """Return True if `host` speaks the DPLUG protocol (used by discovery)."""
50
+ try:
51
+ reader, writer = await asyncio.wait_for(
52
+ asyncio.open_connection(host, port, ssl=ssl_context, server_hostname=host),
53
+ timeout=8,
54
+ )
55
+ except (OSError, asyncio.TimeoutError, ssl.SSLError):
56
+ return False
57
+ try:
58
+ line = await asyncio.wait_for(reader.readuntil(_TERM), 5)
59
+ return "DPLUG" in line.decode("utf-8", "replace")
60
+ except Exception: # noqa: BLE001
61
+ return False
62
+ finally:
63
+ writer.close()
64
+
65
+
66
+ class SamsungAcClient:
67
+ """Short-lived-connection client: connect, auth, do one exchange, close.
68
+
69
+ Serialised with a lock so polling and commands never overlap on the device
70
+ (the module accepts essentially one connection at a time).
71
+ """
72
+
73
+ def __init__(self, host, token=None, ssl_context=None, duid=None, port=DEFAULT_PORT):
74
+ self._host = host
75
+ self._port = port
76
+ self._token = token
77
+ self._ctx = ssl_context
78
+ self._duid = duid
79
+ self._lock = asyncio.Lock()
80
+
81
+ @property
82
+ def duid(self):
83
+ return self._duid
84
+
85
+ async def _readline(self, reader, timeout=5.0):
86
+ data = await asyncio.wait_for(reader.readuntil(_TERM), timeout)
87
+ return data.decode("utf-8", "replace").strip()
88
+
89
+ async def _connect(self):
90
+ reader, writer = await asyncio.wait_for(
91
+ asyncio.open_connection(
92
+ self._host, self._port, ssl=self._ctx, server_hostname=self._host
93
+ ),
94
+ timeout=10,
95
+ )
96
+ greeting = await self._readline(reader)
97
+ if "DPLUG" not in greeting:
98
+ writer.close()
99
+ raise SamsungAcError(f"Unexpected greeting: {greeting!r}")
100
+ return reader, writer
101
+
102
+ async def _authenticate(self, reader, writer):
103
+ line = await self._readline(reader)
104
+ if "InvalidateAccount" not in line:
105
+ line = await self._readline(reader)
106
+ writer.write(
107
+ f'<Request Type="AuthToken"><User Token="{self._token}"/></Request>'.encode()
108
+ + _TERM
109
+ )
110
+ await writer.drain()
111
+ for _ in range(4):
112
+ line = await self._readline(reader)
113
+ if 'Type="AuthToken"' in line and 'Status="Okay"' in line:
114
+ return
115
+ if 'Status="Fail"' in line and "Auth" in line:
116
+ raise AuthError(f"Token rejected: {line}")
117
+ raise AuthError("No AuthToken Okay received")
118
+
119
+ @staticmethod
120
+ def _parse_state(line: str) -> dict:
121
+ return {m[0]: m[2] for m in _ATTR_RE.findall(line)}
122
+
123
+ async def _read_until(self, reader, needle, timeout=6.0):
124
+ loop = asyncio.get_running_loop()
125
+ end = loop.time() + timeout
126
+ while loop.time() < end:
127
+ line = await self._readline(reader, timeout=timeout)
128
+ if needle in line:
129
+ return line
130
+ raise SamsungAcError(f"Timeout waiting for {needle}")
131
+
132
+ async def async_discover_duid(self):
133
+ async with self._lock:
134
+ reader, writer = await self._connect()
135
+ try:
136
+ await self._authenticate(reader, writer)
137
+ writer.write(b'<Request Type="DeviceList"></Request>' + _TERM)
138
+ await writer.drain()
139
+ line = await self._read_until(reader, "DeviceList")
140
+ m = _DUID_RE.search(line)
141
+ if not m:
142
+ raise SamsungAcError(f"No DUID in DeviceList: {line}")
143
+ self._duid = m.group(1)
144
+ return self._duid
145
+ finally:
146
+ writer.close()
147
+
148
+ async def async_get_state(self) -> dict:
149
+ if not self._duid:
150
+ await self.async_discover_duid()
151
+ async with self._lock:
152
+ reader, writer = await self._connect()
153
+ try:
154
+ await self._authenticate(reader, writer)
155
+ writer.write(
156
+ f'<Request Type="DeviceState" DUID="{self._duid}"></Request>'.encode()
157
+ + _TERM
158
+ )
159
+ await writer.drain()
160
+ line = await self._read_until(reader, 'Type="DeviceState"')
161
+ if 'Status="Okay"' not in line:
162
+ raise SamsungAcError(f"DeviceState failed: {line}")
163
+ return self._parse_state(line)
164
+ finally:
165
+ writer.close()
166
+
167
+ async def async_set(self, attr: str, value: str) -> None:
168
+ if not self._duid:
169
+ await self.async_discover_duid()
170
+ async with self._lock:
171
+ reader, writer = await self._connect()
172
+ try:
173
+ await self._authenticate(reader, writer)
174
+ cmd = (
175
+ f'<Request Type="DeviceControl"><Control CommandID="cmd" '
176
+ f'DUID="{self._duid}"><Attr ID="{attr}" Value="{value}" />'
177
+ f"</Control></Request>"
178
+ )
179
+ writer.write(cmd.encode() + _TERM)
180
+ await writer.drain()
181
+ line = await self._read_until(reader, 'Type="DeviceControl"')
182
+ if 'Status="Okay"' not in line:
183
+ raise SamsungAcError(f"Control {attr}={value} failed: {line}")
184
+ finally:
185
+ writer.close()
186
+
187
+ async def async_get_token(self, power_on_timeout=40.0) -> str:
188
+ """One-shot token acquisition. User must power the unit ON during the window."""
189
+ async with self._lock:
190
+ reader, writer = await self._connect()
191
+ try:
192
+ line = await self._readline(reader)
193
+ if "InvalidateAccount" not in line:
194
+ line = await self._readline(reader)
195
+ writer.write(b'<Request Type="GetToken" />' + _TERM)
196
+ await writer.drain()
197
+ await self._read_until(reader, 'Type="GetToken"') # Ready
198
+ token_line = await self._read_until(reader, "Token=", timeout=power_on_timeout)
199
+ m = _TOKEN_RE.search(token_line)
200
+ if not m:
201
+ raise SamsungAcError(f"No token in: {token_line}")
202
+ return m.group(1)
203
+ finally:
204
+ writer.close()
File without changes