openneodb 1.0.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.
Files changed (34) hide show
  1. openneodb-1.0.0/.github/workflows/ci.yml +98 -0
  2. openneodb-1.0.0/.gitignore +5 -0
  3. openneodb-1.0.0/LICENSE +21 -0
  4. openneodb-1.0.0/PKG-INFO +133 -0
  5. openneodb-1.0.0/README.md +105 -0
  6. openneodb-1.0.0/neodb.py +11 -0
  7. openneodb-1.0.0/openneodb/__init__.py +34 -0
  8. openneodb-1.0.0/openneodb/__main__.py +3 -0
  9. openneodb-1.0.0/openneodb/_version.py +24 -0
  10. openneodb-1.0.0/openneodb/cityguide/README.md +70 -0
  11. openneodb-1.0.0/openneodb/cityguide/__init__.py +3 -0
  12. openneodb-1.0.0/openneodb/cityguide/reader.py +145 -0
  13. openneodb-1.0.0/openneodb/cli.py +132 -0
  14. openneodb-1.0.0/openneodb/db/README.md +95 -0
  15. openneodb-1.0.0/openneodb/db/__init__.py +20 -0
  16. openneodb-1.0.0/openneodb/db/model.py +47 -0
  17. openneodb-1.0.0/openneodb/db/writer.py +112 -0
  18. openneodb-1.0.0/openneodb/igo/README.md +44 -0
  19. openneodb-1.0.0/openneodb/igo/__init__.py +3 -0
  20. openneodb-1.0.0/openneodb/igo/reader.py +115 -0
  21. openneodb-1.0.0/openneodb/py.typed +0 -0
  22. openneodb-1.0.0/openneodb/rows.py +67 -0
  23. openneodb-1.0.0/openneodb.egg-info/PKG-INFO +133 -0
  24. openneodb-1.0.0/openneodb.egg-info/SOURCES.txt +32 -0
  25. openneodb-1.0.0/openneodb.egg-info/dependency_links.txt +1 -0
  26. openneodb-1.0.0/openneodb.egg-info/entry_points.txt +2 -0
  27. openneodb-1.0.0/openneodb.egg-info/scm_file_list.json +28 -0
  28. openneodb-1.0.0/openneodb.egg-info/scm_version.json +8 -0
  29. openneodb-1.0.0/openneodb.egg-info/top_level.txt +1 -0
  30. openneodb-1.0.0/pyproject.toml +47 -0
  31. openneodb-1.0.0/setup.cfg +4 -0
  32. openneodb-1.0.0/tests/test_cli.py +133 -0
  33. openneodb-1.0.0/tests/test_readers.py +161 -0
  34. openneodb-1.0.0/tests/test_writer.py +70 -0
@@ -0,0 +1,98 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ name: Tests (Python ${{ matrix.python-version }})
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+ - name: Run tests
27
+ run: python -m unittest discover -s tests -v
28
+
29
+ build:
30
+ name: Build package
31
+ needs: test
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ with:
36
+ # setuptools-scm needs the full history and tags to compute the version.
37
+ fetch-depth: 0
38
+ - uses: actions/setup-python@v5
39
+ with:
40
+ python-version: "3.13"
41
+ - name: Build sdist and wheel
42
+ run: |
43
+ python -m pip install --upgrade build twine
44
+ python -m build
45
+ twine check dist/*
46
+ - name: Check version
47
+ run: |
48
+ python -m pip install dist/*.whl
49
+ version=$(python -c "import openneodb; print(openneodb.__version__)")
50
+ echo "Package version: $version"
51
+ echo "## openneodb $version" >> "$GITHUB_STEP_SUMMARY"
52
+ if [[ "$GITHUB_REF_TYPE" == "tag" && "$version" != "${GITHUB_REF_NAME#v}" ]]; then
53
+ echo "::error::Version $version does not match tag $GITHUB_REF_NAME"
54
+ exit 1
55
+ fi
56
+ neodb --help
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+
62
+ release:
63
+ name: GitHub Release
64
+ if: github.ref_type == 'tag'
65
+ needs: build
66
+ runs-on: ubuntu-latest
67
+ permissions:
68
+ contents: write
69
+ steps:
70
+ - uses: actions/download-artifact@v4
71
+ with:
72
+ name: dist
73
+ path: dist/
74
+ - name: Create release
75
+ env:
76
+ GH_TOKEN: ${{ github.token }}
77
+ run: >
78
+ gh release create "$GITHUB_REF_NAME" dist/*
79
+ --repo "$GITHUB_REPOSITORY"
80
+ --title "$GITHUB_REF_NAME"
81
+ --generate-notes
82
+
83
+ publish:
84
+ name: Publish to PyPI
85
+ if: github.ref_type == 'tag'
86
+ needs: build
87
+ runs-on: ubuntu-latest
88
+ environment:
89
+ name: pypi
90
+ url: https://pypi.org/project/openneodb/
91
+ permissions:
92
+ id-token: write
93
+ steps:
94
+ - uses: actions/download-artifact@v4
95
+ with:
96
+ name: dist
97
+ path: dist/
98
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ reference
2
+ post
3
+ __pycache__
4
+ .DS_Store
5
+ openneodb/_version.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry Titov
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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: openneodb
3
+ Version: 1.0.0
4
+ Summary: Конвертер баз камер CityGuide и iGo в формат Neoline / CityGuide and iGo to Neoline speed-camera database converter
5
+ Author: Dmitry Titov
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/korableg/openneodb
8
+ Project-URL: Source, https://github.com/korableg/openneodb
9
+ Project-URL: Issues, https://github.com/korableg/openneodb/issues
10
+ Project-URL: Author, https://titovde.ru
11
+ Keywords: neoline,speedcam,cityguide,igo,gps,radar-detector
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Natural Language :: Russian
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # openneodb
30
+
31
+ Конвертер баз камер CityGuide и iGoExt в бинарную базу для видеорегистратора
32
+ Neoline X-COP R9000c.
33
+
34
+ ## Структура
35
+
36
+ Пакет `openneodb`:
37
+
38
+ - [`openneodb.cityguide`](openneodb/cityguide/README.md) — чтение CityGuide
39
+ Speedcam v2 (`.bkm`);
40
+ - [`openneodb.igo`](openneodb/igo/README.md) — чтение SpeedCamOnline iGoExt
41
+ (`.txt`);
42
+ - [`openneodb.db`](openneodb/db/README.md) — формирование базы Neoline
43
+ (`*_Baza_GPS.db`);
44
+ - `openneodb.rows` — общий разбор значений строк для readers;
45
+ - `openneodb.cli` — командная строка `neodb`, связывающая reader выбранного
46
+ формата с writer Neoline.
47
+
48
+ ## Использование
49
+
50
+ ```text
51
+ neodb igo [-i INPUT] [-o OUTPUT] [--date DDMMYY]
52
+ neodb cityguide [-i INPUT] [-o OUTPUT] [--date DDMMYY]
53
+ ```
54
+
55
+ Запуск на выбор, зависимостей нет:
56
+
57
+ - `pip install .` — появляется команда `neodb`;
58
+ - без установки — `python3 neodb.py ...`
59
+ - без установки из корня репозитория — `python3 -m openneodb ...`.
60
+
61
+ ```bash
62
+ neodb igo -i cameras.txt -o X-COP_9000c_Baza_GPS.db
63
+ neodb cityguide -i SpeedCam.bkm -o X-COP_9000c_Baza_GPS.db --date 270926
64
+ cat cameras.txt | python3 -m openneodb igo > X-COP_9000c_Baza_GPS.db
65
+ ```
66
+
67
+ По умолчанию используется локальная дата запуска в формате `DDMMYY`.
68
+ Внутреннее имя файла всегда `https://github.com/korableg/openneodb`.
69
+
70
+ Некорректная строка (пустой тип, нарушенная структура, неверное число,
71
+ значение вне диапазона) пропускается с предупреждением. Неизвестный непустой
72
+ тип преобразуется в базовый стационарный радар `0x06`. Ошибка всего файла
73
+ (кодировка, заголовок, CSV) или неверная `--date` завершает процесс
74
+ ненулевым кодом до записи результата. Файл `-o` заменяется атомарно: при
75
+ сбое прежняя база остаётся нетронутой.
76
+
77
+ ## Использование как библиотеки
78
+
79
+ Все публичные классы доступны из корня пакета:
80
+
81
+ ```python
82
+ from openneodb import IGoReader, CityGuideReader, NeolineDBWriter
83
+
84
+ with open("cameras.txt", "rb") as source:
85
+ result = IGoReader().read(source)
86
+
87
+ for message in result.stats.warning_messages:
88
+ print(message)
89
+
90
+ data = NeolineDBWriter(date="270926").build(result.records)
91
+ with open("X-COP_9000c_Baza_GPS.db", "wb") as target:
92
+ target.write(data)
93
+ ```
94
+
95
+ - `IGoReader().read(source)` и `CityGuideReader().read(source)` принимают
96
+ бинарный поток и возвращают `ReadResult`: кортеж `records` и статистику
97
+ `stats` (`read`, `skipped`, `fallback`, `warning_messages`). Библиотека
98
+ ничего не печатает и не пишет в лог.
99
+ - `NeolineDBWriter(date).build(records)` принимает любые `CameraRecord`
100
+ и возвращает готовую базу как `bytes`; дата — строка `DDMMYY`.
101
+ - Ошибки: `IGoFormatError`, `CityGuideFormatError` (весь входной файл
102
+ некорректен) и `NeolineDBError` (неверная дата, запись вне диапазона,
103
+ превышен размер базы). Все наследуют `ValueError`.
104
+ - Пакет содержит аннотации типов (`py.typed`), версия — `openneodb.__version__`.
105
+
106
+ ### `CameraRecord`
107
+
108
+ Записи можно собирать самостоятельно. Поля — целые числа в единицах формата
109
+ Neoline, например координаты в градусах × 10000 с округлением вниз
110
+ (`55.75583` → `557558`). Значение каждого поля описано в
111
+ [структуре записи](https://github.com/korableg/openneodb/blob/main/openneodb/db/README.md#структура-записи).
112
+
113
+ ```python
114
+ from openneodb import CameraRecord, NeolineDBWriter
115
+
116
+ camera = CameraRecord(
117
+ camera_type=0x06,
118
+ latitude=557558,
119
+ longitude=376173,
120
+ direction=90,
121
+ direction_type=1,
122
+ speed=60,
123
+ )
124
+ data = NeolineDBWriter(date="270926").build([camera])
125
+ ```
126
+
127
+ ## Проверка
128
+
129
+ Тесты используют только стандартную библиотеку Python:
130
+
131
+ ```bash
132
+ python3 -m unittest discover -s tests
133
+ ```
@@ -0,0 +1,105 @@
1
+ # openneodb
2
+
3
+ Конвертер баз камер CityGuide и iGoExt в бинарную базу для видеорегистратора
4
+ Neoline X-COP R9000c.
5
+
6
+ ## Структура
7
+
8
+ Пакет `openneodb`:
9
+
10
+ - [`openneodb.cityguide`](openneodb/cityguide/README.md) — чтение CityGuide
11
+ Speedcam v2 (`.bkm`);
12
+ - [`openneodb.igo`](openneodb/igo/README.md) — чтение SpeedCamOnline iGoExt
13
+ (`.txt`);
14
+ - [`openneodb.db`](openneodb/db/README.md) — формирование базы Neoline
15
+ (`*_Baza_GPS.db`);
16
+ - `openneodb.rows` — общий разбор значений строк для readers;
17
+ - `openneodb.cli` — командная строка `neodb`, связывающая reader выбранного
18
+ формата с writer Neoline.
19
+
20
+ ## Использование
21
+
22
+ ```text
23
+ neodb igo [-i INPUT] [-o OUTPUT] [--date DDMMYY]
24
+ neodb cityguide [-i INPUT] [-o OUTPUT] [--date DDMMYY]
25
+ ```
26
+
27
+ Запуск на выбор, зависимостей нет:
28
+
29
+ - `pip install .` — появляется команда `neodb`;
30
+ - без установки — `python3 neodb.py ...`
31
+ - без установки из корня репозитория — `python3 -m openneodb ...`.
32
+
33
+ ```bash
34
+ neodb igo -i cameras.txt -o X-COP_9000c_Baza_GPS.db
35
+ neodb cityguide -i SpeedCam.bkm -o X-COP_9000c_Baza_GPS.db --date 270926
36
+ cat cameras.txt | python3 -m openneodb igo > X-COP_9000c_Baza_GPS.db
37
+ ```
38
+
39
+ По умолчанию используется локальная дата запуска в формате `DDMMYY`.
40
+ Внутреннее имя файла всегда `https://github.com/korableg/openneodb`.
41
+
42
+ Некорректная строка (пустой тип, нарушенная структура, неверное число,
43
+ значение вне диапазона) пропускается с предупреждением. Неизвестный непустой
44
+ тип преобразуется в базовый стационарный радар `0x06`. Ошибка всего файла
45
+ (кодировка, заголовок, CSV) или неверная `--date` завершает процесс
46
+ ненулевым кодом до записи результата. Файл `-o` заменяется атомарно: при
47
+ сбое прежняя база остаётся нетронутой.
48
+
49
+ ## Использование как библиотеки
50
+
51
+ Все публичные классы доступны из корня пакета:
52
+
53
+ ```python
54
+ from openneodb import IGoReader, CityGuideReader, NeolineDBWriter
55
+
56
+ with open("cameras.txt", "rb") as source:
57
+ result = IGoReader().read(source)
58
+
59
+ for message in result.stats.warning_messages:
60
+ print(message)
61
+
62
+ data = NeolineDBWriter(date="270926").build(result.records)
63
+ with open("X-COP_9000c_Baza_GPS.db", "wb") as target:
64
+ target.write(data)
65
+ ```
66
+
67
+ - `IGoReader().read(source)` и `CityGuideReader().read(source)` принимают
68
+ бинарный поток и возвращают `ReadResult`: кортеж `records` и статистику
69
+ `stats` (`read`, `skipped`, `fallback`, `warning_messages`). Библиотека
70
+ ничего не печатает и не пишет в лог.
71
+ - `NeolineDBWriter(date).build(records)` принимает любые `CameraRecord`
72
+ и возвращает готовую базу как `bytes`; дата — строка `DDMMYY`.
73
+ - Ошибки: `IGoFormatError`, `CityGuideFormatError` (весь входной файл
74
+ некорректен) и `NeolineDBError` (неверная дата, запись вне диапазона,
75
+ превышен размер базы). Все наследуют `ValueError`.
76
+ - Пакет содержит аннотации типов (`py.typed`), версия — `openneodb.__version__`.
77
+
78
+ ### `CameraRecord`
79
+
80
+ Записи можно собирать самостоятельно. Поля — целые числа в единицах формата
81
+ Neoline, например координаты в градусах × 10000 с округлением вниз
82
+ (`55.75583` → `557558`). Значение каждого поля описано в
83
+ [структуре записи](https://github.com/korableg/openneodb/blob/main/openneodb/db/README.md#структура-записи).
84
+
85
+ ```python
86
+ from openneodb import CameraRecord, NeolineDBWriter
87
+
88
+ camera = CameraRecord(
89
+ camera_type=0x06,
90
+ latitude=557558,
91
+ longitude=376173,
92
+ direction=90,
93
+ direction_type=1,
94
+ speed=60,
95
+ )
96
+ data = NeolineDBWriter(date="270926").build([camera])
97
+ ```
98
+
99
+ ## Проверка
100
+
101
+ Тесты используют только стандартную библиотеку Python:
102
+
103
+ ```bash
104
+ python3 -m unittest discover -s tests
105
+ ```
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """Run the converter without installation: python3 neodb.py igo -i ... -o ..."""
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
7
+
8
+ from openneodb.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,34 @@
1
+ from .cityguide import CityGuideFormatError, CityGuideReader
2
+ from .db import (
3
+ FALLBACK_CAMERA_TYPE,
4
+ MAX_ALERT_DISTANCE,
5
+ MIN_ALERT_DISTANCE,
6
+ CameraRecord,
7
+ NeolineDBError,
8
+ NeolineDBWriter,
9
+ ReaderStats,
10
+ ReadResult,
11
+ )
12
+ from .igo import IGoFormatError, IGoReader
13
+
14
+ try:
15
+ from ._version import __version__
16
+ except ImportError:
17
+ # Running from a source checkout without installation.
18
+ __version__ = "0.0.0+unknown"
19
+
20
+ __all__ = (
21
+ "FALLBACK_CAMERA_TYPE",
22
+ "MAX_ALERT_DISTANCE",
23
+ "MIN_ALERT_DISTANCE",
24
+ "CameraRecord",
25
+ "CityGuideFormatError",
26
+ "CityGuideReader",
27
+ "IGoFormatError",
28
+ "IGoReader",
29
+ "NeolineDBError",
30
+ "NeolineDBWriter",
31
+ "ReaderStats",
32
+ "ReadResult",
33
+ "__version__",
34
+ )
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = 'gc8a5cc44b'
@@ -0,0 +1,70 @@
1
+ # CityGuide Speedcam v2
2
+
3
+ Каталог содержит `CityGuideReader`, который только читает CityGuide BKM и
4
+ возвращает нормализованные `CameraRecord`.
5
+
6
+ ## Формат
7
+
8
+ Файл — текст в кодировке CP1251 с CRLF. Заголовок имеет фиксированный вид:
9
+
10
+ ```text
11
+ 2|Radars|1251
12
+ ```
13
+
14
+ Каждая следующая строка:
15
+
16
+ ```text
17
+ TYPE|ID|LAT|LON|TAG|VALUE|TAG|VALUE|...|
18
+ ```
19
+
20
+ | Поле или тег | Значение |
21
+ | --- | --- |
22
+ | `TYPE` | тип объекта CityGuide |
23
+ | `ID` | идентификатор; при конвертации не используется |
24
+ | `LAT`, `LON` | координаты в градусах |
25
+ | `1709` | ограничение скорости, км/ч |
26
+ | `1713` | направленность CityGuide |
27
+ | `1705` | азимут камеры, `0`…`359` |
28
+ | `1706` | дистанция предупреждения в метрах |
29
+
30
+ Координаты сохраняются как `floor(value × 10000)`; поддерживаются только
31
+ северная широта `0`…`90` и восточная долгота `0`…`180`. Азимут камеры
32
+ переводится в направление движения добавлением 180° по модулю 360. Дистанция
33
+ делится на 10 с округлением вниз и приводится к пределам RevM60: больше
34
+ 2000 м ограничивается 2000 м (прошивка читает `>200` декаметров как 500 м),
35
+ от 1 до 9 м поднимается до 100 м (`0` означает 500 м), оба случая — с
36
+ предупреждением.
37
+
38
+ Отсутствующие теги получают безопасные значения с предупреждением:
39
+ `1709 = 0`, `1713 = 0`, `1705` — направление `0` без поворота, `1706 = 0`.
40
+ Пустые строки игнорируются. Строка с пустым типом, нарушенной структурой,
41
+ некорректным числом или значением вне диапазона пропускается с
42
+ предупреждением.
43
+
44
+ ## Преобразование типов
45
+
46
+ | CityGuide `TYPE` | Neoline |
47
+ | --- | --- |
48
+ | `18059`, `18951`, `18925` | `0x06` |
49
+ | `18952` | `0x01`, `flags = 0x02` |
50
+ | `18958`, `18950` | `0x4A` |
51
+ | любой другой непустой | `0x06` с предупреждением |
52
+
53
+ Направленность из тега `1713` преобразуется так:
54
+
55
+ | CityGuide | Neoline |
56
+ | --- | --- |
57
+ | `0` | `1`, одно направление |
58
+ | `1` | `1`, одно направление |
59
+ | `2` | `0`, все направления |
60
+
61
+ `0` и `1` описывают один контролируемый поток; встречное направление в
62
+ bkm — отдельная строка. По выгрузке 2026-09-19 записи с `0` и `1`
63
+ статистически неразличимы: встречная запись в радиусе ~50 м есть у 80 % и
64
+ 78 %, совпадают распределения типов, скоростей и дистанций, вендор кодирует
65
+ обе как `direction_type = 1`, `tol = 0`. Вероятно, это режим съёмки
66
+ («в лоб»/«в спину»), для Neoline не значимый. Прежнее прочтение `1` как
67
+ «оба направления» давало лишние оповещения на встречке.
68
+
69
+ Круговые камеры записываются по семантике RevM60: `direction_type = 0`, без
70
+ флага `0x20` и без принудительного допуска 5°.
@@ -0,0 +1,3 @@
1
+ from .reader import CityGuideFormatError, CityGuideReader
2
+
3
+ __all__ = ("CityGuideFormatError", "CityGuideReader")
@@ -0,0 +1,145 @@
1
+ from typing import BinaryIO
2
+
3
+ from ..db import (
4
+ MAX_ALERT_DISTANCE,
5
+ MIN_ALERT_DISTANCE,
6
+ CameraRecord,
7
+ ReaderStats,
8
+ ReadResult,
9
+ )
10
+ from ..rows import (
11
+ MAX_LATITUDE,
12
+ MAX_LONGITUDE,
13
+ RowError,
14
+ check_range,
15
+ map_camera_type,
16
+ optional_integer,
17
+ parse_coordinate,
18
+ parse_integer,
19
+ )
20
+
21
+
22
+ class CityGuideFormatError(ValueError):
23
+ pass
24
+
25
+
26
+ class CityGuideReader:
27
+ HEADER = "2|Radars|1251"
28
+ TYPE_MAPPING = {
29
+ "18059": 0x06,
30
+ "18925": 0x06,
31
+ "18950": 0x4A,
32
+ "18951": 0x06,
33
+ "18952": 0x01,
34
+ "18958": 0x4A,
35
+ }
36
+ # 0 and 1 are both a single controlled direction (likely head-on versus
37
+ # rear shooting); opposite directions are separate rows. 2 is circular.
38
+ DIRECTION_TYPE_MAPPING = {0: 1, 1: 1, 2: 0}
39
+
40
+ def read(self, source: BinaryIO) -> ReadResult:
41
+ try:
42
+ text = source.read().decode("cp1251")
43
+ except UnicodeDecodeError as error:
44
+ raise CityGuideFormatError("CityGuide input must use CP1251") from error
45
+ lines = text.splitlines()
46
+ if not lines:
47
+ raise CityGuideFormatError("empty CityGuide input")
48
+ if lines[0] != self.HEADER:
49
+ raise CityGuideFormatError(
50
+ f"invalid CityGuide header {lines[0]!r}, expected {self.HEADER!r}"
51
+ )
52
+ records = []
53
+ stats = ReaderStats()
54
+ for line_number, line in enumerate(lines[1:], start=2):
55
+ if not line.strip():
56
+ continue
57
+ stats.read += 1
58
+ try:
59
+ records.append(self._record(line, line_number, stats))
60
+ except RowError as error:
61
+ stats.skip(line_number, str(error))
62
+ return ReadResult(tuple(records), stats)
63
+
64
+ def _record(self, line: str, line_number: int, stats: ReaderStats) -> CameraRecord:
65
+ parts = line.split("|")
66
+ if parts[-1] != "" or len(parts) < 5 or len(parts) % 2 == 0:
67
+ raise RowError("malformed CityGuide row")
68
+ source_type = parts[0].strip()
69
+ if not source_type:
70
+ raise RowError("blank camera type")
71
+ tags = self._tags(parts[4:-1], line_number, stats)
72
+ latitude = parse_coordinate(parts[2].strip(), "latitude", MAX_LATITUDE)
73
+ longitude = parse_coordinate(parts[3].strip(), "longitude", MAX_LONGITUDE)
74
+ speed = self._tag_integer(tags, "1709", 0, line_number, stats)
75
+ check_range(speed, "tag 1709", 0, 255)
76
+ source_direction_type = self._tag_integer(tags, "1713", 0, line_number, stats)
77
+ check_range(source_direction_type, "tag 1713", 0, 2)
78
+ direction = self._direction(tags, line_number, stats)
79
+ distance_metres = self._tag_integer(tags, "1706", 0, line_number, stats)
80
+ if distance_metres < 0:
81
+ raise RowError(f"tag 1706={distance_metres} must not be negative")
82
+ return CameraRecord(
83
+ camera_type=map_camera_type(
84
+ self.TYPE_MAPPING, source_type, "camera type", line_number, stats
85
+ ),
86
+ latitude=latitude,
87
+ longitude=longitude,
88
+ direction=direction,
89
+ direction_type=self.DIRECTION_TYPE_MAPPING[source_direction_type],
90
+ speed=speed,
91
+ flags=0x02 if source_type == "18952" else 0,
92
+ distance=self._distance(distance_metres, line_number, stats),
93
+ )
94
+
95
+ @staticmethod
96
+ def _direction(tags: dict[str, str], line_number: int, stats: ReaderStats) -> int:
97
+ value = tags.get("1705", "").strip()
98
+ if not value:
99
+ stats.warn(line_number, "missing tag 1705, using direction 0")
100
+ return 0
101
+ source_direction = parse_integer(value, "tag 1705")
102
+ check_range(source_direction, "tag 1705", 0, 359)
103
+ # Tag 1705 is the camera heading; Neoline stores the traffic heading.
104
+ return (source_direction + 180) % 360
105
+
106
+ @staticmethod
107
+ def _distance(metres: int, line_number: int, stats: ReaderStats) -> int:
108
+ distance = metres // 10
109
+ if metres and distance == 0:
110
+ # 0 means the 500 m default, not "closer than 10 m".
111
+ stats.warn(
112
+ line_number,
113
+ f"tag 1706={metres} raised to {MIN_ALERT_DISTANCE * 10} metres",
114
+ )
115
+ return MIN_ALERT_DISTANCE
116
+ if distance > MAX_ALERT_DISTANCE:
117
+ stats.warn(
118
+ line_number,
119
+ f"tag 1706={metres} clamped to {MAX_ALERT_DISTANCE * 10} metres",
120
+ )
121
+ return MAX_ALERT_DISTANCE
122
+ return distance
123
+
124
+ @staticmethod
125
+ def _tags(
126
+ fields: list[str], line_number: int, stats: ReaderStats
127
+ ) -> dict[str, str]:
128
+ tags = {}
129
+ for tag, value in zip(fields[0::2], fields[1::2]):
130
+ if tag in tags:
131
+ stats.warn(line_number, f"duplicate tag {tag}, using last value")
132
+ tags[tag] = value
133
+ return tags
134
+
135
+ @staticmethod
136
+ def _tag_integer(
137
+ tags: dict[str, str],
138
+ tag: str,
139
+ default: int,
140
+ line_number: int,
141
+ stats: ReaderStats,
142
+ ) -> int:
143
+ return optional_integer(
144
+ tags.get(tag, "").strip(), f"tag {tag}", default, line_number, stats
145
+ )