rf-protocols 2.0.0__tar.gz → 2.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.
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/PKG-INFO +2 -1
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/pyproject.toml +5 -2
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/__init__.py +2 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/loader.py +16 -48
- rf_protocols-2.1.0/rf_protocols/parser.py +52 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols.egg-info/PKG-INFO +2 -1
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols.egg-info/SOURCES.txt +3 -1
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols.egg-info/requires.txt +1 -0
- rf_protocols-2.1.0/tests/test_loader.py +111 -0
- rf_protocols-2.1.0/tests/test_parser.py +63 -0
- rf_protocols-2.0.0/tests/test_loader.py +0 -166
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/LICENSE +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/README.md +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/codes/honeywell/string_lights/turn_off.sub +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/codes/honeywell/string_lights/turn_on.sub +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/commands.py +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols.egg-info/dependency_links.txt +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols.egg-info/top_level.txt +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/setup.cfg +0 -0
- {rf_protocols-2.0.0 → rf_protocols-2.1.0}/tests/test_commands.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rf-protocols
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Library to decode and encode radio frequency signals.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/home-assistant-libs/rf-protocols
|
|
@@ -11,6 +11,7 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
14
15
|
Requires-Dist: prek; extra == "dev"
|
|
15
16
|
Dynamic: license-file
|
|
16
17
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rf-protocols"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
description = "Library to decode and encode radio frequency signals."
|
|
10
10
|
readme = "README.md"
|
|
@@ -46,4 +46,7 @@ max-complexity = 25
|
|
|
46
46
|
typeCheckingMode = "standard"
|
|
47
47
|
|
|
48
48
|
[project.optional-dependencies]
|
|
49
|
-
dev = ["pytest", "prek"]
|
|
49
|
+
dev = ["pytest", "pytest-asyncio", "prek"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .commands import ModulationType, OOKCommand, RadioFrequencyCommand
|
|
4
4
|
from .loader import CodeCollection, get_codes
|
|
5
|
+
from .parser import parse_sub_content
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"CodeCollection",
|
|
@@ -9,4 +10,5 @@ __all__ = [
|
|
|
9
10
|
"OOKCommand",
|
|
10
11
|
"RadioFrequencyCommand",
|
|
11
12
|
"get_codes",
|
|
13
|
+
"parse_sub_content",
|
|
12
14
|
]
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
7
7
|
|
|
8
|
-
from .commands import
|
|
8
|
+
from .commands import RadioFrequencyCommand
|
|
9
|
+
from .parser import parse_sub_content
|
|
9
10
|
|
|
10
11
|
_DEFAULT_BASE_DIR = Path(__file__).parent / "codes"
|
|
11
12
|
|
|
@@ -34,10 +35,22 @@ class CodeCollection:
|
|
|
34
35
|
sub_file = self._codes_dir / f"{name.lower()}.sub"
|
|
35
36
|
if not sub_file.is_file():
|
|
36
37
|
raise KeyError(f"No command {name!r} in {self._codes_dir}")
|
|
37
|
-
cmd =
|
|
38
|
+
cmd = parse_sub_content(sub_file.read_text())
|
|
38
39
|
self._cache[name] = cmd
|
|
39
40
|
return cmd
|
|
40
41
|
|
|
42
|
+
async def async_load_command(self, name: str) -> RadioFrequencyCommand:
|
|
43
|
+
"""Async variant of :meth:`load_command`.
|
|
44
|
+
|
|
45
|
+
Returns the cached command synchronously when available; otherwise
|
|
46
|
+
reads and parses the ``.sub`` file in the default executor.
|
|
47
|
+
"""
|
|
48
|
+
if (cached := self._cache.get(name)) is not None:
|
|
49
|
+
return cached
|
|
50
|
+
return await asyncio.get_running_loop().run_in_executor(
|
|
51
|
+
None, self.load_command, name
|
|
52
|
+
)
|
|
53
|
+
|
|
41
54
|
def __repr__(self) -> str:
|
|
42
55
|
"""Return a concise representation."""
|
|
43
56
|
return f"CodeCollection({self._codes_dir})"
|
|
@@ -57,48 +70,3 @@ def get_codes(name: str, base_dir: Path | str | None = None) -> CodeCollection:
|
|
|
57
70
|
if not codes_dir.is_dir():
|
|
58
71
|
raise FileNotFoundError(f"No codes directory at {codes_dir}")
|
|
59
72
|
return CodeCollection(codes_dir)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _parse_sub_content(content: str) -> RadioFrequencyCommand:
|
|
63
|
-
"""Parse a Flipper ``.sub`` file into a :class:`RadioFrequencyCommand`."""
|
|
64
|
-
fields: dict[str, str] = {}
|
|
65
|
-
raw_parts: list[str] = []
|
|
66
|
-
for raw_line in content.splitlines():
|
|
67
|
-
line = raw_line.strip()
|
|
68
|
-
if not line:
|
|
69
|
-
continue
|
|
70
|
-
key, sep, value = line.partition(":")
|
|
71
|
-
if not sep:
|
|
72
|
-
continue
|
|
73
|
-
key = key.strip()
|
|
74
|
-
value = value.strip()
|
|
75
|
-
if key == "RAW_Data":
|
|
76
|
-
raw_parts.append(value)
|
|
77
|
-
else:
|
|
78
|
-
fields[key] = value
|
|
79
|
-
|
|
80
|
-
protocol = fields.get("Protocol", "")
|
|
81
|
-
if protocol != "RAW":
|
|
82
|
-
raise ValueError(f"Unsupported Protocol {protocol!r}; only RAW is supported")
|
|
83
|
-
|
|
84
|
-
preset = fields.get("Preset", "")
|
|
85
|
-
if "Ook" not in preset:
|
|
86
|
-
raise ValueError(
|
|
87
|
-
f"Unsupported Preset {preset!r}; only OOK presets are supported"
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
if "Frequency" not in fields:
|
|
91
|
-
raise ValueError("Missing Frequency field")
|
|
92
|
-
if not raw_parts:
|
|
93
|
-
raise ValueError("Missing RAW_Data field")
|
|
94
|
-
|
|
95
|
-
timings = [int(v) for part in raw_parts for v in part.split()]
|
|
96
|
-
if len(timings) % 2 != 0:
|
|
97
|
-
raise ValueError("RAW_Data must contain an even number of values")
|
|
98
|
-
|
|
99
|
-
kwargs: dict[str, Any] = {
|
|
100
|
-
"frequency": int(fields["Frequency"]),
|
|
101
|
-
"timings": timings,
|
|
102
|
-
"repeat_count": int(fields.get("Repeat", "0")),
|
|
103
|
-
}
|
|
104
|
-
return OOKCommand(**kwargs)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Parse Flipper ``.sub`` file contents into :class:`RadioFrequencyCommand`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .commands import OOKCommand, RadioFrequencyCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_sub_content(content: str) -> RadioFrequencyCommand:
|
|
11
|
+
"""Parse a Flipper ``.sub`` file into a :class:`RadioFrequencyCommand`."""
|
|
12
|
+
fields: dict[str, str] = {}
|
|
13
|
+
raw_parts: list[str] = []
|
|
14
|
+
for raw_line in content.splitlines():
|
|
15
|
+
line = raw_line.strip()
|
|
16
|
+
if not line:
|
|
17
|
+
continue
|
|
18
|
+
key, sep, value = line.partition(":")
|
|
19
|
+
if not sep:
|
|
20
|
+
continue
|
|
21
|
+
key = key.strip()
|
|
22
|
+
value = value.strip()
|
|
23
|
+
if key == "RAW_Data":
|
|
24
|
+
raw_parts.append(value)
|
|
25
|
+
else:
|
|
26
|
+
fields[key] = value
|
|
27
|
+
|
|
28
|
+
protocol = fields.get("Protocol", "")
|
|
29
|
+
if protocol != "RAW":
|
|
30
|
+
raise ValueError(f"Unsupported Protocol {protocol!r}; only RAW is supported")
|
|
31
|
+
|
|
32
|
+
preset = fields.get("Preset", "")
|
|
33
|
+
if "Ook" not in preset:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Unsupported Preset {preset!r}; only OOK presets are supported"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if "Frequency" not in fields:
|
|
39
|
+
raise ValueError("Missing Frequency field")
|
|
40
|
+
if not raw_parts:
|
|
41
|
+
raise ValueError("Missing RAW_Data field")
|
|
42
|
+
|
|
43
|
+
timings = [int(v) for part in raw_parts for v in part.split()]
|
|
44
|
+
if len(timings) % 2 != 0:
|
|
45
|
+
raise ValueError("RAW_Data must contain an even number of values")
|
|
46
|
+
|
|
47
|
+
kwargs: dict[str, Any] = {
|
|
48
|
+
"frequency": int(fields["Frequency"]),
|
|
49
|
+
"timings": timings,
|
|
50
|
+
"repeat_count": int(fields.get("Repeat", "0")),
|
|
51
|
+
}
|
|
52
|
+
return OOKCommand(**kwargs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rf-protocols
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Library to decode and encode radio frequency signals.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/home-assistant-libs/rf-protocols
|
|
@@ -11,6 +11,7 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
14
15
|
Requires-Dist: prek; extra == "dev"
|
|
15
16
|
Dynamic: license-file
|
|
16
17
|
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
rf_protocols/__init__.py
|
|
5
5
|
rf_protocols/commands.py
|
|
6
6
|
rf_protocols/loader.py
|
|
7
|
+
rf_protocols/parser.py
|
|
7
8
|
rf_protocols.egg-info/PKG-INFO
|
|
8
9
|
rf_protocols.egg-info/SOURCES.txt
|
|
9
10
|
rf_protocols.egg-info/dependency_links.txt
|
|
@@ -12,4 +13,5 @@ rf_protocols.egg-info/top_level.txt
|
|
|
12
13
|
rf_protocols/codes/honeywell/string_lights/turn_off.sub
|
|
13
14
|
rf_protocols/codes/honeywell/string_lights/turn_on.sub
|
|
14
15
|
tests/test_commands.py
|
|
15
|
-
tests/test_loader.py
|
|
16
|
+
tests/test_loader.py
|
|
17
|
+
tests/test_parser.py
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for the .sub file loader."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import rf_protocols
|
|
9
|
+
from rf_protocols import CodeCollection, ModulationType, OOKCommand, get_codes
|
|
10
|
+
|
|
11
|
+
_BUNDLED_CODES_ROOT = Path(rf_protocols.__file__).parent / "codes"
|
|
12
|
+
_BUNDLED_SUB_FILES = sorted(_BUNDLED_CODES_ROOT.rglob("*.sub"))
|
|
13
|
+
_HONEYWELL_ROOT = _BUNDLED_CODES_ROOT / "honeywell"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_bundled_sub_files_exist() -> None:
|
|
17
|
+
"""At least one bundled .sub file ships with the package."""
|
|
18
|
+
assert _BUNDLED_SUB_FILES, f"No .sub files found under {_BUNDLED_CODES_ROOT}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize(
|
|
22
|
+
"sub_file",
|
|
23
|
+
_BUNDLED_SUB_FILES,
|
|
24
|
+
ids=[str(p.relative_to(_BUNDLED_CODES_ROOT)) for p in _BUNDLED_SUB_FILES],
|
|
25
|
+
)
|
|
26
|
+
def test_bundled_sub_file_parses(sub_file: Path) -> None:
|
|
27
|
+
"""Every bundled .sub file parses into a valid OOK command."""
|
|
28
|
+
device_dir = sub_file.parent.relative_to(_BUNDLED_CODES_ROOT)
|
|
29
|
+
codes = get_codes(str(device_dir))
|
|
30
|
+
cmd = codes.load_command(sub_file.stem.upper())
|
|
31
|
+
assert isinstance(cmd, OOKCommand)
|
|
32
|
+
assert cmd.frequency > 0
|
|
33
|
+
assert cmd.modulation == ModulationType.OOK
|
|
34
|
+
assert cmd.timings
|
|
35
|
+
assert len(cmd.timings) % 2 == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_codes_bundled_honeywell() -> None:
|
|
39
|
+
"""Bundled Honeywell string lights codes can be discovered and loaded."""
|
|
40
|
+
codes = get_codes("honeywell/string_lights")
|
|
41
|
+
assert isinstance(codes, CodeCollection)
|
|
42
|
+
cmd = codes.load_command("TURN_ON")
|
|
43
|
+
assert isinstance(cmd, OOKCommand)
|
|
44
|
+
assert cmd.frequency == 433_920_000
|
|
45
|
+
assert cmd.repeat_count == 50
|
|
46
|
+
assert cmd.timings[:2] == [2000, -550]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_load_command_caches_results() -> None:
|
|
50
|
+
"""Repeated load_command calls return the same instance."""
|
|
51
|
+
codes = get_codes("honeywell/string_lights")
|
|
52
|
+
assert codes.load_command("TURN_ON") is codes.load_command("TURN_ON")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_load_command_unknown_name() -> None:
|
|
56
|
+
"""Requesting an unknown command name raises KeyError."""
|
|
57
|
+
codes = get_codes("honeywell/string_lights")
|
|
58
|
+
with pytest.raises(KeyError, match="NOPE"):
|
|
59
|
+
codes.load_command("NOPE")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_load_command_base_dir_override() -> None:
|
|
63
|
+
"""Passing base_dir loads codes from an alternate location."""
|
|
64
|
+
codes = get_codes("string_lights", base_dir=_HONEYWELL_ROOT)
|
|
65
|
+
cmd = codes.load_command("TURN_ON")
|
|
66
|
+
assert isinstance(cmd, OOKCommand)
|
|
67
|
+
assert cmd.frequency == 433_920_000
|
|
68
|
+
assert cmd.repeat_count == 50
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_load_command_base_dir_accepts_str() -> None:
|
|
72
|
+
"""base_dir accepts a str as well as a Path."""
|
|
73
|
+
codes = get_codes("string_lights", base_dir=str(_HONEYWELL_ROOT))
|
|
74
|
+
assert isinstance(codes.load_command("TURN_ON"), OOKCommand)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_get_codes_missing_directory(tmp_path: Path) -> None:
|
|
78
|
+
"""get_codes raises FileNotFoundError when the directory is missing."""
|
|
79
|
+
with pytest.raises(FileNotFoundError):
|
|
80
|
+
get_codes("nope", base_dir=tmp_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_get_codes_rejects_path_escape() -> None:
|
|
84
|
+
"""Paths that resolve outside of base_dir are rejected."""
|
|
85
|
+
with pytest.raises(ValueError, match="outside"):
|
|
86
|
+
get_codes("../honeywell", base_dir=_HONEYWELL_ROOT / "string_lights")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_async_load_command_loads_from_disk() -> None:
|
|
90
|
+
"""async_load_command parses the command from disk."""
|
|
91
|
+
codes = get_codes("honeywell/string_lights")
|
|
92
|
+
cmd = await codes.async_load_command("TURN_ON")
|
|
93
|
+
assert isinstance(cmd, OOKCommand)
|
|
94
|
+
assert cmd.repeat_count == 50
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_async_load_command_returns_cached_without_executor(
|
|
98
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Cached commands bypass the executor entirely."""
|
|
101
|
+
codes = get_codes("honeywell/string_lights")
|
|
102
|
+
first = codes.load_command("TURN_ON")
|
|
103
|
+
|
|
104
|
+
def _boom(*_args: object, **_kwargs: object) -> None:
|
|
105
|
+
raise AssertionError("executor must not be used for cached commands")
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(
|
|
108
|
+
asyncio.AbstractEventLoop, "run_in_executor", _boom, raising=True
|
|
109
|
+
)
|
|
110
|
+
second = await codes.async_load_command("TURN_ON")
|
|
111
|
+
assert first is second
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tests for :func:`parse_sub_content`."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from rf_protocols import OOKCommand, parse_sub_content
|
|
6
|
+
|
|
7
|
+
_SAMPLE_SUB = """\
|
|
8
|
+
Filetype: Flipper SubGhz RAW File
|
|
9
|
+
Version: 1
|
|
10
|
+
Frequency: 433920000
|
|
11
|
+
Preset: FuriHalSubGhzPresetOok650Async
|
|
12
|
+
Protocol: RAW
|
|
13
|
+
Repeat: 7
|
|
14
|
+
RAW_Data: 2000 -550 450 -1000
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_sub_content_parses_sample() -> None:
|
|
19
|
+
"""A well-formed .sub payload parses into an OOKCommand."""
|
|
20
|
+
cmd = parse_sub_content(_SAMPLE_SUB)
|
|
21
|
+
assert isinstance(cmd, OOKCommand)
|
|
22
|
+
assert cmd.frequency == 433_920_000
|
|
23
|
+
assert cmd.repeat_count == 7
|
|
24
|
+
assert cmd.timings == [2000, -550, 450, -1000]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_parse_sub_content_rejects_non_raw_protocol() -> None:
|
|
28
|
+
"""Non-RAW protocols are rejected."""
|
|
29
|
+
with pytest.raises(ValueError, match="Protocol"):
|
|
30
|
+
parse_sub_content(_SAMPLE_SUB.replace("Protocol: RAW", "Protocol: Princeton"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_parse_sub_content_rejects_non_ook_preset() -> None:
|
|
34
|
+
"""Non-OOK presets are rejected."""
|
|
35
|
+
with pytest.raises(ValueError, match="Preset"):
|
|
36
|
+
parse_sub_content(
|
|
37
|
+
_SAMPLE_SUB.replace(
|
|
38
|
+
"FuriHalSubGhzPresetOok650Async", "FuriHalSubGhzPreset2FSKDev238Async"
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_parse_sub_content_multiline_raw_data() -> None:
|
|
44
|
+
"""Multiple RAW_Data lines are concatenated."""
|
|
45
|
+
content = (
|
|
46
|
+
"Filetype: Flipper SubGhz RAW File\n"
|
|
47
|
+
"Version: 1\n"
|
|
48
|
+
"Frequency: 433920000\n"
|
|
49
|
+
"Preset: FuriHalSubGhzPresetOok650Async\n"
|
|
50
|
+
"Protocol: RAW\n"
|
|
51
|
+
"RAW_Data: 2000 -550\n"
|
|
52
|
+
"RAW_Data: 450 -1000\n"
|
|
53
|
+
)
|
|
54
|
+
cmd = parse_sub_content(content)
|
|
55
|
+
assert isinstance(cmd, OOKCommand)
|
|
56
|
+
assert cmd.timings == [2000, -550, 450, -1000]
|
|
57
|
+
assert cmd.repeat_count == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_sub_content_rejects_odd_raw_data() -> None:
|
|
61
|
+
"""Odd-length RAW_Data is rejected."""
|
|
62
|
+
with pytest.raises(ValueError, match="even number"):
|
|
63
|
+
parse_sub_content(_SAMPLE_SUB.replace("2000 -550 450 -1000", "2000 -550 450"))
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
"""Tests for the .sub file loader."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
import rf_protocols
|
|
8
|
-
from rf_protocols import CodeCollection, ModulationType, OOKCommand, get_codes
|
|
9
|
-
|
|
10
|
-
_BUNDLED_CODES_ROOT = Path(rf_protocols.__file__).parent / "codes"
|
|
11
|
-
_BUNDLED_SUB_FILES = sorted(_BUNDLED_CODES_ROOT.rglob("*.sub"))
|
|
12
|
-
|
|
13
|
-
_SAMPLE_SUB = """\
|
|
14
|
-
Filetype: Flipper SubGhz RAW File
|
|
15
|
-
Version: 1
|
|
16
|
-
Frequency: 433920000
|
|
17
|
-
Preset: FuriHalSubGhzPresetOok650Async
|
|
18
|
-
Protocol: RAW
|
|
19
|
-
Repeat: 7
|
|
20
|
-
RAW_Data: 2000 -550 450 -1000
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _write_sub(path: Path, content: str = _SAMPLE_SUB) -> None:
|
|
25
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
-
path.write_text(content)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_bundled_sub_files_exist() -> None:
|
|
30
|
-
"""At least one bundled .sub file ships with the package."""
|
|
31
|
-
assert _BUNDLED_SUB_FILES, f"No .sub files found under {_BUNDLED_CODES_ROOT}"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@pytest.mark.parametrize(
|
|
35
|
-
"sub_file",
|
|
36
|
-
_BUNDLED_SUB_FILES,
|
|
37
|
-
ids=[str(p.relative_to(_BUNDLED_CODES_ROOT)) for p in _BUNDLED_SUB_FILES],
|
|
38
|
-
)
|
|
39
|
-
def test_bundled_sub_file_parses(sub_file: Path) -> None:
|
|
40
|
-
"""Every bundled .sub file parses into a valid OOK command."""
|
|
41
|
-
device_dir = sub_file.parent.relative_to(_BUNDLED_CODES_ROOT)
|
|
42
|
-
codes = get_codes(str(device_dir))
|
|
43
|
-
cmd = codes.load_command(sub_file.stem.upper())
|
|
44
|
-
assert isinstance(cmd, OOKCommand)
|
|
45
|
-
assert cmd.frequency > 0
|
|
46
|
-
assert cmd.modulation == ModulationType.OOK
|
|
47
|
-
assert cmd.timings
|
|
48
|
-
assert len(cmd.timings) % 2 == 0
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_get_codes_bundled_honeywell() -> None:
|
|
52
|
-
"""Bundled Honeywell string lights codes can be discovered and loaded."""
|
|
53
|
-
codes = get_codes("honeywell/string_lights")
|
|
54
|
-
assert isinstance(codes, CodeCollection)
|
|
55
|
-
cmd = codes.load_command("TURN_ON")
|
|
56
|
-
assert isinstance(cmd, OOKCommand)
|
|
57
|
-
assert cmd.frequency == 433_920_000
|
|
58
|
-
assert cmd.repeat_count == 50
|
|
59
|
-
assert cmd.timings[:2] == [2000, -550]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_load_command_caches_results(tmp_path: Path) -> None:
|
|
63
|
-
"""Repeated load_command calls return the same instance without re-reading."""
|
|
64
|
-
sub_path = tmp_path / "vendor" / "device" / "power.sub"
|
|
65
|
-
_write_sub(sub_path)
|
|
66
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
67
|
-
first = codes.load_command("POWER")
|
|
68
|
-
sub_path.unlink()
|
|
69
|
-
second = codes.load_command("POWER")
|
|
70
|
-
assert first is second
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def test_load_command_unknown_name(tmp_path: Path) -> None:
|
|
74
|
-
"""Requesting an unknown command name raises KeyError."""
|
|
75
|
-
_write_sub(tmp_path / "vendor" / "device" / "power.sub")
|
|
76
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
77
|
-
with pytest.raises(KeyError, match="NOPE"):
|
|
78
|
-
codes.load_command("NOPE")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_load_command_base_dir_override(tmp_path: Path) -> None:
|
|
82
|
-
"""Passing base_dir loads codes from an alternate location."""
|
|
83
|
-
_write_sub(tmp_path / "vendor" / "device" / "power.sub")
|
|
84
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
85
|
-
cmd = codes.load_command("POWER")
|
|
86
|
-
assert isinstance(cmd, OOKCommand)
|
|
87
|
-
assert cmd.frequency == 433_920_000
|
|
88
|
-
assert cmd.repeat_count == 7
|
|
89
|
-
assert cmd.timings == [2000, -550, 450, -1000]
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_load_command_base_dir_accepts_str(tmp_path: Path) -> None:
|
|
93
|
-
"""base_dir accepts a str as well as a Path."""
|
|
94
|
-
_write_sub(tmp_path / "vendor" / "device" / "power.sub")
|
|
95
|
-
codes = get_codes("vendor/device", base_dir=str(tmp_path))
|
|
96
|
-
assert isinstance(codes.load_command("POWER"), OOKCommand)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_get_codes_missing_directory(tmp_path: Path) -> None:
|
|
100
|
-
"""get_codes raises FileNotFoundError when the directory is missing."""
|
|
101
|
-
with pytest.raises(FileNotFoundError):
|
|
102
|
-
get_codes("nope", base_dir=tmp_path)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def test_load_command_rejects_non_raw_protocol(tmp_path: Path) -> None:
|
|
106
|
-
"""Non-RAW protocols are rejected on load_command."""
|
|
107
|
-
_write_sub(
|
|
108
|
-
tmp_path / "vendor" / "device" / "power.sub",
|
|
109
|
-
_SAMPLE_SUB.replace("Protocol: RAW", "Protocol: Princeton"),
|
|
110
|
-
)
|
|
111
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
112
|
-
with pytest.raises(ValueError, match="Protocol"):
|
|
113
|
-
codes.load_command("POWER")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_load_command_rejects_non_ook_preset(tmp_path: Path) -> None:
|
|
117
|
-
"""Non-OOK presets are rejected on load_command."""
|
|
118
|
-
_write_sub(
|
|
119
|
-
tmp_path / "vendor" / "device" / "power.sub",
|
|
120
|
-
_SAMPLE_SUB.replace(
|
|
121
|
-
"FuriHalSubGhzPresetOok650Async", "FuriHalSubGhzPreset2FSKDev238Async"
|
|
122
|
-
),
|
|
123
|
-
)
|
|
124
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
125
|
-
with pytest.raises(ValueError, match="Preset"):
|
|
126
|
-
codes.load_command("POWER")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def test_get_codes_rejects_path_escape(tmp_path: Path) -> None:
|
|
130
|
-
"""Paths that resolve outside of base_dir are rejected."""
|
|
131
|
-
(tmp_path / "inside").mkdir()
|
|
132
|
-
outside = tmp_path.parent / "outside_codes"
|
|
133
|
-
outside.mkdir(exist_ok=True)
|
|
134
|
-
_write_sub(outside / "power.sub")
|
|
135
|
-
with pytest.raises(ValueError, match="outside"):
|
|
136
|
-
get_codes("../outside_codes", base_dir=tmp_path / "inside")
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def test_load_command_multiline_raw_data(tmp_path: Path) -> None:
|
|
140
|
-
"""Multiple RAW_Data lines are concatenated."""
|
|
141
|
-
content = (
|
|
142
|
-
"Filetype: Flipper SubGhz RAW File\n"
|
|
143
|
-
"Version: 1\n"
|
|
144
|
-
"Frequency: 433920000\n"
|
|
145
|
-
"Preset: FuriHalSubGhzPresetOok650Async\n"
|
|
146
|
-
"Protocol: RAW\n"
|
|
147
|
-
"RAW_Data: 2000 -550\n"
|
|
148
|
-
"RAW_Data: 450 -1000\n"
|
|
149
|
-
)
|
|
150
|
-
_write_sub(tmp_path / "vendor" / "device" / "power.sub", content)
|
|
151
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
152
|
-
cmd = codes.load_command("POWER")
|
|
153
|
-
assert isinstance(cmd, OOKCommand)
|
|
154
|
-
assert cmd.timings == [2000, -550, 450, -1000]
|
|
155
|
-
assert cmd.repeat_count == 0
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def test_load_command_rejects_odd_raw_data(tmp_path: Path) -> None:
|
|
159
|
-
"""Odd-length RAW_Data is rejected on load_command."""
|
|
160
|
-
_write_sub(
|
|
161
|
-
tmp_path / "vendor" / "device" / "power.sub",
|
|
162
|
-
_SAMPLE_SUB.replace("2000 -550 450 -1000", "2000 -550 450"),
|
|
163
|
-
)
|
|
164
|
-
codes = get_codes("vendor/device", base_dir=tmp_path)
|
|
165
|
-
with pytest.raises(ValueError, match="even number"):
|
|
166
|
-
codes.load_command("POWER")
|
|
File without changes
|
|
File without changes
|
{rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/codes/honeywell/string_lights/turn_off.sub
RENAMED
|
File without changes
|
{rf_protocols-2.0.0 → rf_protocols-2.1.0}/rf_protocols/codes/honeywell/string_lights/turn_on.sub
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|