diff --git a/Makefile b/Makefile index b57976d..54fd712 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev install-editable reinstall test test-pytest-all test-all test-input test-output test-task test-verbose test-examples-syntax lint format clean clean-build clean-pyc clean-test coverage docs dist release examples +.PHONY: help install install-dev install-editable reinstall test test-pytest-all test-all test-input test-output test-task test-verbose test-examples-syntax lint format clean clean-build clean-pyc clean-test coverage docs dist release examples test-core test-web install-core install-web build-core build-web .DEFAULT_GOAL := help # Python interpreter detection @@ -223,3 +223,53 @@ release: dist ## Package and upload a release @echo "$(BLUE)Uploading to PyPI...$(NC)" twine upload dist/* @echo "$(GREEN)✓ Release uploaded$(NC)" + +##@ Monorepo - Core Package + +install-core: ## Install core package (packages/core) + @echo "$(BLUE)Installing core package...$(NC)" + cd packages/core && $(PYTHON) -m pip install -e . + @echo "$(GREEN)✓ Core package installed$(NC)" + +test-core: ## Run core package tests + $(call check_command,pytest) + @echo "$(BLUE)Running core package tests...$(NC)" + $(PYTHON) -m pytest packages/core/tests/task/ packages/core/tests/module/input_module/ packages/core/tests/module/output_module/ -v + @echo "$(GREEN)✓ Core tests completed$(NC)" + +build-core: ## Build core package distribution + @echo "$(BLUE)Building core package...$(NC)" + cd packages/core && $(PYTHON) -m pip install --upgrade build && $(PYTHON) -m build + @echo "$(GREEN)✓ Core package built$(NC)" + +##@ Monorepo - Web Package + +install-web: install-core ## Install web package (depends on core) + @echo "$(BLUE)Installing web package...$(NC)" + cd packages/web && $(PYTHON) -m pip install -e . + @echo "$(GREEN)✓ Web package installed$(NC)" + +test-web: ## Run web package tests + $(call check_command,pytest) + @echo "$(BLUE)Running web package tests...$(NC)" + $(PYTHON) -m pytest packages/web/tests/ -v + @echo "$(GREEN)✓ Web tests completed$(NC)" + +build-web: ## Build web package distribution + @echo "$(BLUE)Building web package...$(NC)" + cd packages/web && $(PYTHON) -m pip install --upgrade build && $(PYTHON) -m build + @echo "$(GREEN)✓ Web package built$(NC)" + +##@ Monorepo - All Packages + +install-all: install-core install-web ## Install all packages + @echo "$(GREEN)✓ All packages installed$(NC)" + +test-monorepo: test-core test-web ## Run all package tests + @echo "" + @echo "$(GREEN)========================================$(NC)" + @echo "$(GREEN)✓ All monorepo tests passed!$(NC)" + @echo "$(GREEN)========================================$(NC)" + +build-all: build-core build-web ## Build all packages + @echo "$(GREEN)✓ All packages built$(NC)" diff --git a/README.md b/README.md index ef7335c..cd38b0a 100644 --- a/README.md +++ b/README.md @@ -5,169 +5,112 @@ [![Read the Docs (version)](https://img.shields.io/readthedocs/pymodi-plus/latest?style=flat-square)](https://pymodi-plus.readthedocs.io/en/latest/?badge=master) [![GitHub Workflow Status (Build)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) [![GitHub LICENSE](https://img.shields.io/github/license/LUXROBO/pymodi-plus?style=flat-square&color=blue)](https://github.com/LUXROBO/pymodi-plus/blob/master/LICENSE) -[![Lines of Code](https://img.shields.io/tokei/lines/github/LUXROBO/pymodi-plus?style=flat-square)](https://github.com/LUXROBO/pymodi-plus/tree/master/modi_plus) -Description -=========== -> Python API for controlling modular electronics, MODI+. +# PyMODI+ Monorepo +> Python API for controlling modular electronics, MODI+ - Desktop and Web support -Features --------- -PyMODI+ provides a control of modular electronics. -* Platform agnostic control of modules through serial connection -* Utilities of wireless connection with BLE (Bluetooth Low Engery) +This monorepo contains two packages: -Build Status ------------- -|master|develop| -|:---:|:---:| -| [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=develop)](https://github.com/LUXROBO/pymodi-plus/actions) +| Package | Description | PyPI | +|---------|-------------|------| +| **[packages/core](./packages/core/)** | Core library for desktop (USB/BLE) | `pymodi-plus` | +| **[packages/web](./packages/web/)** | Web extension for Pyodide/Browser | `pymodi-plus-web` | -System Support --------------- -| System | 3.7 | 3.8 | 3.9 | 3.10 | 3.11 | -| :---: | :---: | :---: | :---: | :---: | :---: | -| Linux | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) -| Mac OS | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) -| Windows | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) +## Quick Start -Contribution Guidelines ------------------------ -We appreciate all contributions. If you are planning to report bugs, please do so [here](https://github.com/LUXROBO/pymodi/issues). Feel free to fork our repository to your local environment, and please send us feedback by filing an issue. +### Desktop Usage (USB/BLE) -If you want to contribute to pymodi, be sure to review the contribution guidelines. This project adheres to pymodi's code of conduct. By participating, you are expected to uphold this code. +```bash +# Install with all features +pip install pymodi-plus[all] -[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) - -Installation ------------- -> When installing PyMODI+ package, we highly recommend you to use Anaconda to manage the distribution. -> With Anaconda, you can use an isolated virtual environment, solely for PyMODI+. - -[Optional] Once you install [Anaconda](https://docs.anaconda.com/anaconda/install/), then: +# Or minimal install +pip install pymodi-plus ``` -# Install new python environment for PyMODI+ package, choose python version >= 3.7 -conda create --name pymodi_plus python=3.7 -# After you properly install the python environment, activate it -conda activate pymodi_plus - -# Ensure that your python version is compatible with PyMODI+ -python --version -``` - -Install the latest PyMODI+ if you haven't installed it yet: -``` -python -m pip install pymodi-plus --user --upgrade -``` - -Usage ------ -Import modi_plus package and create MODIPlus object (we call it "bundle", a bundle of MODI+ modules). ```python -# Import modi_plus package import modi_plus -""" -Create MODIPlus object, make sure that you have connected your network module -to your machine while other modules are attached to the network module -""" bundle = modi_plus.MODIPlus() +led = bundle.leds[0] +led.turn_on() ``` -[Optional] Specify how you would like to establish the connection between your machine and the network module. +### Web/Pyodide Usage + ```python -# 1. Serial connection (via USB), it's the default connection method -bundle = modi_plus.MODIPlus(connection_type="serialport") +# In Pyodide environment +import micropip +await micropip.install('pymodi-plus-web') -# 2. BLE (Bluetooth Low Energy) connection, it's wireless! But it can be slow :( -bundle = modi_plus.MODIPlus(conn_type="ble", network_uuid="YOUR_NETWORK_MODULE_UUID") +from modi_plus_web import MODIPlusWeb + +modi = MODIPlusWeb() +modi.set_send_callback(js_send_function) +# ... use like regular MODIPlus ``` -List and create connected modules' object. -```python -# List connected modules -print(bundle.modules) +## Development -# List connected leds -print(bundle.leds) +```bash +# Install all packages in development mode +make install-all -# Pick the first led object from the bundle -led = bundle.leds[0] +# Run all tests +make test-monorepo + +# Build all packages +make build-all ``` -Let's blink the LED 5 times. -```python -import time +### Package-specific commands -for _ in range(5): - # turn on for 0.5 second - led.turn_on() - time.sleep(0.5) +```bash +# Core package +make install-core +make test-core +make build-core - # turn off for 0.5 second - led.turn_off() - time.sleep(0.5) +# Web package +make install-web +make test-web +make build-web ``` -If you are still not sure how to use PyMODI, you can play PyMODI tutorial over REPL: -``` -$ python -m modi_plus --tutorial -``` -As well as an interactive usage examples: -``` -$ python -m modi_plus --usage -``` +## Architecture -Additional Usage ----------------- -To diagnose MODI+ modules (helpful to find existing malfunctioning modules), ``` -$ python -m modi_plus --inspect +pymodi-plus/ +├── packages/ +│ ├── core/ # pymodi-plus (PyPI) +│ │ ├── modi_plus/ # Core library +│ │ ├── tests/ +│ │ └── setup.py +│ └── web/ # pymodi-plus-web (PyPI) +│ ├── modi_plus_web/ +│ ├── tests/ +│ └── setup.py +├── docs/ # Documentation +├── Makefile # Monorepo commands +└── pyproject.toml # Root configuration ``` -To initialize MODI+ modules implicitly (set `i` flag to enable REPL mode), -``` -$ python -im modi_plus --initialize -``` +## Documentation -To see what other commands are available, -``` -$ python -m modi_plus --help -``` +- [Quick Start Guide](./docs/getting-started/QUICKSTART.md) +- [Env Module RGB Features](./docs/features/ENV_RGB_FEATURE.md) +- [Development Guide](./docs/development/MAKEFILE_GUIDE.md) +- [Web Package README](./packages/web/README.md) + +## Contributing -Documentation -------------- -📚 **Complete documentation is available in the [docs/](./docs/) folder.** - -### Quick Links -- 🚀 [Quick Start Guide](./docs/getting-started/QUICKSTART.md) - Get up and running quickly -- ✨ [Env Module RGB Features](./docs/features/ENV_RGB_FEATURE.md) - New RGB sensor support (v2.x+) -- 🛠️ [Development Guide](./docs/development/MAKEFILE_GUIDE.md) - Build, test, and contribute -- 📦 [Deployment Guide](./docs/deployment/DEPLOY_GUIDE_KOREAN.md) - Release to PyPI -- 🐛 [Troubleshooting](./docs/troubleshooting/) - Platform-specific issues and fixes - -### What's New in v0.4.0 -- ✅ **RGB Color Sensor Support** for Env module v2.x+ - - New properties: `red`, `green`, `blue`, `white`, `black` - - Color classification: `color_class` (0-5) - - Brightness measurement: `brightness` (0-100%) -- ✅ **Enhanced Testing** - 94 tests across all platforms -- ✅ **Python 3.8-3.13 Support** - Wide version compatibility -- ✅ **Improved CI/CD** - GitHub Actions enhancements - -See [Release History](./docs/project/HISTORY.md) for complete changelog. - -Contributing ------------- We welcome contributions! Please see: - [Contributing Guidelines](./docs/getting-started/CONTRIBUTING.md) - [Code of Conduct](./docs/getting-started/CODE_OF_CONDUCT.md) -- [Development Guide](./docs/development/TESTS_README.md) -License -------- +## License + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/guides/WEB_DEPLOYMENT_GUIDE.md b/docs/guides/WEB_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..e8ed939 --- /dev/null +++ b/docs/guides/WEB_DEPLOYMENT_GUIDE.md @@ -0,0 +1,407 @@ +# PyMODI+ Web 배포 및 사용 가이드 + +## 목차 +1. [개요](#개요) +2. [내부 테스트 배포](#내부-테스트-배포) +3. [설치 방법](#설치-방법) +4. [사용 방법](#사용-방법) +5. [Pyodide 통합](#pyodide-통합) +6. [Flutter 연동](#flutter-연동) +7. [트러블슈팅](#트러블슈팅) + +--- + +## 개요 + +`pymodi-plus-web`은 웹 브라우저 환경(Pyodide)에서 MODI+ 모듈을 제어하기 위한 패키지입니다. + +### 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Web Browser │ +│ ┌─────────────┐ postMessage ┌─────────────────────┐ │ +│ │ Pyodide │ ◄───────────────► │ JavaScript/Flutter │ │ +│ │ (Python) │ │ (WebUSB) │ │ +│ └─────────────┘ └─────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ pymodi-plus │ │ MODI+ Hardware │ │ +│ │ -web │ │ (via WebUSB) │ │ +│ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 패키지 구조 + +``` +pymodi-plus-web/ +├── modi_plus_web/ +│ ├── __init__.py +│ ├── modi_plus_web.py # MODIPlusWeb 클래스 +│ └── task/ +│ ├── __init__.py +│ └── postmessage_task.py # PostMessageTask 통신 레이어 +└── tests/ +``` + +--- + +## 내부 테스트 배포 + +### 방법 1: TestPyPI (권장) + +TestPyPI는 PyPI의 테스트 서버로, 실제 배포 전 테스트용으로 사용합니다. + +#### 1단계: TestPyPI 계정 설정 + +```bash +# ~/.pypirc 파일 생성 +cat > ~/.pypirc << 'EOF' +[testpypi] +username = __token__ +password = pypi-YOUR_TEST_PYPI_TOKEN +EOF +``` + +#### 2단계: 패키지 빌드 + +```bash +cd packages/web +rm -rf dist build *.egg-info +python -m build +``` + +#### 3단계: TestPyPI에 업로드 + +```bash +python -m twine upload --repository testpypi dist/* +``` + +#### 4단계: TestPyPI에서 설치 테스트 + +```bash +pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pymodi-plus-web +``` + +### 방법 2: Git에서 직접 설치 + +브랜치에서 직접 설치하는 방법입니다. + +```bash +# pymodi-plus (core) 설치 - 변경된 버전 +pip install git+https://github.com/LUXROBO/pymodi-plus.git@feature/web-support + +# pymodi-plus-web 설치 +pip install git+https://github.com/LUXROBO/pymodi-plus.git@feature/web-support#subdirectory=packages/web +``` + +### 방법 3: 로컬 wheel 파일 + +빌드된 wheel 파일을 직접 공유하는 방법입니다. + +```bash +# 빌드 +cd packages/web +python -m build + +# 설치 (wheel 파일 경로) +pip install dist/pymodi_plus_web-0.1.0-py3-none-any.whl +``` + +--- + +## 설치 방법 + +### 데스크톱 환경 + +```bash +# pymodi-plus-web 설치 (pymodi-plus 자동 설치됨) +pip install pymodi-plus-web +``` + +### Pyodide 환경 (웹 브라우저) + +```python +import micropip + +# pymodi-plus 설치 (최소 의존성) +await micropip.install('pymodi-plus') + +# pymodi-plus-web 설치 +await micropip.install('pymodi-plus-web') +``` + +--- + +## 사용 방법 + +### 기본 사용법 + +```python +from modi_plus_web import MODIPlusWeb + +# 인스턴스 생성 +modi = MODIPlusWeb(verbose=True) + +# JavaScript로 메시지 전송할 콜백 설정 +def send_to_js(packet): + # JavaScript postMessage로 전송 + js.window.modiSend(packet) + +modi.set_send_callback(send_to_js) + +# JavaScript에서 메시지 수신 시 호출 +def on_js_message(data): + modi.on_message(data) + +# 모듈 사용 (기존 pymodi-plus와 동일) +led = modi.leds[0] +led.turn_on() +led.set_rgb(255, 0, 0) +``` + +### PostMessageTask 직접 사용 + +```python +from modi_plus_web.task import PostMessageTask + +# Task 직접 생성 +task = PostMessageTask(verbose=True) +task.open_connection() + +# 콜백 설정 +task.set_send_callback(lambda pkt: print(f"Send: {pkt}")) + +# 메시지 수신 +task.on_message('{"c":0,"s":100,"d":0}') + +# 메시지 처리 +received = task.recv() +print(f"Received: {received}") + +# 메시지 전송 +task.send('{"c":4,"s":0,"d":100}') +``` + +--- + +## Pyodide 통합 + +### HTML 템플릿 + +```html + + + + PyMODI+ Web + + + +
+ + + + +``` + +### Python 코드 실행 + +```javascript +// JavaScript에서 Python 코드 실행 +async function runPythonCode(code) { + try { + const result = await pyodide.runPythonAsync(code); + return result; + } catch (error) { + console.error('Python error:', error); + throw error; + } +} + +// 예시: LED 제어 +await runPythonCode(` +led = modi.leds[0] +led.turn_on() +led.set_rgb(255, 0, 0) +`); +``` + +--- + +## Flutter 연동 + +### Flutter → Pyodide 통신 + +```dart +// Flutter (Dart) +import 'package:webview_flutter/webview_flutter.dart'; + +class ModiWebView extends StatefulWidget { + @override + _ModiWebViewState createState() => _ModiWebViewState(); +} + +class _ModiWebViewState extends State { + late WebViewController controller; + + @override + void initState() { + super.initState(); + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'ModiChannel', + onMessageReceived: (message) { + // Python에서 온 메시지 → 하드웨어로 전송 + _sendToHardware(message.message); + }, + ) + ..loadFlutterAsset('assets/pyodide.html'); + } + + // 하드웨어에서 온 메시지 → Python으로 전송 + void sendToPython(String data) { + controller.runJavaScript('sendToPython($data)'); + } + + void _sendToHardware(String packet) { + // WebUSB 또는 시리얼 통신으로 전송 + } +} +``` + +### JavaScript Bridge 설정 + +```javascript +// pyodide.html 내 JavaScript +window.modiSend = function(packet) { + // Flutter로 전송 + if (window.ModiChannel) { + ModiChannel.postMessage(packet); + } +}; +``` + +--- + +## 트러블슈팅 + +### 1. micropip 설치 오류 + +**증상:** +``` +ValueError: Can't find a pure Python 3 wheel for 'pymodi-plus' +``` + +**해결:** +pymodi-plus가 pure Python wheel로 빌드되어야 합니다. TestPyPI나 PyPI에서 올바른 버전을 확인하세요. + +### 2. JavaScript 콜백 오류 + +**증상:** +``` +AttributeError: 'JsProxy' object has no attribute 'js_send' +``` + +**해결:** +`js.window.modiSend` 형식으로 전역 함수를 호출하세요: +```python +modi.set_send_callback(lambda pkt: js.window.modiSend(pkt)) +``` + +### 3. 모듈을 찾을 수 없음 + +**증상:** +``` +IndexError: list index out of range (modi.leds[0]) +``` + +**해결:** +모듈 검색이 완료될 때까지 대기하거나, 하드웨어 연결 상태를 확인하세요: +```python +import time +time.sleep(2) # 모듈 검색 대기 +print(modi.modules) # 연결된 모듈 확인 +``` + +### 4. 메시지 형식 오류 + +**증상:** +``` +json.JSONDecodeError: Expecting property name +``` + +**해결:** +JSON 형식이 올바른지 확인하세요: +```python +# 올바른 형식 +modi.on_message('{"c":0,"s":100,"d":0}') + +# 잘못된 형식 +modi.on_message("{c:0,s:100,d:0}") # 따옴표 누락 +``` + +--- + +## 버전 호환성 + +| pymodi-plus | pymodi-plus-web | Python | Pyodide | +|-------------|-----------------|--------|---------| +| >= 0.5.0 | 0.1.0 | >= 3.8 | >= 0.24 | + +--- + +## 참고 자료 + +- [PyMODI+ GitHub](https://github.com/LUXROBO/pymodi-plus) +- [Pyodide 공식 문서](https://pyodide.org/) +- [WebUSB API](https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API) diff --git a/modi_plus/modi_plus.py b/modi_plus/modi_plus.py index 8f0b872..291e6a3 100644 --- a/modi_plus/modi_plus.py +++ b/modi_plus/modi_plus.py @@ -2,6 +2,7 @@ import time import atexit +from typing import Optional from importlib import import_module as im @@ -21,6 +22,7 @@ from modi_plus.module.module import ModuleList from modi_plus._exe_thread import ExeThread from modi_plus.util.connection_util import get_platform, get_ble_task_path +from modi_plus.task import HAS_SERIAL, HAS_BLE, ConnectionTask class MODIPlus: @@ -37,9 +39,20 @@ def __call__(cls, *args, **kwargs): cls.network_uuids[network_uuid] = super(MODIPlus, cls).__call__(*args, **kwargs) return cls.network_uuids[network_uuid] - def __init__(self, connection_type="serialport", verbose=False, port=None, network_uuid=""): + def __init__( + self, + connection_type: str = "serialport", + verbose: bool = False, + port: Optional[str] = None, + network_uuid: str = "", + task: Optional[ConnectionTask] = None, + ): self._modules = list() - self._connection = self.__init_task(connection_type, verbose, port, network_uuid) + # 외부에서 task 주입 가능 (웹 버전용) + if task is not None: + self._connection = task + else: + self._connection = self.__init_task(connection_type, verbose, port, network_uuid) self._exe_thread = ExeThread(self._modules, self._connection) print("Start initializing connected MODI+ modules") @@ -59,15 +72,25 @@ def __init__(self, connection_type="serialport", verbose=False, port=None, netwo def __init_task(self, connection_type, verbose, port, network_uuid): if connection_type == "serialport": + if not HAS_SERIAL: + raise ImportError( + "Serial 통신을 사용하려면 pyserial이 필요합니다.\n" + "설치: pip install pymodi-plus[serial] 또는 pip install pymodi-plus[all]" + ) return im("modi_plus.task.serialport_task").SerialportTask(verbose, port) elif connection_type == "ble": + if not HAS_BLE: + raise ImportError( + "BLE 통신을 사용하려면 bleak이 필요합니다.\n" + "설치: pip install pymodi-plus[ble] 또는 pip install pymodi-plus[all]" + ) if not network_uuid: raise ValueError("Network UUID not specified!") self.network_uuids[network_uuid] = self os = get_platform() if os == "chrome" or os == "linux": - raise ValueError(f"{os} doen't supported for ble connection") + raise ValueError(f"{os} doesn't support ble connection") return im(get_ble_task_path()).BleTask(verbose, network_uuid) else: diff --git a/modi_plus/task/__init__.py b/modi_plus/task/__init__.py index e69de29..0152acc 100644 --- a/modi_plus/task/__init__.py +++ b/modi_plus/task/__init__.py @@ -0,0 +1,34 @@ +"""MODI+ Task module - 통신 레이어 + +조건부 import를 통해 pyserial/bleak 없이도 패키지 import 가능 +""" + +# 플래그 초기화 +HAS_SERIAL = False +HAS_BLE = False + +# Serial Task (pyserial 필요) +try: + from modi_plus.task.serialport_task import SerialportTask + HAS_SERIAL = True +except ImportError: + SerialportTask = None + +# BLE Task (bleak 필요) +try: + from modi_plus.util.connection_util import get_ble_task_path + from importlib import import_module + # BLE task는 플랫폼별로 다르므로 여기서 직접 import하지 않음 + HAS_BLE = True +except ImportError: + HAS_BLE = False + +# Connection Task (항상 사용 가능) +from modi_plus.task.connection_task import ConnectionTask + +__all__ = [ + 'ConnectionTask', + 'HAS_SERIAL', + 'HAS_BLE', + 'SerialportTask', +] diff --git a/modi_plus/util/connection_util.py b/modi_plus/util/connection_util.py index 9c130d7..b9a1da7 100644 --- a/modi_plus/util/connection_util.py +++ b/modi_plus/util/connection_util.py @@ -3,7 +3,13 @@ import platform from typing import List -import serial.tools.list_ports as stl +# 조건부 import - pyserial 없어도 기본 기능은 사용 가능 +try: + import serial.tools.list_ports as stl + HAS_SERIAL = True +except ImportError: + stl = None + HAS_SERIAL = False def list_modi_ports() -> List[str]: @@ -11,6 +17,9 @@ def list_modi_ports() -> List[str]: :return: List[ListPortInfo] """ + if not HAS_SERIAL: + return [] + info_list = [] def __is_modi_port(port): diff --git a/packages/core/HISTORY.md b/packages/core/HISTORY.md new file mode 100644 index 0000000..a98144e --- /dev/null +++ b/packages/core/HISTORY.md @@ -0,0 +1,81 @@ +History +== + +0.4.2 (2025-12-22) +-- +* Feature +1. Add RGB raw value properties for Env module v2.x+ + - New properties: `raw_red`, `raw_green`, `raw_blue`, `raw_white` (0-65535) + - New property: `raw_rgb` tuple (raw_red, raw_green, raw_blue, raw_white) +2. Add `set_rgb_mode(mode, duration)` method for Env module + - RGB mode constants: `RGB_MODE_AMBIENT`, `RGB_MODE_ON`, `RGB_MODE_DUALSHOT` +3. Code style fixes for flake8 compatibility + +0.4.0 (2025-11-19) +-- +* Feature +1. Add RGB support for Env module v2.x+ + - New properties: `red`, `green`, `blue`, `white`, `black` (0-100%) + - New property: `rgb` - returns tuple (red, green, blue) + - New property: `color_class` (0-5: unknown/red/green/blue/white/black) + - New property: `brightness` (0-100%) + - Automatic version detection via `_is_rgb_supported()` method + - Raises `AttributeError` when accessing RGB properties on v1.x modules +2. Enhanced GitHub Actions workflows + - Support Python 3.8-3.13 across all platforms + - Platform-specific compatibility fixes (macOS, Windows) + - Improved CI/CD with conditional linting (flake8 for 3.8-3.11, ruff for 3.12+) + +* Tests +1. Add 31 new RGB-related tests + - Version compatibility tests + - RGB property tests + - Data type validation tests + - Total: 94 tests (all passing) + +* Documentation +1. Complete RGB feature documentation +2. GitHub Actions compatibility guides +3. Branch protection setup guide + +0.3.0 (2023-01-19) +-- +* Feature +1. Add `draw_dot` function on display module + +* Patch +1. Fix `write_text` function error on display module if text length is 23 +2. Change module constructor argument from uuid to id + +0.2.1 (2022-12-02) +-- +* Patch +1. Refactor `write_text` input type on display module + +0.2.0 (2022-12-02) +-- +* Feature +1. Refactor getter/setter for each MODI+ module + +0.1.1 (2022-11-23) +-- +* Feature +1. Change python minimum version to 3.7 + +0.1.0 (2022-11-22) +-- +* Feature +1. Add creation examples (brush, dodge) +2. Add network, battery module functions +3. Fix `play_music` function on speaker module +4. Add preset resource on speaker and display module +5. Add search module time and timeout exception + +0.0.2 (2022-11-18) +-- +* Feature +1. Change python minimum version to 3.9 + +0.0.1 (2022-11-15) +-- +* Release initial version of the package on in-house GitHub diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..1aeaadd --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018, LUXROBO + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/MANIFEST.in b/packages/core/MANIFEST.in new file mode 100644 index 0000000..2721a0a --- /dev/null +++ b/packages/core/MANIFEST.in @@ -0,0 +1,15 @@ +include HISTORY.md +include LICENSE +include README.md + +include requirements.txt +include requirements-dev.txt + +recursive-include modi_plus/assets * +recursive-include modi_plus/task/ble_task * + +recursive-include tests * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs conf.py Makefile make.bat diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..9d344d1 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,197 @@ +
+ +[![Python Versions](https://img.shields.io/pypi/pyversions/pymodi-plus.svg?style=flat-square)](https://pypi.python.org/pypi/pymodi-plus) +[![PyPI Release (latest by date)](https://img.shields.io/github/v/release/LUXROBO/pymodi-plus?style=flat-square)](https://pypi.python.org/pypi/pymodi-plus) +[![Read the Docs (version)](https://img.shields.io/readthedocs/pymodi-plus/latest?style=flat-square)](https://pymodi-plus.readthedocs.io/en/latest/?badge=master) +[![GitHub Workflow Status (Build)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) +[![GitHub LICENSE](https://img.shields.io/github/license/LUXROBO/pymodi-plus?style=flat-square&color=blue)](https://github.com/LUXROBO/pymodi-plus/blob/master/LICENSE) +[![Lines of Code](https://img.shields.io/tokei/lines/github/LUXROBO/pymodi-plus?style=flat-square)](https://github.com/LUXROBO/pymodi-plus/tree/master/modi_plus) + +
+ +Description +=========== +> Python API for controlling modular electronics, MODI+. + + +Features +-------- +PyMODI+ provides a control of modular electronics. +* Platform agnostic control of modules through serial connection +* Utilities of wireless connection with BLE (Bluetooth Low Engery) + +Build Status +------------ +|master|develop| +|:---:|:---:| +| [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/build.yml?branch=develop)](https://github.com/LUXROBO/pymodi-plus/actions) + +System Support +-------------- +| System | 3.7 | 3.8 | 3.9 | 3.10 | 3.11 | +| :---: | :---: | :---: | :---: | :---: | :---: | +| Linux | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_ubuntu.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) +| Mac OS | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_macos.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) +| Windows | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/LUXROBO/pymodi-plus/unit_test_windows.yml?branch=master)](https://github.com/LUXROBO/pymodi-plus/actions) + +Contribution Guidelines +----------------------- +We appreciate all contributions. If you are planning to report bugs, please do so [here](https://github.com/LUXROBO/pymodi/issues). Feel free to fork our repository to your local environment, and please send us feedback by filing an issue. + +If you want to contribute to pymodi, be sure to review the contribution guidelines. This project adheres to pymodi's code of conduct. By participating, you are expected to uphold this code. + +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) + +Installation +------------ +> When installing PyMODI+ package, we highly recommend you to use Anaconda to manage the distribution. +> With Anaconda, you can use an isolated virtual environment, solely for PyMODI+. + +[Optional] Once you install [Anaconda](https://docs.anaconda.com/anaconda/install/), then: +``` +# Install new python environment for PyMODI+ package, choose python version >= 3.7 +conda create --name pymodi_plus python=3.7 + +# After you properly install the python environment, activate it +conda activate pymodi_plus + +# Ensure that your python version is compatible with PyMODI+ +python --version +``` + +Install the latest PyMODI+ with all features (USB + BLE): +```bash +pip install pymodi-plus[all] +``` + +Or install with specific features: +```bash +# USB/Serial connection only +pip install pymodi-plus[serial] + +# BLE connection only +pip install pymodi-plus[ble] + +# Minimal install (for web/Pyodide environments) +pip install pymodi-plus +``` + +### Web/Pyodide Support + +For web browser environments using Pyodide, install [pymodi-plus-web](https://github.com/LUXROBO/pymodi-plus-web): +```python +# In Pyodide +import micropip +await micropip.install('pymodi-plus-web') + +from modi_plus_web import MODIPlusWeb +modi = MODIPlusWeb() +``` + +Usage +----- +Import modi_plus package and create MODIPlus object (we call it "bundle", a bundle of MODI+ modules). +```python +# Import modi_plus package +import modi_plus + +""" +Create MODIPlus object, make sure that you have connected your network module +to your machine while other modules are attached to the network module +""" +bundle = modi_plus.MODIPlus() +``` + +[Optional] Specify how you would like to establish the connection between your machine and the network module. +```python +# 1. Serial connection (via USB), it's the default connection method +bundle = modi_plus.MODIPlus(connection_type="serialport") + +# 2. BLE (Bluetooth Low Energy) connection, it's wireless! But it can be slow :( +bundle = modi_plus.MODIPlus(conn_type="ble", network_uuid="YOUR_NETWORK_MODULE_UUID") +``` + +List and create connected modules' object. +```python +# List connected modules +print(bundle.modules) + +# List connected leds +print(bundle.leds) + +# Pick the first led object from the bundle +led = bundle.leds[0] +``` + +Let's blink the LED 5 times. +```python +import time + +for _ in range(5): + # turn on for 0.5 second + led.turn_on() + time.sleep(0.5) + + # turn off for 0.5 second + led.turn_off() + time.sleep(0.5) +``` + +If you are still not sure how to use PyMODI, you can play PyMODI tutorial over REPL: +``` +$ python -m modi_plus --tutorial +``` +As well as an interactive usage examples: +``` +$ python -m modi_plus --usage +``` + +Additional Usage +---------------- +To diagnose MODI+ modules (helpful to find existing malfunctioning modules), +``` +$ python -m modi_plus --inspect +``` + +To initialize MODI+ modules implicitly (set `i` flag to enable REPL mode), +``` +$ python -im modi_plus --initialize +``` + +To see what other commands are available, +``` +$ python -m modi_plus --help +``` + +Documentation +------------- +📚 **Complete documentation is available in the [docs/](./docs/) folder.** + +### Quick Links +- 🚀 [Quick Start Guide](./docs/getting-started/QUICKSTART.md) - Get up and running quickly +- ✨ [Env Module RGB Features](./docs/features/ENV_RGB_FEATURE.md) - New RGB sensor support (v2.x+) +- 🛠️ [Development Guide](./docs/development/MAKEFILE_GUIDE.md) - Build, test, and contribute +- 📦 [Deployment Guide](./docs/deployment/DEPLOY_GUIDE_KOREAN.md) - Release to PyPI +- 🐛 [Troubleshooting](./docs/troubleshooting/) - Platform-specific issues and fixes + +### What's New in v0.4.0 +- ✅ **RGB Color Sensor Support** for Env module v2.x+ + - New properties: `red`, `green`, `blue`, `white`, `black` + - Color classification: `color_class` (0-5) + - Brightness measurement: `brightness` (0-100%) +- ✅ **Enhanced Testing** - 94 tests across all platforms +- ✅ **Python 3.8-3.13 Support** - Wide version compatibility +- ✅ **Improved CI/CD** - GitHub Actions enhancements + +See [Release History](./docs/project/HISTORY.md) for complete changelog. + +Contributing +------------ +We welcome contributions! Please see: +- [Contributing Guidelines](./docs/getting-started/CONTRIBUTING.md) +- [Code of Conduct](./docs/getting-started/CODE_OF_CONDUCT.md) +- [Development Guide](./docs/development/TESTS_README.md) + +License +------- +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/packages/core/examples/basic_usage_examples/active_error.py b/packages/core/examples/basic_usage_examples/active_error.py new file mode 100644 index 0000000..a117d48 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/active_error.py @@ -0,0 +1,29 @@ +import modi_plus +import time +bundle=modi_plus.MODIPlus() + +led=bundle.leds[0] +time.sleep(3) +led.red=255 +time.sleep(3) +led.red=0 +time.sleep(1) + +led.green=100 +time.sleep(3) +led.green=0 +time.sleep(1) + +led.blue=100 +time.sleep(3) +led.blue=0 +time.sleep(1) + +led.turn_on() +time.sleep(2) +led.turn_off() +time.sleep(1) + +led.rgb=(100, 0, 100) +time.sleep(2) +led.turn_off() diff --git a/packages/core/examples/basic_usage_examples/battery_example.py b/packages/core/examples/basic_usage_examples/battery_example.py new file mode 100644 index 0000000..bb3fb49 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/battery_example.py @@ -0,0 +1,16 @@ +import modi_plus +import time + +""" +Example script for the usage of battery module +Make sure you connect 1 battery module to your +network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + battery = bundle.batterys[0] + + while True: + print(f"level(%): {battery.level:<10}", end="\r") + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/button_example.py b/packages/core/examples/basic_usage_examples/button_example.py new file mode 100644 index 0000000..e145a9d --- /dev/null +++ b/packages/core/examples/basic_usage_examples/button_example.py @@ -0,0 +1,22 @@ +import modi_plus +import time + +""" +Example script for the usage of button module +Make sure you connect 1 button module to your +network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + button = bundle.buttons[0] + + while True: + if button.pressed: + print("pressed ", end="\r") + else: + print("not pressed ", end="\r") + if button.double_clicked: + print("double clicked", end="\r") + break + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/dial_example.py b/packages/core/examples/basic_usage_examples/dial_example.py new file mode 100644 index 0000000..1cdba74 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/dial_example.py @@ -0,0 +1,16 @@ +import modi_plus +import time + +""" +Example script for the usage of dial module +Make sure you connect 1 dial module and 1 speaker module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + dial = bundle.dials[0] + speak = bundle.speakers[0] + + while True: + speak.tune = "DO6", dial.turn + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/display_example.py b/packages/core/examples/basic_usage_examples/display_example.py new file mode 100644 index 0000000..1460e20 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/display_example.py @@ -0,0 +1,33 @@ +import modi_plus +import time + +""" +Example script for the usage of display module +Make sure you connect 1 display module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + display = bundle.displays[0] + + display.text = "Bouncing ball simulation..." + time.sleep(3) + + vel = (1, 1) + pos = (20, 30) + + for i in range(500): + display.write_variable_xy(pos[0], pos[1], 0) + pos = (pos[0] + vel[0], pos[1] + vel[1]) + if pos[0] < 0 or pos[0] > 40: + vel = (-vel[0], vel[1]) + if pos[1] < 0 or pos[1] > 60: + vel = (vel[0], -vel[1]) + if pos[1] < 0: + pos = (pos[0], 0) + if pos[0] < 0: + pos = (0, pos[1]) + time.sleep(0.02) + + display.reset() + time.sleep(2) diff --git a/packages/core/examples/basic_usage_examples/env_example.py b/packages/core/examples/basic_usage_examples/env_example.py new file mode 100644 index 0000000..866f207 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/env_example.py @@ -0,0 +1,16 @@ +import modi_plus +import time + +""" +Example script for the usage of env module +Make sure you connect 1 env module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + env = bundle.envs[0] + + while True: + print(f"humidity(%): {env.humidity:<10} temperature(°C): {env.temperature:<10} " + f"illuminance(%): {env.illuminance:<10} Volume(%): {env.volume:<10}", end="\r") + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/env_rgb_color_detection.py b/packages/core/examples/basic_usage_examples/env_rgb_color_detection.py new file mode 100644 index 0000000..491d86f --- /dev/null +++ b/packages/core/examples/basic_usage_examples/env_rgb_color_detection.py @@ -0,0 +1,144 @@ +"""Example: RGB Color Detection with multiple Env modules + +This example demonstrates color detection using RGB sensors. +Works with multiple Env modules simultaneously. +""" + +import modi_plus +import time + + +def detect_color(r, g, b, threshold=50): + """Detect dominant color from RGB values + + Args: + r, g, b: RGB values (0-255) + threshold: Minimum difference to consider dominant + + Returns: + Color name string + """ + # Calculate relative differences + colors = {'R': r, 'G': g, 'B': b} + max_color = max(colors, key=colors.get) + max_value = colors[max_color] + + # Check if dominant enough + other_values = [v for k, v in colors.items() if k != max_color] + if max_value - max(other_values) < threshold: + return "MIXED/GRAY" + + # Determine color + if max_color == 'R': + if g > 100 and b < 50: + return "YELLOW" + elif g < 50 and b < 50: + return "RED" + elif g > 100 and b > 100: + return "WHITE" + elif max_color == 'G': + if r < 50 and b < 50: + return "GREEN" + elif r > 100 and b > 100: + return "WHITE" + elif max_color == 'B': + if r < 50 and g < 50: + return "BLUE" + elif r > 100 and g < 50: + return "PURPLE" + elif r > 100 and g > 100: + return "WHITE" + + return "MIXED" + + +def get_rgb_modules(bundle): + """Get all RGB-capable Env modules""" + rgb_modules = [] + for i, env in enumerate(bundle.envs): + if hasattr(env, '_is_rgb_supported') and env._is_rgb_supported(): + rgb_modules.append({ + 'index': i, + 'env': env, + 'id': env.id, + 'version': env.app_version + }) + return rgb_modules + + +def color_detection_demo(rgb_modules): + """Run color detection on all RGB modules""" + print(f"\n{'=' * 70}") + print(f"Color Detection - {len(rgb_modules)} RGB Sensor(s)") + print("Press Ctrl+C to stop") + print(f"{'=' * 70}\n") + + try: + while True: + output_lines = [] + + for m in rgb_modules: + try: + m['env'].set_rgb_mode(m['env'].RGB_MODE_DUALSHOT) + # m['env'].set_rgb_mode(m['env'].RGB_MODE_ON) + # Ensure RGB mode is enabled + r, g, b = m['env'].rgb + color = detect_color(r, g, b) + + # Create color bar (simple ASCII visualization) + r_bar = '█' * (r // 10) + g_bar = '█' * (g // 10) + b_bar = '█' * (b // 10) + + output_lines.append( + f"Module #{m['index'] + 1} (0x{m['id']:X}): " + f"RGB=({r:3d}, {g:3d}, {b:3d}) -> {color:12s}" + ) + output_lines.append(f" R: {r_bar}") + output_lines.append(f" G: {g_bar}") + output_lines.append(f" B: {b_bar}") + output_lines.append("") + + except Exception as e: + output_lines.append(f"Module #{m['index'] + 1}: Error - {e}\n") + + # Clear and display + print("\033[2J\033[H", end="") # Clear screen + print(f"{'=' * 70}") + print("RGB Color Detection Monitor") + print(f"{'=' * 70}\n") + for line in output_lines: + print(line) + + time.sleep(0.2) + + except KeyboardInterrupt: + print("\n\nStopped by user") + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + + print("=" * 70) + print("RGB Color Detection Example") + print("=" * 70) + + # Find RGB-capable modules + print(f"\nScanning for RGB-capable Env modules...") + rgb_modules = get_rgb_modules(bundle) + + if not rgb_modules: + print("\nError: No RGB-capable Env modules found!") + print("This example requires Env module version 2.x or higher") + bundle.close() + exit(1) + + print(f"Found {len(rgb_modules)} RGB-capable module(s):") + for m in rgb_modules: + print(f" Module #{m['index'] + 1}: ID=0x{m['id']:X}, Version={m['version']}") + + # Start color detection + input("\nPress Enter to start color detection...") + color_detection_demo(rgb_modules) + + bundle.close() diff --git a/packages/core/examples/basic_usage_examples/env_rgb_example.py b/packages/core/examples/basic_usage_examples/env_rgb_example.py new file mode 100644 index 0000000..5f8cbef --- /dev/null +++ b/packages/core/examples/basic_usage_examples/env_rgb_example.py @@ -0,0 +1,173 @@ +"""Example of using Env module RGB and color properties + +This example demonstrates how to use the color sensor properties +of the Env module including: +- RGB (red, green, blue) values +- White and Black values +- Color class detection (red/green/blue/white/black/unknown) +- Brightness value + +Note: These properties are only available in version 2.x and above. +This example tests ALL connected Env modules. +""" + +import os +import sys + +import modi_plus +import time + +# --- OS 구분 --- +IS_WINDOWS = (os.name == "nt") + +if IS_WINDOWS: + import msvcrt +else: + import termios + import tty + import select + + +def get_key_nonblocking(): + """ + - 키가 눌리면: 1글자(str) 반환 + - 아무 키도 없으면: None 반환 + """ + if IS_WINDOWS: + # Windows: msvcrt 사용 + if msvcrt.kbhit(): + ch = msvcrt.getch() + try: + return ch.decode(errors="ignore") + except Exception: + return None + return None + else: + # macOS / Linux: select + cbreak 모드에서 stdin 읽기 + dr, _, _ = select.select([sys.stdin], [], [], 0) + if dr: + return sys.stdin.read(1) + return None + + +# --- macOS / Linux에서는 터미널 모드 변경 필요(cbreak) --- +if not IS_WINDOWS: + fd = sys.stdin.fileno() + old_term_attr = termios.tcgetattr(fd) + tty.setcbreak(fd) # Enter 없이 한 글자씩 읽히도록 + +def test_env_module(env, index): + """Test a single Env module for RGB support""" + print(f"\n{'=' * 60}") + print(f"Env Module #{index + 1} (ID: 0x{env.id:X})") + print(f"{'=' * 60}") + print(f"App Version: {env.app_version}") + + # Check if version supports RGB + if hasattr(env, '_is_rgb_supported') and env._is_rgb_supported(): + print("✓ RGB properties are supported!") + return True + else: + print("✗ RGB properties are NOT supported in this version") + print("Please upgrade firmware to version 2.x or above") + print("\nAvailable properties:") + print(f" - Temperature: {env.temperature}°C") + print(f" - Humidity: {env.humidity}%") + print(f" - Illuminance: {env.illuminance} lux") + print(f" - Volume: {env.volume} dB") + return False + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + + print("=" * 60) + print("Env Module Color Sensor Example - Multi-Module Support") + print("=" * 60) + + # Check how many Env modules are connected + num_envs = len(bundle.envs) + print(f"\nFound {num_envs} Env module(s)") + + if num_envs == 0: + print("Error: No Env modules found!") + bundle.close() + exit(1) + + # Test each Env module + rgb_supported_modules = [] + for i, env in enumerate(bundle.envs): + if test_env_module(env, i): + rgb_supported_modules.append((i, env)) + + stop_flag = False + + # If any module supports RGB, start continuous reading + if rgb_supported_modules: + print(f"\n{'=' * 60}") + print(f"Reading color sensor values from {len(rgb_supported_modules)} module(s)") + print("Press Ctrl+C to stop") + print(f"{'=' * 60}\n") + + # Color class 이름 매핑 + color_names = { + 0: "unknown", + 1: "red", + 2: "green", + 3: "blue", + 4: "white", + 5: "black" + } + + try: + while True: + ch = get_key_nonblocking() + if ch is not None: + ch = ch.lower() + if ch == 'q': + print("\n\nStop command received. Exiting...") + break + elif ch == 's': + stop_flag = not stop_flag + if stop_flag: + print("\nReading paused. Press 's' to resume.") + else: + print("\nReading resumed.") + + if stop_flag: + time.sleep(0.1) + continue + + # Read and display all color properties from all supported modules + for idx, env in rgb_supported_modules: + # env.set_rgb_mode(env.RGB_MODE_DUALSHOT) + env.set_rgb_mode(env.RGB_MODE_AMBIENT) + try: + r, g, b = env.rgb + white = env.white + black = env.black + color_class = env.color_class + brightness = env.brightness + color_name = color_names.get(color_class, "unknown") + + print(f"Module #{idx + 1}: ", end="") + print(f"RGB=({r:3d},{g:3d},{b:3d}) ", end="") + print(f"W={white:3d} B={black:3d} ", end="") + print(f"Bright={brightness:3d} ", end="") + print(f"Color={color_name:7s}", end=" ") + except Exception as e: + print(f"Module #{idx + 1}: Error - {e}", end=" ") + + print("\r", end="", flush=True) + time.sleep(0.1) + + except KeyboardInterrupt: + print("\n\nStopped by user") + + else: + print(f"\n{'=' * 60}") + print("No modules with RGB support found.") + print("All connected modules are version 1.x") + print(f"{'=' * 60}") + + bundle.close() diff --git a/packages/core/examples/basic_usage_examples/env_rgb_mixed_versions.py b/packages/core/examples/basic_usage_examples/env_rgb_mixed_versions.py new file mode 100644 index 0000000..4728567 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/env_rgb_mixed_versions.py @@ -0,0 +1,133 @@ +"""Example: Mixed version Env modules (v1.x and v2.x together) + +This example shows how to handle multiple Env modules with different versions. +Some modules may support RGB (v2.x+) while others don't (v1.x). +""" + +import modi_plus +import time + + +def classify_env_modules(envs): + """Classify Env modules by RGB support""" + rgb_modules = [] + legacy_modules = [] + + for i, env in enumerate(envs): + module_info = { + 'index': i, + 'env': env, + 'id': env.id, + 'version': env.app_version + } + + if hasattr(env, '_is_rgb_supported') and env._is_rgb_supported(): + rgb_modules.append(module_info) + else: + legacy_modules.append(module_info) + + return rgb_modules, legacy_modules + + +def print_module_summary(rgb_modules, legacy_modules): + """Print summary of all connected modules""" + total = len(rgb_modules) + len(legacy_modules) + + print(f"\n{'=' * 70}") + print(f"Connected Env Modules Summary ({total} total)") + print(f"{'=' * 70}") + + if rgb_modules: + print(f"\n✓ RGB-capable modules (v2.x+): {len(rgb_modules)}") + for m in rgb_modules: + print(f" Module #{m['index'] + 1}: ID=0x{m['id']:X}, Version={m['version']}") + + if legacy_modules: + print(f"\n✗ Legacy modules (v1.x): {len(legacy_modules)}") + for m in legacy_modules: + print(f" Module #{m['index'] + 1}: ID=0x{m['id']:X}, Version={m['version']}") + + +def read_all_sensors(rgb_modules, legacy_modules): + """Read all available sensors from all modules""" + print(f"\n{'=' * 70}") + print("Reading sensor values from all modules (Press Ctrl+C to stop)") + print(f"{'=' * 70}\n") + + try: + while True: + output_lines = [] + + # Read RGB from v2.x modules + if rgb_modules: + output_lines.append("RGB Modules:") + for m in rgb_modules: + try: + r, g, b = m['env'].rgb + temp = m['env'].temperature + output_lines.append( + f" #{m['index'] + 1}: RGB=({r:3d},{g:3d},{b:3d}) " + f"Temp={temp:2d}°C" + ) + except Exception as e: + output_lines.append(f" #{m['index'] + 1}: Error - {e}") + + # Read sensors from v1.x modules + if legacy_modules: + output_lines.append("\nLegacy Modules:") + for m in legacy_modules: + try: + temp = m['env'].temperature + hum = m['env'].humidity + lux = m['env'].illuminance + output_lines.append( + f" #{m['index'] + 1}: Temp={temp:2d}°C " + f"Humidity={hum:2d}% Lux={lux:3d}" + ) + except Exception as e: + output_lines.append(f" #{m['index'] + 1}: Error - {e}") + + # Clear screen and print + print("\033[2J\033[H", end="") # Clear screen + print(f"{'=' * 70}") + print("Multi-Version Env Modules Monitor") + print(f"{'=' * 70}") + for line in output_lines: + print(line) + + time.sleep(0.2) + + except KeyboardInterrupt: + print("\n\nStopped by user") + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + + print("=" * 70) + print("Mixed Version Env Modules Example") + print("=" * 70) + + # Check connected modules + num_envs = len(bundle.envs) + print(f"\nDetecting Env modules... Found {num_envs} module(s)") + + if num_envs == 0: + print("Error: No Env modules found!") + bundle.close() + exit(1) + + # Classify by version + rgb_modules, legacy_modules = classify_env_modules(bundle.envs) + + # Print summary + print_module_summary(rgb_modules, legacy_modules) + + # Start reading + if rgb_modules or legacy_modules: + input("\nPress Enter to start monitoring...") + read_all_sensors(rgb_modules, legacy_modules) + else: + print("\nNo modules found!") + + bundle.close() diff --git a/packages/core/examples/basic_usage_examples/env_rgb_stats.py b/packages/core/examples/basic_usage_examples/env_rgb_stats.py new file mode 100644 index 0000000..b29616b --- /dev/null +++ b/packages/core/examples/basic_usage_examples/env_rgb_stats.py @@ -0,0 +1,332 @@ +"""Example of using Env module RGB and color properties + +This example demonstrates how to use the color sensor properties +of the Env module including: +- RGB (red, green, blue) values +- White and Black values +- Color class detection (red/green/blue/white/black/unknown) +- Brightness value + +Note: These properties are only available in version 2.x and above. +This example tests ALL connected Env modules. +""" + +import os +import sys + +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +import modi_plus +import time + +# --- OS 구분 --- +IS_WINDOWS = (os.name == "nt") + +if IS_WINDOWS: + import msvcrt +else: + import termios + import tty + import select + + +# --------------------------------------------------------------------------- +# Statistics helpers (refactored from globals -> class) +# --------------------------------------------------------------------------- + +@dataclass +class RunningStat: + """min/max/avg용 누적 통계""" + min: Optional[int] = None + max: Optional[int] = None + sum: int = 0 + count: int = 0 + + def update(self, value: int) -> None: + if self.min is None or value < self.min: + self.min = value + if self.max is None or value > self.max: + self.max = value + self.sum += int(value) + self.count += 1 + + @property + def avg(self) -> float: + return (self.sum / self.count) if self.count else 0.0 + + +class EnvStats: + """Env RGB/조도 측정값 + color_name count 통계를 관리""" + + def __init__(self, metric_names=None) -> None: + if metric_names is None: + metric_names = ("raw_r", "raw_g", "raw_b", "raw_w", "r", "g", "b", "white", "black", "brightness") + self._metric_names = tuple(metric_names) + self.reset() + + def reset(self) -> None: + # 숫자 통계 + self.metrics: Dict[str, RunningStat] = {n: RunningStat() for n in self._metric_names} + # color_name 통계 + self.color_counts: Dict[str, int] = {} + + def update_metric(self, name: str, value: int) -> None: + if name not in self.metrics: + # 오타/새 항목이 들어와도 안전하게 처리 + self.metrics[name] = RunningStat() + self.metrics[name].update(value) + + def update_color(self, color_name: Optional[str]) -> None: + if not color_name: + color_name = "None" + self.color_counts[color_name] = self.color_counts.get(color_name, 0) + 1 + + def top_color(self) -> Tuple[str, int]: + if not self.color_counts: + return ("N/A", 0) + name, cnt = max(self.color_counts.items(), key=lambda x: x[1]) + return (name, cnt) + + def print_stats(self) -> None: + """현재까지 누적된 통계 출력""" + print("\n\n=== Measurement Statistics ===") + for name in self._metric_names: + s = self.metrics[name] + if s.count == 0: + print(f"{name:10s}: no data") + else: + print(f"{name:10s} min={s.min:4d} max={s.max:4d} avg={s.avg:8.2f} (n={s.count})") + + # color_name 통계 + print("\n=== Color Name Counts ===") + if not self.color_counts: + print("no color data") + return + + sorted_colors = sorted(self.color_counts.items(), key=lambda x: -x[1]) + max_count = sorted_colors[0][1] if sorted_colors else 0 + top_color = sorted_colors[0][0] if sorted_colors else "N/A" + + for cname, cnt in sorted_colors: + line = f"{cname:10s}: {cnt:5d}" + if cnt == max_count: + line += " <== The top-ranked color" + print(line) + + # 요약 한줄 + raw_r = self.metrics["raw_r"].avg + raw_g = self.metrics["raw_g"].avg + raw_b = self.metrics["raw_b"].avg + raw_w = self.metrics["raw_w"].avg + + r = self.metrics["r"].avg + g = self.metrics["g"].avg + b = self.metrics["b"].avg + w = self.metrics["white"].avg + k = self.metrics["black"].avg + print("\n[Summary] RAW_R,RAW_G,RAW_B,RAW_W,R,G,B,W,K Avg,Top Color") + print(f"{raw_r:.1f},{raw_g:.1f},{raw_b:.1f},{raw_w:.1f},", end="") + print(f"{r:.1f},{g:.1f},{b:.1f},{w:.1f},{k:.1f},{top_color}") + + +# 통계 인스턴스 딕셔너리: module_idx -> EnvStats +# rgb_supported_modules 기준으로 동적 생성 +stats_by_module: Dict[int, EnvStats] = {} + + +def get_stats(module_idx: int) -> EnvStats: + """모듈 인덱스에 해당하는 EnvStats 반환 (없으면 생성)""" + if module_idx not in stats_by_module: + stats_by_module[module_idx] = EnvStats() + return stats_by_module[module_idx] + + +def reset_all_stats() -> None: + """모든 모듈의 통계 초기화""" + for s in stats_by_module.values(): + s.reset() + + +def print_all_stats() -> None: + """모든 모듈의 통계 출력""" + for module_idx, s in sorted(stats_by_module.items()): + print(f"\n{'#' * 60}") + print(f"# Module #{module_idx + 1} Statistics") + print(f"{'#' * 60}") + s.print_stats() +def get_key_nonblocking(): + """ + - 키가 눌리면: 1글자(str) 반환 + - 아무 키도 없으면: None 반환 + """ + if IS_WINDOWS: + # Windows: msvcrt 사용 + if msvcrt.kbhit(): + ch = msvcrt.getch() + try: + return ch.decode(errors="ignore") + except Exception: + return None + return None + else: + # macOS / Linux: select + cbreak 모드에서 stdin 읽기 + dr, _, _ = select.select([sys.stdin], [], [], 0) + if dr: + return sys.stdin.read(1) + return None + + +# --- macOS / Linux에서는 터미널 모드 변경 필요(cbreak) --- +if not IS_WINDOWS: + fd = sys.stdin.fileno() + old_term_attr = termios.tcgetattr(fd) + tty.setcbreak(fd) # Enter 없이 한 글자씩 읽히도록 + +def test_env_module(env, index): + """Test a single Env module for RGB support""" + print(f"\n{'=' * 60}") + print(f"Env Module #{index + 1} (ID: 0x{env.id:X})") + print(f"{'=' * 60}") + print(f"App Version: {env.app_version}") + + # Check if version supports RGB + if hasattr(env, '_is_rgb_supported') and env._is_rgb_supported(): + print("✓ RGB properties are supported!") + return True + else: + print("✗ RGB properties are NOT supported in this version") + print("Please upgrade firmware to version 2.x or above") + print("\nAvailable properties:") + print(f" - Temperature: {env.temperature}°C") + print(f" - Humidity: {env.humidity}%") + print(f" - Illuminance: {env.illuminance} lux") + print(f" - Volume: {env.volume} dB") + return False + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + + print("=" * 60) + print("Env Module Color Sensor Example - Multi-Module Support") + print("=" * 60) + + # Check how many Env modules are connected + num_envs = len(bundle.envs) + print(f"\nFound {num_envs} Env module(s)") + + if num_envs == 0: + print("Error: No Env modules found!") + bundle.close() + exit(1) + + # Test each Env module + rgb_supported_modules = [] + for i, env in enumerate(bundle.envs): + if test_env_module(env, i): + rgb_supported_modules.append((i, env)) + + stop_flag = False + + # If any module supports RGB, start continuous reading + if rgb_supported_modules: + print(f"\n{'=' * 60}") + print(f"Reading color sensor values from {len(rgb_supported_modules)} module(s)") + print("Press Ctrl+C to stop") + print(f"{'=' * 60}\n") + + # Color class 이름 매핑 + color_names = { + 0: "unknown", + 1: "red", + 2: "green", + 3: "blue", + 4: "white", + 5: "black" + } + + # 각 모듈별 통계 초기화 + for idx, env in rgb_supported_modules: + get_stats(idx).reset() + stats_count = 0 + try: + while True: + ch = get_key_nonblocking() + if ch is not None: + ch = ch.lower() + if ch == 'q': + print("\n\nStop command received. Exiting...") + break + elif ch == 's': + stop_flag = not stop_flag + if stop_flag: + print("\nReading paused. Press 's' to resume.") + print_all_stats() + reset_all_stats() + stats_count = 0 + else: + reset_all_stats() + stats_count = 0 + print("\nReading resumed.") + elif stats_count >= 100: + stop_flag = True + print_all_stats() + reset_all_stats() + stats_count = 0 + + if stop_flag: + time.sleep(0.1) + continue + + # Read and display all color properties from all supported modules + for idx, env in rgb_supported_modules: + # env.set_rgb_mode(env.RGB_MODE_DUALSHOT) + env.set_rgb_mode(env.RGB_MODE_ON, 300) + try: + r, g, b = env.rgb + raw_r, raw_g, raw_b, raw_w = env.raw_rgb + white = env.white + black = env.black + color_class = env.color_class + brightness = env.brightness + color_name = color_names.get(color_class, "unknown") + + # --- 모듈별 통계 갱신 --- + module_stats = get_stats(idx) + module_stats.update_metric("raw_r", raw_r) + module_stats.update_metric("raw_g", raw_g) + module_stats.update_metric("raw_b", raw_b) + module_stats.update_metric("raw_w", raw_w) + + module_stats.update_metric("r", r) + module_stats.update_metric("g", g) + module_stats.update_metric("b", b) + module_stats.update_metric("white", white) + module_stats.update_metric("black", black) + module_stats.update_metric("brightness", brightness) + module_stats.update_color(color_name) + + print(f"Module #{idx + 1}: ", end="") + print(f"RAW_RGB=({raw_r:5d},{raw_g:5d},{raw_b:5d},{raw_w:5d}) ", end="") + print(f"RGB=({r:3d},{g:3d},{b:3d}) ", end="") + print(f"W={white:3d} B={black:3d} ", end="") + print(f"Bright={brightness:3d} ", end="") + print(f"Color={color_name:7s}", end=" ") + stats_count += 1 + except Exception as e: + print(f"Module #{idx + 1}: Error - {e}", end=" ") + + print("\r", end="", flush=True) + time.sleep(0.1) + + except KeyboardInterrupt: + print("\n\nStopped by user") + + else: + print(f"\n{'=' * 60}") + print("No modules with RGB support found.") + print("All connected modules are version 1.x") + print(f"{'=' * 60}") + + bundle.close() \ No newline at end of file diff --git a/packages/core/examples/basic_usage_examples/error1.py b/packages/core/examples/basic_usage_examples/error1.py new file mode 100644 index 0000000..0b7dc9e --- /dev/null +++ b/packages/core/examples/basic_usage_examples/error1.py @@ -0,0 +1,23 @@ +import modi_plus +import time + +bundle = modi_plus.MODIPlus() + +button = bundle.buttons[0] +led = bundle.leds[0] + +mode = 0 +while True: + if button.double_clicked: + break + if button.clicked: + mode = mode + 1 + if mode == 1: + led.rgb = 20, 20, 20 + elif mode == 2: + led.rgb = 60, 60, 60 + elif mode == 3: + led.rgb = 100, 100, 100 + elif mode == 4: + led.rgb = 0, 0, 0 + mode = 0 diff --git a/packages/core/examples/basic_usage_examples/imu_example.py b/packages/core/examples/basic_usage_examples/imu_example.py new file mode 100644 index 0000000..24d8af8 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/imu_example.py @@ -0,0 +1,23 @@ +import modi_plus +import time + +""" +Example script for the usage of imu module +Make sure you connect 1 imu module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + imu = bundle.imus[0] + + while True: + print(f"Angle_y: {imu.angle_y:<10}" + f"Angle_x: {imu.angle_x:<10}" + f"Angle_z: {imu.angle_z:<10}" + f"Vel x: {imu.angular_vel_x:<10}" + f"Vel y: {imu.angular_vel_y:<10}" + f"Vel z: {imu.angular_vel_z:<10}" + f"Acc x: {imu.acceleration_x:<10}" + f"Acc y: {imu.acceleration_y:<10}" + f"Acc z: {imu.acceleration_z:<10}", end="\r") + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/joystick_example.py b/packages/core/examples/basic_usage_examples/joystick_example.py new file mode 100644 index 0000000..8bbf9b4 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/joystick_example.py @@ -0,0 +1,15 @@ +import modi_plus +import time + +""" +Example script for the usage of joystick module +Make sure you connect 1 joystick module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + joystick = bundle.joysticks[0] + + while True: + print("x: {0:<10} y: {1:<10} direction: {2:<10}".format(joystick.x, joystick.y, joystick.direction), end="\r") + time.sleep(0.02) diff --git a/packages/core/examples/basic_usage_examples/led_example.py b/packages/core/examples/basic_usage_examples/led_example.py new file mode 100644 index 0000000..69e3adc --- /dev/null +++ b/packages/core/examples/basic_usage_examples/led_example.py @@ -0,0 +1,30 @@ +import modi_plus +import time + +""" +Example script for the usage of led module +Make sure you connect 1 led module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + led = bundle.leds[0] + + led.blue = 100 + time.sleep(1) + led.blue = 0 + time.sleep(1) + led.green = 255 + time.sleep(1) + led.green = 0 + time.sleep(1) + led.red = 100 + time.sleep(1) + for c in range(100): + led.rgb = 100 - c, c, 0 + time.sleep(0.02) + + led.turn_on() + time.sleep(1) + led.turn_off() + time.sleep(1) diff --git a/packages/core/examples/basic_usage_examples/motor_example.py b/packages/core/examples/basic_usage_examples/motor_example.py new file mode 100644 index 0000000..324ca81 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/motor_example.py @@ -0,0 +1,22 @@ +import modi_plus +import time + +""" +Example script for the usage of motor module +Make sure you connect 1 motor module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + motor = bundle.motors[0] + + motor.angle = 0, 70 + time.sleep(3) + motor.angle = 50, 70 + time.sleep(3) + motor.speed = 50 + time.sleep(3) + motor.speed = 100 + time.sleep(3) + motor.speed = 0 + time.sleep(1) diff --git a/packages/core/examples/basic_usage_examples/speaker_example.py b/packages/core/examples/basic_usage_examples/speaker_example.py new file mode 100644 index 0000000..d8cf341 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/speaker_example.py @@ -0,0 +1,20 @@ +import modi_plus +import time + +""" +Example script for the usage of speaker module +Make sure you connect 1 speaker module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + speak = bundle.speakers[0] + + speak.tune = 800, 70 + time.sleep(3) + speak.frequency = 700 + time.sleep(3) + speak.volume = 100 + time.sleep(1) + speak.reset() + time.sleep(1) diff --git a/packages/core/examples/basic_usage_examples/tof_example.py b/packages/core/examples/basic_usage_examples/tof_example.py new file mode 100644 index 0000000..36a2807 --- /dev/null +++ b/packages/core/examples/basic_usage_examples/tof_example.py @@ -0,0 +1,15 @@ +import modi_plus +import time + +""" +Example script for the usage of tof module +Make sure you connect 1 tof module to your network module +""" + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + tof = bundle.tofs[0] + + while True: + print(f"Distance(cm): {tof.distance:<10} ", end="\r") + time.sleep(0.02) diff --git a/packages/core/examples/creation_examples/brush.py b/packages/core/examples/creation_examples/brush.py new file mode 100644 index 0000000..cc2aa3b --- /dev/null +++ b/packages/core/examples/creation_examples/brush.py @@ -0,0 +1,32 @@ +import modi_plus +from playscii import GameManager, GameObject +from math import sin, radians + + +class BrushManager(GameManager): + def __init__(self, size, imu, button): + super().__init__(size) + self.cursor = Brush(pos=(size[0] // 2, size[1] // 2), render="o") + self.imu = imu + self.button = button + + def setup(self): + self.add_object(self.cursor) + + def update(self): + h, w = self.height // 2, self.width // 2 + self.cursor.y = h - h * sin(radians(-self.imu.angle_x)) + self.cursor.x = w - w * sin(radians(-self.imu.angle_y)) + if self.button.pressed: + self.add_object(Brush((self.cursor.x, self.cursor.y), "x")) + + +class Brush(GameObject): + def update(self): + pass + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + canvas = BrushManager((100, 20), bundle.imus[0], bundle.buttons[0]) + canvas.start() diff --git a/packages/core/examples/creation_examples/dodge.py b/packages/core/examples/creation_examples/dodge.py new file mode 100644 index 0000000..e283d2b --- /dev/null +++ b/packages/core/examples/creation_examples/dodge.py @@ -0,0 +1,58 @@ +import modi_plus +from playscii import GameManager, GameObject +from random import randint +import time + +""" +This example requires you to install playascii package +""" + +PLAYER_RENDER = " O \n" \ + "/|\\ \n" \ + "/ \\" +MODI_RENDER = "-------\n" \ + "|MODI+|\n" \ + "_______" + + +class DodgeManager(GameManager): + + def __init__(self, controller): + super().__init__((50, 20)) + + self.player = self.GameObject( + pos=(25, 2), + render=PLAYER_RENDER) + self.fire = self.GameObject(render=MODI_RENDER) + self.imu = controller.imus[0] + self.button = controller.buttons[0] + + def setup(self): + self.set_title("PyMODI+ Dodge") + self.add_object(self.player) + self.add_object(self.fire) + self.fire.x, self.fire.y = 25, 20 + + def update(self): + angle_y = -self.imu.angle_y + if angle_y < -5 and self.player.x < 48: + self.player.x += 30 * self.delta_time + elif angle_y > 5 and self.player.x > 0: + self.player.x -= 30 * self.delta_time + self.fire.y -= 15 * self.delta_time + if self.fire.y < 0: + self.fire.x, self.fire.y = randint(0, 40), 25 + if self.fire.y < 3 and (self.fire.x - 4 <= self.player.x <= self.fire.x + 4): + self.set_title("GAME OVER") + self.set_flag("quit", True) + + class GameObject(GameObject): + def update(self): + pass + + +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + game_manager = DodgeManager(bundle) + game_manager.start() + time.sleep(3) diff --git a/packages/core/examples/creation_examples/requirements.txt b/packages/core/examples/creation_examples/requirements.txt new file mode 100644 index 0000000..56dbca8 --- /dev/null +++ b/packages/core/examples/creation_examples/requirements.txt @@ -0,0 +1 @@ +pyplayscii diff --git a/packages/core/examples/intermediate_usage_examples/multi_module_example.py b/packages/core/examples/intermediate_usage_examples/multi_module_example.py new file mode 100644 index 0000000..5875ae0 --- /dev/null +++ b/packages/core/examples/intermediate_usage_examples/multi_module_example.py @@ -0,0 +1,26 @@ +import modi_plus + +""" +This example explains the convention that pymodi uses when multiple modules +with the same type are connected. + +When multiple modules are connected, the modules are sorted in ascending connected time. +""" + +# Let say you run the code below, then one of the four cases below will occur +if __name__ == "__main__": + bundle = modi_plus.MODIPlus() + led0 = bundle.leds[0] + led1 = bundle.leds[1] + +""" +It is also possible to access modules by there id or uuid. + +led0 = bundle.leds.get(0x881) +led1 = bundle.leds.get(0xA55) + +or + +led0 = bundle.led(0x881) +led1 = bundle.led(0xA55) +""" diff --git a/packages/core/modi_plus/__init__.py b/packages/core/modi_plus/__init__.py new file mode 100644 index 0000000..b84dd3c --- /dev/null +++ b/packages/core/modi_plus/__init__.py @@ -0,0 +1,13 @@ +"""Top-level package for pyMODI+.""" +from modi_plus import about +from modi_plus.modi_plus import ( + MODIPlus, +) + +__all__ = [ + "MODIPlus", +] + +__version__ = about.__version__ + +print(f"Running PyMODI+ (v{__version__})") diff --git a/packages/core/modi_plus/__main__.py b/packages/core/modi_plus/__main__.py new file mode 100644 index 0000000..e85a573 --- /dev/null +++ b/packages/core/modi_plus/__main__.py @@ -0,0 +1,130 @@ +import os +import sys +import time +import textwrap + +from getopt import getopt, GetoptError + +import modi_plus + +from modi_plus.util.usage_util import UsageInstructor +from modi_plus.util.tutorial_util import Tutor +from modi_plus.util.inspection_util import Inspector + +from modi_plus.util.message_util import parse_message, decode_message + + +def check_option(*options): + for o, a in opts: + if o in options: + return a if a else True + return False + + +if __name__ == "__main__": + usage = textwrap.dedent( + """ + Usage: python -m modi_plus - + Options: + -t, --tutorial: Interactive Tutorial + -h, --help: Print out help page + """.rstrip() + ) + + help_page = textwrap.dedent( + """ + Usage: python -m modi_plus - + Options: + -t, --tutorial: Interactive Tutorial + Usage: python -m modi_plus --tutorial + """.rstrip() + ) + + try: + # all commands should be defined here in advance + opts, args = getopt( + sys.argv[1:], "tahvpiu", + [ + "tutorial", + "initialize", + "help", + "verbose", + "performance", + "inspect", + "usage", + ] + ) + # exit program if an invalid option has been entered + except GetoptError as err: + print(str(err)) + print(usage) + os._exit(2) + + # Ensure that there is an option but argument + if len(sys.argv) == 1 or len(args) > 0: + print(usage) + os._exit(2) + + # Print help page + if check_option("-h", "--help"): + print(help_page) + os._exit(0) + + # Start interactive pymodi+ tutorial + if check_option("-t", "--tutorial"): + pymodi_tutor = Tutor() + pymodi_tutor.run_introduction() + os._exit(0) + + # Time message transfer between local machine and network module + if check_option("-p", "--performance"): + print("[PyMODI+ Performance Test]" + "\n" + "=" * 25) + init_time = time.time() + bundle = modi_plus.MODIPlus() + fin_time = time.time() + took = (fin_time - init_time) * 100 // 1 / 100 + print(f"Took {took} seconds to initialize") + req_tp_msg = parse_message(0x2A, 0, bundle.networks[0].id) + print(f"request message... {req_tp_msg}") + bundle._exe_thread.close() + init_time = time.perf_counter() + bundle.send(req_tp_msg) + msg = None + while True: + msg = bundle.recv() + if not msg: + continue + recv_cmd = decode_message(msg)[0] + if recv_cmd == 0x07: + break + fin_time = time.perf_counter() + took = fin_time - init_time + print(f"received message... {msg}") + print(f"Took {took / 2:.10f} seconds for message transfer") + os._exit(0) + + # Initialize modules implicitly + if check_option("-a", "--initialize"): + # TODO: Handle when there are more than one module with the same type + print(">>> bundle = modi_plus.MODIPlus()") + init_time = time.time() + bundle = modi_plus.MODIPlus(verbose=check_option("-v", "--verbose")) + fin_time = time.time() + print(f"Took {fin_time - init_time:.2f} seconds to init MODI+ modules") + + for module in bundle.modules: + module_name = module.module_type.lower() + print(">>> " + module_name + " = bundle." + module_name + "s[0]") + exec(module_name + " = module") + + # Run inspection mode + if check_option("-i", "--inspect"): + pymodi_inspector = Inspector() + pymodi_inspector.run_inspection() + os._exit(0) + + # Show each module usage + if check_option("-u", "--usage"): + usage = UsageInstructor() + usage.run_usage_manual() + os._exit(0) diff --git a/packages/core/modi_plus/_exe_thread.py b/packages/core/modi_plus/_exe_thread.py new file mode 100644 index 0000000..bd30853 --- /dev/null +++ b/packages/core/modi_plus/_exe_thread.py @@ -0,0 +1,28 @@ +import threading as th + +from modi_plus.task.exe_task import ExeTask + + +class ExeThread(th.Thread): + """ + :param dict() modules: dict() of module instance + """ + + def __init__(self, modules, connection_task): + super().__init__(daemon=True) + connection_task.open_connection() + self.__exe_task = ExeTask(modules, connection_task) + self.__kill_sig = False + + def close(self): + self.__kill_sig = True + + def run(self) -> None: + """ Run executor task + + :return: None + """ + while True: + self.__exe_task.run(delay=0.001) + if self.__kill_sig: + break diff --git a/packages/core/modi_plus/about.py b/packages/core/modi_plus/about.py new file mode 100644 index 0000000..a19d49e --- /dev/null +++ b/packages/core/modi_plus/about.py @@ -0,0 +1,8 @@ +__title__ = "pymodi-plus" +__version__ = "0.4.2" +__author__ = "LUXROBO" +__email__ = "module.dev@luxrobo.com" +__description__ = "Python API for controlling modular electronics, MODI+." +__url__ = "https://github.com/LUXROBO/pymodi-plus" +__license__ = "MIT" +__summary__ = "Python API for controlling modular electronics, MODI+." diff --git a/packages/core/modi_plus/assets/firmware/esp32/bootloader.bin b/packages/core/modi_plus/assets/firmware/esp32/bootloader.bin new file mode 100644 index 0000000..7c5db03 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/esp32/bootloader.bin differ diff --git a/packages/core/modi_plus/assets/firmware/esp32/esp32.bin b/packages/core/modi_plus/assets/firmware/esp32/esp32.bin new file mode 100644 index 0000000..b22f61e Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/esp32/esp32.bin differ diff --git a/packages/core/modi_plus/assets/firmware/esp32/modi_ota_factory.bin b/packages/core/modi_plus/assets/firmware/esp32/modi_ota_factory.bin new file mode 100644 index 0000000..b19f70c Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/esp32/modi_ota_factory.bin differ diff --git a/packages/core/modi_plus/assets/firmware/esp32/ota_data_initial.bin b/packages/core/modi_plus/assets/firmware/esp32/ota_data_initial.bin new file mode 100644 index 0000000..b4033a7 --- /dev/null +++ b/packages/core/modi_plus/assets/firmware/esp32/ota_data_initial.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/modi_plus/assets/firmware/esp32/partitions.bin b/packages/core/modi_plus/assets/firmware/esp32/partitions.bin new file mode 100644 index 0000000..62909ff Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/esp32/partitions.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/battery.bin b/packages/core/modi_plus/assets/firmware/modules/battery.bin new file mode 100644 index 0000000..5970df8 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/battery.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/button.bin b/packages/core/modi_plus/assets/firmware/modules/button.bin new file mode 100644 index 0000000..c812c98 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/button.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/dial.bin b/packages/core/modi_plus/assets/firmware/modules/dial.bin new file mode 100644 index 0000000..8e24cdd Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/dial.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/display.bin b/packages/core/modi_plus/assets/firmware/modules/display.bin new file mode 100644 index 0000000..dd63b21 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/display.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/env.bin b/packages/core/modi_plus/assets/firmware/modules/env.bin new file mode 100644 index 0000000..a684399 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/env.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/imu.bin b/packages/core/modi_plus/assets/firmware/modules/imu.bin new file mode 100644 index 0000000..d9b46ac Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/imu.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/joystick.bin b/packages/core/modi_plus/assets/firmware/modules/joystick.bin new file mode 100644 index 0000000..28188a4 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/joystick.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/led.bin b/packages/core/modi_plus/assets/firmware/modules/led.bin new file mode 100644 index 0000000..b9b4a74 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/led.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/motor.bin b/packages/core/modi_plus/assets/firmware/modules/motor.bin new file mode 100644 index 0000000..f9aa2ce Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/motor.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/network.bin b/packages/core/modi_plus/assets/firmware/modules/network.bin new file mode 100644 index 0000000..b556e34 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/network.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/speaker.bin b/packages/core/modi_plus/assets/firmware/modules/speaker.bin new file mode 100644 index 0000000..e91b7fa Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/speaker.bin differ diff --git a/packages/core/modi_plus/assets/firmware/modules/tof.bin b/packages/core/modi_plus/assets/firmware/modules/tof.bin new file mode 100644 index 0000000..c1cb183 Binary files /dev/null and b/packages/core/modi_plus/assets/firmware/modules/tof.bin differ diff --git a/packages/core/modi_plus/assets/version.txt b/packages/core/modi_plus/assets/version.txt new file mode 100644 index 0000000..274ed2c --- /dev/null +++ b/packages/core/modi_plus/assets/version.txt @@ -0,0 +1,21 @@ +{ + "battery": "v1.0.5", + "bootloader_e103": "v1.0.1", + "bootloader_e230": "v1.0.0", + "button": "v1.0.0", + "dial": "v1.0.3", + "display": "v1.3.0", + "env": "v1.0.2", + "esp32_app": "v4.3.0", + "esp32_ota": "v1.0.0", + "imu": "v1.1.3", + "joystick": "v1.1.1", + "led": "v1.0.0", + "motor": "v1.2.1", + "network": "v1.1.2", + "os_e103": "v1.3.0", + "os_e230": "v1.3.0", + "release": "v20220826", + "speaker": "v1.2.1", + "tof": "v1.1.2" +} \ No newline at end of file diff --git a/packages/core/modi_plus/modi_plus.py b/packages/core/modi_plus/modi_plus.py new file mode 100644 index 0000000..291e6a3 --- /dev/null +++ b/packages/core/modi_plus/modi_plus.py @@ -0,0 +1,324 @@ +"""Main MODI+ module.""" + +import time +import atexit +from typing import Optional + +from importlib import import_module as im + +from modi_plus.module.setup_module.network import Network +from modi_plus.module.setup_module.battery import Battery +from modi_plus.module.input_module.env import Env +from modi_plus.module.input_module.imu import Imu +from modi_plus.module.input_module.button import Button +from modi_plus.module.input_module.dial import Dial +from modi_plus.module.input_module.joystick import Joystick +from modi_plus.module.input_module.tof import Tof +from modi_plus.module.output_module.display import Display +from modi_plus.module.output_module.motor import Motor +from modi_plus.module.output_module.led import Led +from modi_plus.module.output_module.speaker import Speaker + +from modi_plus.module.module import ModuleList +from modi_plus._exe_thread import ExeThread +from modi_plus.util.connection_util import get_platform, get_ble_task_path +from modi_plus.task import HAS_SERIAL, HAS_BLE, ConnectionTask + + +class MODIPlus: + network_uuids = {} + + def __call__(cls, *args, **kwargs): + network_uuid = kwargs.get("network_uuid") + connection_type = kwargs.get("connection_type") + if connection_type != "ble": + return super(MODIPlus, cls).__call__(*args, **kwargs) + if not network_uuid: + raise ValueError("Should input a valid network uuid!") + if network_uuid not in cls.network_uuids: + cls.network_uuids[network_uuid] = super(MODIPlus, cls).__call__(*args, **kwargs) + return cls.network_uuids[network_uuid] + + def __init__( + self, + connection_type: str = "serialport", + verbose: bool = False, + port: Optional[str] = None, + network_uuid: str = "", + task: Optional[ConnectionTask] = None, + ): + self._modules = list() + # 외부에서 task 주입 가능 (웹 버전용) + if task is not None: + self._connection = task + else: + self._connection = self.__init_task(connection_type, verbose, port, network_uuid) + self._exe_thread = ExeThread(self._modules, self._connection) + + print("Start initializing connected MODI+ modules") + self._exe_thread.start() + + # check usb connected module + init_time = time.time() + while not self.__is_usb_connected(): + time.sleep(0.1) + if time.time() - init_time > 3: + print("MODI init timeout over. Check your module connection.") + break + + print("MODI+ modules are initialized!") + + atexit.register(self.close) + + def __init_task(self, connection_type, verbose, port, network_uuid): + if connection_type == "serialport": + if not HAS_SERIAL: + raise ImportError( + "Serial 통신을 사용하려면 pyserial이 필요합니다.\n" + "설치: pip install pymodi-plus[serial] 또는 pip install pymodi-plus[all]" + ) + return im("modi_plus.task.serialport_task").SerialportTask(verbose, port) + elif connection_type == "ble": + if not HAS_BLE: + raise ImportError( + "BLE 통신을 사용하려면 bleak이 필요합니다.\n" + "설치: pip install pymodi-plus[ble] 또는 pip install pymodi-plus[all]" + ) + if not network_uuid: + raise ValueError("Network UUID not specified!") + self.network_uuids[network_uuid] = self + + os = get_platform() + if os == "chrome" or os == "linux": + raise ValueError(f"{os} doesn't support ble connection") + + return im(get_ble_task_path()).BleTask(verbose, network_uuid) + else: + raise ValueError(f"Invalid connection type: {connection_type}") + + def open(self): + atexit.register(self.close) + self._exe_thread = ExeThread(self._modules, self._connection) + self._connection.open_connection() + self._exe_thread.start() + + def close(self): + atexit.unregister(self.close) + print("Closing MODI+ connection...") + self._exe_thread.close() + self._connection.close_connection() + + def send(self, message): + """Low level method to send json pkt directly to modules + + :param message: Json packet to send + :return: None + """ + self._connection.send_nowait(message) + + def recv(self): + """Low level method to receive json pkt directly from modules + + :return: Json msg received + :rtype: str if msg exists, else None + """ + return self._connection.recv() + + def __get_module_by_id(self, module_id): + for module in self._modules: + if module.id == module_id: + return module + return None + + def __is_usb_connected(self): + for module in self._modules: + if module.is_usb_connected: + return True + return False + + def __get_connected_module_by_id(self, id): + target = self.__get_module_by_id(id) + if target is None: + start_time = time.time() + while time.time() - start_time < 3: + target = self.__get_module_by_id(id) + if target is not None: + return target + time.sleep(0.1) + raise Exception("Module with given id does not exits!") + else: + return target + + def network(self, id: int) -> Network: + """Module Class of connected Network module. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "network": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not network!") + return module + + def battery(self, id: int) -> Battery: + """Module Class of connected Battery module. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "battery": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not battery!") + return module + + def env(self, id: int) -> Env: + """Module Class of connected Environment modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "env": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not env!") + return module + + def imu(self, id: int) -> Imu: + """Module Class of connected IMU modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "imu": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not imu!") + return module + + def button(self, id: int) -> Button: + """Module Class of connected Button modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "button": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not button!") + return module + + def dial(self, id: int) -> Dial: + """Module Class of connected Dial modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "dial": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not dial!") + return module + + def joystick(self, id: int) -> Joystick: + """Module Class of connected Joystick modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "joystick": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not joystick!") + return module + + def tof(self, id: int) -> Tof: + """Module Class of connected ToF modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "tof": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not tof!") + return module + + def display(self, id: int) -> Display: + """Module Class of connected Display modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "display": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not display!") + return module + + def motor(self, id: int) -> Motor: + """Module Class of connected Motor modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "motor": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not motor!") + return module + + def led(self, id: int) -> Led: + """Module Class of connected Led modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "led": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not led!") + return module + + def speaker(self, id: int) -> Speaker: + """Module Class of connected Speaker modules. + """ + module = self.__get_connected_module_by_id(id) + if module.module_type != "speaker": + raise Exception(f"This module(0x{id:X}) is {module.module_type} not speaker!") + return module + + @property + def modules(self) -> ModuleList: + """Module List of connected modules except network module. + """ + return ModuleList(self._modules) + + @property + def networks(self) -> ModuleList: + """Module List of connected Network modules. + """ + return ModuleList(self._modules, "network") + + @property + def batterys(self) -> ModuleList: + """Module List of connected Battery modules. + """ + return ModuleList(self._modules, "battery") + + @property + def envs(self) -> ModuleList: + """Module List of connected Environment modules. + """ + return ModuleList(self._modules, "env") + + @property + def imus(self) -> ModuleList: + """Module List of connected IMU modules. + """ + return ModuleList(self._modules, "imu") + + @property + def buttons(self) -> ModuleList: + """Module List of connected Button modules. + """ + return ModuleList(self._modules, "button") + + @property + def dials(self) -> ModuleList: + """Module List of connected Dial modules. + """ + return ModuleList(self._modules, "dial") + + @property + def joysticks(self) -> ModuleList: + """Module List of connected Joystick modules. + """ + return ModuleList(self._modules, "joystick") + + @property + def tofs(self) -> ModuleList: + """Module List of connected ToF modules. + """ + return ModuleList(self._modules, "tof") + + @property + def displays(self) -> ModuleList: + """Module List of connected Display modules. + """ + return ModuleList(self._modules, "display") + + @property + def motors(self) -> ModuleList: + """Module List of connected Motor modules. + """ + return ModuleList(self._modules, "motor") + + @property + def leds(self) -> ModuleList: + """Module List of connected Led modules. + """ + return ModuleList(self._modules, "led") + + @property + def speakers(self) -> ModuleList: + """Module List of connected Speaker modules. + """ + return ModuleList(self._modules, "speaker") diff --git a/packages/core/modi_plus/module/__init__.py b/packages/core/modi_plus/module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/module/input_module/__init__.py b/packages/core/modi_plus/module/input_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/module/input_module/button.py b/packages/core/modi_plus/module/input_module/button.py new file mode 100644 index 0000000..f010ac6 --- /dev/null +++ b/packages/core/modi_plus/module/input_module/button.py @@ -0,0 +1,69 @@ +"""Button module.""" + +import struct +from modi_plus.module.module import InputModule + + +class Button(InputModule): + + STATE_TRUE = 100 + STATE_FALSE = 0 + + PROPERTY_BUTTON_STATE = 2 + + PROPERTY_OFFSET_CLICKED = 0 + PROPERTY_OFFSET_DOUBLE_CLICKED = 2 + PROPERTY_OFFSET_PRESSED = 4 + PROPERTY_OFFSET_TOGGLED = 6 + + @property + def clicked(self) -> bool: + """Returns true when button is clicked + + :return: `True` if clicked or `False`. + :rtype: bool + """ + + offset = Button.PROPERTY_OFFSET_CLICKED + raw = self._get_property(Button.PROPERTY_BUTTON_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Button.STATE_TRUE + + @property + def double_clicked(self) -> bool: + """Returns true when button is double clicked + + :return: `True` if double clicked or `False`. + :rtype: bool + """ + + offset = Button.PROPERTY_OFFSET_DOUBLE_CLICKED + raw = self._get_property(Button.PROPERTY_BUTTON_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Button.STATE_TRUE + + @property + def pressed(self) -> bool: + """Returns true while button is pressed + + :return: `True` if pressed or `False`. + :rtype: bool + """ + + offset = Button.PROPERTY_OFFSET_PRESSED + raw = self._get_property(Button.PROPERTY_BUTTON_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Button.STATE_TRUE + + @property + def toggled(self) -> bool: + """Returns true when button is toggled + + :return: `True` if toggled or `False`. + :rtype: bool + """ + + offset = Button.PROPERTY_OFFSET_TOGGLED + raw = self._get_property(Button.PROPERTY_BUTTON_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Button.STATE_TRUE diff --git a/packages/core/modi_plus/module/input_module/dial.py b/packages/core/modi_plus/module/input_module/dial.py new file mode 100644 index 0000000..02eaaa5 --- /dev/null +++ b/packages/core/modi_plus/module/input_module/dial.py @@ -0,0 +1,38 @@ +"""Dial module.""" + +import struct +from modi_plus.module.module import InputModule + + +class Dial(InputModule): + + PROPERTY_DIAL_STATE = 2 + + PROPERTY_OFFSET_TURN = 0 + PROPERTY_OFFSET_SPEED = 2 + + @property + def turn(self) -> int: + """Returns the angle of the dial between 0 and 100 + + :return: The dial's angle. + :rtype: int + """ + + offset = Dial.PROPERTY_OFFSET_TURN + raw = self._get_property(Dial.PROPERTY_DIAL_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def speed(self) -> int: + """Returns the turn speed of the dial between -100 and 100 + + :return: The dial's turn speed. + :rtype: int + """ + + offset = Dial.PROPERTY_OFFSET_SPEED + raw = self._get_property(Dial.PROPERTY_DIAL_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data diff --git a/packages/core/modi_plus/module/input_module/env.py b/packages/core/modi_plus/module/input_module/env.py new file mode 100644 index 0000000..ffc4fa7 --- /dev/null +++ b/packages/core/modi_plus/module/input_module/env.py @@ -0,0 +1,409 @@ +"""Env module.""" + +import struct +from modi_plus.module.module import InputModule + + +class Env(InputModule): + + # -- Property Numbers -- + PROPERTY_ENV_STATE = 2 + PROPERTY_RGB_STATE = 3 + PROPERTY_RAW_RGB_STATE = 4 + + PROPERTY_OFFSET_ILLUMINANCE = 0 + PROPERTY_OFFSET_TEMPERATURE = 2 + PROPERTY_OFFSET_HUMIDITY = 4 + PROPERTY_OFFSET_VOLUME = 6 + + # RGB property offsets (only available in version 2.x and above) + PROPERTY_OFFSET_RED = 0 + PROPERTY_OFFSET_GREEN = 2 + PROPERTY_OFFSET_BLUE = 4 + PROPERTY_OFFSET_WHITE = 6 + PROPERTY_OFFSET_BLACK = 8 + PROPERTY_OFFSET_COLOR_CLASS = 10 + PROPERTY_OFFSET_BRIGHTNESS = 11 + + PROPERTY_RAW_OFFSET_RED = 0 + PROPERTY_RAW_OFFSET_GREEN = 2 + PROPERTY_RAW_OFFSET_BLUE = 4 + PROPERTY_RAW_OFFSET_WHITE = 6 + + PROPERTY_ENV_SET_RECORD_VOICE = 16 + PROPERTY_ENV_SET_RGB_MODE = 17 + + RGB_MODE_AMBIENT = 0 + RGB_MODE_ON = 1 + RGB_MODE_DUALSHOT = 2 + + @property + def illuminance(self) -> int: + """Returns the value of illuminance between 0 and 100 + + :return: The environment's illuminance. + :rtype: int + """ + + offset = Env.PROPERTY_OFFSET_ILLUMINANCE + raw = self._get_property(Env.PROPERTY_ENV_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def temperature(self) -> int: + """Returns the value of temperature between -10 and 60 + + :return: The environment's temperature. + :rtype: int + """ + + offset = Env.PROPERTY_OFFSET_TEMPERATURE + raw = self._get_property(Env.PROPERTY_ENV_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def humidity(self) -> int: + """Returns the value of humidity between 0 and 100 + + :return: The environment's humidity. + :rtype: int + """ + + offset = Env.PROPERTY_OFFSET_HUMIDITY + raw = self._get_property(Env.PROPERTY_ENV_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def volume(self) -> int: + """Returns the value of volume between 0 and 100 + + :return: The environment's volume. + :rtype: int + """ + + offset = Env.PROPERTY_OFFSET_VOLUME + raw = self._get_property(Env.PROPERTY_ENV_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + def _is_rgb_supported(self) -> bool: + """Check if RGB properties are supported based on app version + + RGB is supported in app version 2.x and above. + Version 1.x does not support RGB. + + :return: True if RGB is supported, False otherwise + :rtype: bool + """ + if not hasattr(self, '_Module__app_version') or self._Module__app_version is None: + return False + + # Extract major version: version >> 13 + major_version = self._Module__app_version >> 13 + return major_version >= 2 + + @property + def red(self) -> int: + """Returns the red color value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's red color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_RED + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def green(self) -> int: + """Returns the green color value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's green color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_GREEN + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def blue(self) -> int: + """Returns the blue color value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's blue color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_BLUE + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def white(self) -> int: + """Returns the white color value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's white color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_WHITE + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def black(self) -> int: + """Returns the black color value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's black color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_BLACK + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def color_class(self) -> int: + """Returns the detected color class + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The detected color class (0=unknown, 1=red, 2=green, 3=blue, 4=white, 5=black). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_COLOR_CLASS + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("B", raw[offset:offset + 1])[0] + return data + + @property + def brightness(self) -> int: + """Returns the brightness value between 0 and 100 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's brightness value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_OFFSET_BRIGHTNESS + raw = self._get_property(Env.PROPERTY_RGB_STATE) + data = struct.unpack("B", raw[offset:offset + 1])[0] + return data + + @property + def rgb(self) -> tuple: + """Returns the RGB color values as a tuple (red, green, blue) + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: Tuple of (red, green, blue) values, each between 0 and 100. + :rtype: tuple + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + return (self.red, self.green, self.blue) + + @property + def raw_red(self) -> int: + """Returns the raw red value between 0 and 65536 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's red color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_RAW_OFFSET_RED + raw = self._get_property(Env.PROPERTY_RAW_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def raw_green(self) -> int: + """Returns the raw green value between 0 and 65536 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's green color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_RAW_OFFSET_GREEN + raw = self._get_property(Env.PROPERTY_RAW_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def raw_blue(self) -> int: + """Returns the raw blue color between 0 and 65535 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's blue color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_RAW_OFFSET_BLUE + raw = self._get_property(Env.PROPERTY_RAW_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def raw_white(self) -> int: + """Returns the raw white color between 0 and 65535 + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: The environment's white color value (0-100%). + :rtype: int + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + offset = Env.PROPERTY_RAW_OFFSET_WHITE + raw = self._get_property(Env.PROPERTY_RAW_RGB_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def raw_rgb(self) -> tuple: + """Returns the RGB color values as a tuple (raw_red, raw_green, raw_blue, raw_white) + + Note: This property is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :return: Tuple of (red, green, blue) values, each between 0 and 100. + :rtype: tuple + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + return (self.raw_red, self.raw_green, self.raw_blue, self.raw_white) + + def set_rgb_mode(self, mode: int, duration: int = 3) -> None: + """Sets the RGB mode of the Env module + + Note: This method is only available in Env module version 2.x and above. + Version 1.x does not support RGB properties. + + :param mode: RGB mode to set (0=off, 1=on) + :type mode: int + :return: None + :raises AttributeError: If app version is 1.x (RGB not supported) + """ + if not self._is_rgb_supported(): + raise AttributeError( + "RGB properties are not supported in Env module version 1.x. " + "Please upgrade to version 2.x or above." + ) + + self._set_property( + destination_id=self.id, + property_num=Env.PROPERTY_ENV_SET_RGB_MODE, + property_values=(("u8", mode), + ("u16", duration), )) diff --git a/packages/core/modi_plus/module/input_module/imu.py b/packages/core/modi_plus/module/input_module/imu.py new file mode 100644 index 0000000..c469a76 --- /dev/null +++ b/packages/core/modi_plus/module/input_module/imu.py @@ -0,0 +1,184 @@ +"""Imu module.""" + +import struct +from typing import Tuple +from modi_plus.module.module import InputModule + + +class Imu(InputModule): + + PROPERTY_ANGLE_STATE = 2 + PROPERTY_ACC_STATE = 3 + PROPERTY_GYRO_STATE = 4 + PROPERTY_VIBRATION_STATE = 5 + + PROPERTY_OFFSET_ROLL = 0 + PROPERTY_OFFSET_PITCH = 4 + PROPERTY_OFFSET_YAW = 8 + PROPERTY_OFFSET_ACC_X = 0 + PROPERTY_OFFSET_ACC_Y = 4 + PROPERTY_OFFSET_ACC_Z = 8 + PROPERTY_OFFSET_GYRO_X = 0 + PROPERTY_OFFSET_GYRO_Y = 4 + PROPERTY_OFFSET_GYRO_Z = 8 + PROPERTY_OFFSET_VIBRATION = 0 + + @property + def angle_x(self) -> float: + """Returns the angle_x angle of the imu + + :return: The imu's angle_x angle. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_ROLL + raw = self._get_property(Imu.PROPERTY_ANGLE_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angle_y(self) -> float: + """Returns the angle_y angle of the imu + + :return: The imu's angle_y angle. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_PITCH + raw = self._get_property(Imu.PROPERTY_ANGLE_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angle_z(self) -> float: + """Returns the angle_zle_z angle of the imu + + :return: The imu's angle_z angle. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_YAW + raw = self._get_property(Imu.PROPERTY_ANGLE_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angle(self) -> Tuple[float, float, float]: + """Returns the angle_x, angle_y and angle_z angle of the imu + + :return: The imu's angles of angle_x, angle_y and angle_z. + :rtype: tuple + """ + + return self.angle_x, self.angle_y, self.angle_z + + @property + def angular_vel_x(self) -> float: + """Returns the angle_x angle of the imu + + :return: The imu's angular velocity the about x-axis. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_GYRO_X + raw = self._get_property(Imu.PROPERTY_GYRO_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angular_vel_y(self) -> float: + """Returns the angular velocity about y-axis + + :return: The imu's angular velocity the about y-axis. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_GYRO_Y + raw = self._get_property(Imu.PROPERTY_GYRO_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angular_vel_z(self) -> float: + """Returns the angular velocity about z-axis + + :return: The imu's angular velocity the about z-axis. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_GYRO_Z + raw = self._get_property(Imu.PROPERTY_GYRO_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def angular_velocity(self) -> Tuple[float, float, float]: + """Returns the angular velocity about x, y and z axis + + :return: The imu's angular velocity the about x, y and z axis. + :rtype: tuple + """ + + return self.angular_vel_x, self.angular_vel_y, self.angular_vel_z + + @property + def acceleration_x(self) -> float: + """Returns the x component of the acceleration + + :return: The imu's x-axis acceleration. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_ACC_X + raw = self._get_property(Imu.PROPERTY_ACC_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def acceleration_y(self) -> float: + """Returns the y component of the acceleration + + :return: The imu's y-axis acceleration. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_ACC_Y + raw = self._get_property(Imu.PROPERTY_ACC_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def acceleration_z(self) -> float: + """Returns the z component of the acceleration + + :return: The imu's z-axis acceleration. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_ACC_Z + raw = self._get_property(Imu.PROPERTY_ACC_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data + + @property + def acceleration(self) -> Tuple[float, float, float]: + """Returns the acceleration about x, y and z axis + + :return: The imu's acceleration the about x, y and z axis. + :rtype: tuple + """ + + return self.acceleration_x, self.acceleration_y, self.acceleration_z + + @property + def vibration(self) -> float: + """Returns the vibration value + + :return: The imu's vibration. + :rtype: float + """ + + offset = Imu.PROPERTY_OFFSET_VIBRATION + raw = self._get_property(Imu.PROPERTY_VIBRATION_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data diff --git a/packages/core/modi_plus/module/input_module/joystick.py b/packages/core/modi_plus/module/input_module/joystick.py new file mode 100644 index 0000000..b6a404d --- /dev/null +++ b/packages/core/modi_plus/module/input_module/joystick.py @@ -0,0 +1,66 @@ +"""Joystick module.""" + +import struct +from modi_plus.module.module import InputModule + + +class Joystick(InputModule): + + STATE_UP = 100 + STATE_DOWN = -100 + STATE_LEFT = -50 + STATE_RIGHT = 50 + STATE_ORIGIN = 0 + + PROPERTY_POSITION_STATE = 2 + PROPERTY_DIRECTION_STATE = 3 + + PROPERTY_OFFSET_X = 0 + PROPERTY_OFFSET_Y = 2 + PROPERTY_OFFSET_DIRECTION = 0 + + @property + def x(self) -> int: + """Returns the x position of the joystick between -100 and 100 + + :return: The joystick's x position. + :rtype: int + """ + + offset = Joystick.PROPERTY_OFFSET_X + raw = self._get_property(Joystick.PROPERTY_POSITION_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def y(self) -> int: + """Returns the y position of the joystick between -100 and 100 + + :return: The joystick's y position. + :rtype: int + """ + + offset = Joystick.PROPERTY_OFFSET_Y + raw = self._get_property(Joystick.PROPERTY_POSITION_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + def direction(self) -> str: + """Returns the direction of the joystick + + :return: 'up', 'down', 'left', 'right', 'origin' + :rtype: str + """ + + offset = Joystick.PROPERTY_OFFSET_DIRECTION + raw = self._get_property(Joystick.PROPERTY_DIRECTION_STATE) + data = struct.unpack("h", raw[offset:offset + 2])[0] + + return { + Joystick.STATE_UP: "up", + Joystick.STATE_DOWN: "down", + Joystick.STATE_LEFT: "left", + Joystick.STATE_RIGHT: "right", + Joystick.STATE_ORIGIN: "origin" + }.get(data) diff --git a/packages/core/modi_plus/module/input_module/tof.py b/packages/core/modi_plus/module/input_module/tof.py new file mode 100644 index 0000000..aabe970 --- /dev/null +++ b/packages/core/modi_plus/module/input_module/tof.py @@ -0,0 +1,24 @@ +"""Tof module.""" + +import struct +from modi_plus.module.module import InputModule + + +class Tof(InputModule): + + PROPERTY_DISTANCE_STATE = 2 + + PROPERTY_OFFSET_DISTANCE = 0 + + @property + def distance(self) -> float: + """Returns the distance of the object between 0cm and 100cm + + :return: The tof's distance to object. + :rtype: float + """ + + offset = Tof.PROPERTY_OFFSET_DISTANCE + raw = self._get_property(Tof.PROPERTY_DISTANCE_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data diff --git a/packages/core/modi_plus/module/module.py b/packages/core/modi_plus/module/module.py new file mode 100644 index 0000000..36a41e5 --- /dev/null +++ b/packages/core/modi_plus/module/module.py @@ -0,0 +1,388 @@ +"""Module module.""" + +import time +import json +from os import path +from typing import Tuple, Union +from importlib.util import find_spec + +from modi_plus.util.message_util import parse_get_property_message, parse_set_property_message + +BROADCAST_ID = 0xFFF + + +def get_module_type_from_uuid(uuid): + module_type_num = uuid >> 32 + module_type = { + # Setup modules + 0: "network", + 0x10: "battery", + + # Input modules + 0x2000: "env", + 0x2010: "imu", + 0x2030: "button", + 0x2040: "dial", + 0x2070: "joystick", + 0x2080: "tof", + + # Output modules + 0x4000: "display", + 0x4010: "motor", + 0x4011: "motor", + 0x4020: "led", + 0x4030: "speaker", + }.get(module_type_num) + return "network" if module_type is None else module_type + + +def get_module_from_name(module_type: str): + """ Find module type for module initialize + + :param module_type: Type of the module in string + :type module_type: str + :return: Module corresponding to the type + :rtype: Module + """ + + module_type = module_type[0].lower() + module_type[1:] + module_name = module_type[0].upper() + module_type[1:] + module_module = find_spec(f"modi_plus.module.input_module.{module_type}") + if not module_module: + module_module = find_spec(f"modi_plus.module.output_module.{module_type}") + if not module_module: + module_module = find_spec(f"modi_plus.module.setup_module.{module_type}") + module_module = module_module.loader.load_module(module_module.name) + return getattr(module_module, module_name) + + +def ask_modi_plus_device(devices): + if not devices: + raise ValueError( + "No MODI+ network module(s) available!\n" + "The network module that you\"re trying to connect, may in use." + ) + for idx, dev in enumerate(devices): + print(f"<{idx}>: {dev}") + i = input("Choose your device index (ex: 0) : ") + return devices[int(i)].lstrip("MODI+_") + + +class Module: + """ + :param int id_: The id of the module. + :param int uuid: The uuid of the module. + """ + + class Property: + def __init__(self): + self.value = None + self.last_update_time = time.time() + + class GetValueInitTimeout(Exception): + def __init__(self): + super().__init__("property initialization failed\nplease check the module connection") + + RUN = 0 + WARNING = 1 + FORCED_PAUSE = 2 + ERROR_STOP = 3 + UPDATE_FIRMWARE = 4 + UPDATE_FIRMWARE_READY = 5 + REBOOT = 6 + PNP_ON = 1 + PNP_OFF = 2 + + def __init__(self, id_, uuid, connection_task): + self._id = id_ + self._uuid = uuid + self._connection = connection_task + self.module_type = str() + + # property + self.prop_samp_freq = 91 # sampling_rate[ms] = (100 - property_sampling_frequency) * 11 + self.prop_request_period = 2 # [s] + self.__get_properties = dict() + self.__set_properties = dict() + self.__last_set_property_num = None + + self.is_connected = True + self.is_usb_connected = False + self.has_printed = False + self.last_updated_time = time.time() + self.first_connected_time = None + self.__app_version = None + self.__os_version = None + self._enable_get_property_timeout = True + + def __gt__(self, other): + if self.first_connected_time is not None: + if other.first_connected_time is not None: + return self.first_connected_time > other.first_connected_time + else: + return False + else: + if other.first_connected_time is not None: + return True + else: + return False + + def __lt__(self, other): + if self.first_connected_time is not None: + if other.first_connected_time is not None: + return self.first_connected_time < other.first_connected_time + else: + return True + else: + if other.first_connected_time is not None: + return False + else: + return True + + def __str__(self): + return f"{self.__class__.__name__}(0x{self._id:X})" + + @property + def app_version(self): + version_string = "" + version_string += str(self.__app_version >> 13) + "." + version_string += str(self.__app_version % (2 ** 13) >> 8) + "." + version_string += str(self.__app_version % (2 ** 8)) + return version_string + + @app_version.setter + def app_version(self, version_info): + self.__app_version = version_info + + @property + def os_version(self): + version_string = "" + version_string += str(self.__os_version >> 13) + "." + version_string += str(self.__os_version % (2 ** 13) >> 8) + "." + version_string += str(self.__os_version % (2 ** 8)) + return version_string + + @os_version.setter + def os_version(self, version_info): + self.__os_version = version_info + + @property + def id(self) -> int: + return self._id + + @property + def uuid(self) -> int: + return self._uuid + + @property + def is_up_to_date(self): + root_path = path.join(path.dirname(__file__), "..", "assets") + version_path = path.join(root_path, "version.txt") + + with open(version_path, "r") as version_file: + try: + version_info = json.loads(version_file.read()) + except Exception: + pass + + app_version_info = version_info[self.module_type].lstrip("v").rstrip("\n") + if self.module_type in ["env", "display", "speaker"]: + os_version_info = version_info["os_e103"].lstrip("v").rstrip("\n") + else: + os_version_info = version_info["os_e230"].lstrip("v").rstrip("\n") + + app_version_digits = [int(digit) for digit in app_version_info.split(".")] + os_version_digits = [int(digit) for digit in os_version_info.split(".")] + + latest_app_version = ( + app_version_digits[0] << 13 + | app_version_digits[1] << 8 + | app_version_digits[2] + ) + latest_os_version = ( + os_version_digits[0] << 13 + | os_version_digits[1] << 8 + | os_version_digits[2] + ) + + return latest_app_version <= self.__app_version or latest_os_version <= self.__os_version + + def _get_property(self, property_type: int) -> bytearray: + """ Get module property value and request + + :param property_type: Type of the requested property + :type property_type: int + """ + + # Register property if not exists + if property_type not in self.__get_properties: + self.__get_properties[property_type] = self.Property() + self.__request_property(self._id, property_type) + + # Request property value if not updated for 2 sec + last_update = self.__get_properties[property_type].last_update_time + if time.time() - last_update > self.prop_request_period: + self.__request_property(self._id, property_type) + + if self.__get_properties[property_type].value is None: + if self._enable_get_property_timeout: + first_request_time = time.time() + + # 3s timeout + while self.__get_properties[property_type].value is None: + if time.time() - first_request_time > 3: + raise Module.GetValueInitTimeout + time.sleep(0.1) + else: + return bytearray(14) # Increased from 12 to 14 to support RGB properties (offset 12 + 2 bytes) + + return self.__get_properties[property_type].value + + def _set_property(self, destination_id: int, + property_num: int, + property_values: Union[Tuple, str], + force: bool = False) -> None: + """Send the message of set_property command to the module + + :param destination_id: Id of the destination module + :type destination_id: int + :param property_num: Property Type + :type property_num: int + :param property_values: Property Values + :type property_values: Tuple + :param force: Force data to be sent + :type force: bool + :return: None + """ + + do_send = False + now_time = time.time() + + if not self.__check_last_set_property(property_num): + force = True + + if property_num in self.__set_properties: + if property_values == self.__set_properties[property_num].value: + duration = now_time - self.__set_properties[property_num].last_update_time + if force or duration > self.prop_request_period: + # 마지막으로 보낸 데이터와 같은 경우, 2초마다 전송 or force가 true인 경우 + self.__set_properties[property_num].value = property_values + self.__set_properties[property_num].last_update_time = now_time + do_send = True + else: + # 마지막으로 보낸 데이터와 다른 경우, 바로 전송 + self.__set_properties[property_num].value = property_values + self.__set_properties[property_num].last_update_time = now_time + do_send = True + else: + # 데이터를 한번도 안 보낸 경우, 바로 전송 + self.__set_properties[property_num] = self.Property() + self.__set_properties[property_num].value = property_values + self.__set_properties[property_num].last_update_time = now_time + do_send = True + + if do_send: + message = parse_set_property_message( + destination_id, + property_num, + property_values, + ) + self._connection.send_nowait(message) + + self.__last_set_property_num = property_num + + def update_property(self, property_type: int, property_value: bytearray) -> None: + """ Update property value and time + + :param property_type: Type of the updated property + :type property_type: int + :param property_value: Value to update the property + :type property_value: bytearray + """ + + if property_type not in self.__get_properties: + self.__get_properties[property_type] = self.Property() + self.__get_properties[property_type].value = property_value + self.__get_properties[property_type].last_update_time = time.time() + + def __request_property(self, destination_id: int, property_type: int) -> None: + """ Generate message for request property + + :param destination_id: Id of the destination module + :type destination_id: int + :param property_type: Type of the requested property + :type property_type: int + :return: None + """ + + self.__get_properties[property_type].last_update_time = time.time() + req_prop_msg = parse_get_property_message(destination_id, property_type, self.prop_samp_freq) + self._connection.send(req_prop_msg) + + def __check_last_set_property(self, property_num: int) -> bool: + if self.__last_set_property_num is None: + return False + else: + return self.__last_set_property_num == property_num + + +class SetupModule(Module): + pass + + +class InputModule(Module): + pass + + +class OutputModule(Module): + pass + + +class ModuleList(list): + + def __init__(self, src, module_type=None): + self.__src = src + self.__module_type = module_type + super().__init__(self.sublist()) + + def __len__(self): + return len(self.sublist()) + + def __eq__(self, other): + return super().__eq__(other) + + def __getitem__(self, key): + if int(key) >= len(self): + start_time = time.time() + # 3s timeout + while ((time.time() - start_time) < 3) and (int(key) >= len(self)): + time.sleep(0.1) + if int(key) >= len(self): + raise Exception("Not enough modules exits!!") + return self.sublist()[key] + + def get(self, module_id): + for module in self.sublist(): + if module.id == module_id: + return module + raise Exception("Module with given id does not exits!!") + + def sublist(self): + """ When accessing the module, the modules are sorted in an ascending order of + 1. the connected time from network module + + :return: Module + """ + + if self.__module_type: + modules = list(filter(lambda module: module.module_type == self.__module_type, self.__src)) + else: + modules = self.__src + modules.sort() + return modules + + def find(self, module_id): + for idx, module in enumerate(self.sublist()): + if module_id == module.id: + return idx + return -1 diff --git a/packages/core/modi_plus/module/output_module/__init__.py b/packages/core/modi_plus/module/output_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/module/output_module/display.py b/packages/core/modi_plus/module/output_module/display.py new file mode 100644 index 0000000..1823e8c --- /dev/null +++ b/packages/core/modi_plus/module/output_module/display.py @@ -0,0 +1,347 @@ +"""Display module.""" + +import time +from typing import List +from modi_plus.module.module import OutputModule + + +class Display(OutputModule): + + WIDTH = 96 + HEIGHT = 96 + + TEXT_SPLIT_LEN = 24 + DOT_SPLIT_LEN = 23 + DOT_LEN = int(WIDTH * HEIGHT / 8) + + PROPERTY_DISPLAY_WRITE_TEXT = 17 + PROPERTY_DISPLAY_DRAW_DOT = 18 + PROPERTY_DISPLAY_DRAW_PICTURE = 19 + PROPERTY_DISPLAY_RESET = 21 + PROPERTY_DISPLAY_WRITE_VARIABLE = 22 + PROPERTY_DISPLAY_SET_OFFSET = 25 + PROPERTY_DISPLAY_MOVE_SCREEN = 26 + + PRESET_PICTURE = { + "smiling brightly": "res/smileb.bmp", + "falling in love": "res/love.bmp", + "smiling": "res/smiling.bmp", + "angry": "res/angry.bmp", + "tired": "res/tired.bmp", + "surprised": "res/surprise.bmp", + "crying": "res/cry.bmp", + "dizzy": "res/dizzy.bmp", + "turn a blind eye": "res/bilnd.bmp", + "sleeping": "res/sleeping.bmp", + "embarrassed": "res/emv.bmp", + "proud": "res/proud.bmp", + "Devil": "res/devil.bmp", + "Angel": "res/angel.bmp", + "Dragon": "res/dragon.bmp", + "Santa Claus": "res/santa.bmp", + "Ludolf": "res/ludolf.bmp", + "Ghost": "res/ghost.bmp", + "Witch": "res/witch.bmp", + "Halloween Pumpkin": "res/pumpkin.bmp", + "magic wand": "res/wand.bmp", + "magic hat": "res/hat.bmp", + "crystal ball": "res/ball.bmp", + "potion": "res/potion.bmp", + "Dog": "res/dog.bmp", + "Cat": "res/cat.bmp", + "Rabbit": "res/rabbit.bmp", + "Chick": "res/chick.bmp", + "Lion": "res/lion.bmp", + "Turtle": "res/turtle.bmp", + "Sparrow": "res/sparrow.bmp", + "Penguin": "res/penguin.bmp", + "Butterfly": "res/butfly.bmp", + "Fish": "res/fish.bmp", + "Dolphin": "res/dolphin.bmp", + "Hedgehog": "res/hedgeh.bmp", + "Flower": "res/flower.bmp", + "Tree": "res/tree.bmp", + "Sun": "res/sun.bmp", + "Star": "res/star.bmp", + "Moon": "res/moon.bmp", + "Earth": "res/earth.bmp", + "Space": "res/space.bmp", + "Cloud": "res/cloud.bmp", + "Rain": "res/rain.bmp", + "Snow": "res/snow.bmp", + "Wind": "res/wind.bmp", + "Thunder": "res/thunder.bmp", + "Water drop": "res/water.bmp", + "Fire": "res/fire.bmp", + "Car": "res/car.bmp", + "Ship": "res/ship.bmp", + "Airplane": "res/airplane.bmp", + "Train": "res/train.bmp", + "Bus": "res/bus.bmp", + "Police car": "res/policec.bmp", + "Ambulance": "res/ambul.bmp", + "Rocket": "res/rocket.bmp", + "Hot-air balloon": "res/hotair.bmp", + "Helicopter": "res/helicop.bmp", + "Sports car": "res/sportsc.bmp", + "Bicycle": "res/bicycle.bmp", + "School": "res/school.bmp", + "Park": "res/park.bmp", + "Hospital": "res/hospital.bmp", + "Building": "res/build.bmp", + "Apartment": "res/apart.bmp", + "Amusement park": "res/amuse.bmp", + "a house of brick": "res/brick.bmp", + "log cabin": "res/cabin.bmp", + "a house of straw": "res/straw.bmp", + "vacant lot": "res/vacant.bmp", + "Field": "res/field.bmp", + "Mountain": "res/mountain.bmp", + "Apple": "res/apple.bmp", + "Banana": "res/banana.bmp", + "Strawberry": "res/strawb.bmp", + "Peach": "res/peach.bmp", + "Watermelon": "res/waterm.bmp", + "Chicken": "res/chicken.bmp", + "Pizza": "res/pizza.bmp", + "Hamburger": "res/hamburg.bmp", + "Cake": "res/cake.bmp", + "Nuddle": "res/nuddle.bmp", + "Donut": "res/donut.bmp", + "Candy": "res/candy.bmp", + "Communication ": "res/comm.bmp", + "Battery": "res/battery.bmp", + "Download": "res/download.bmp", + "Check": "res/check.bmp", + "X": "res/x.bmp", + "Play": "res/play.bmp", + "Stop": "res/stop2.bmp", + "Pause": "res/pause.bmp", + "Power": "res/power.bmp", + "Bulb": "res/bulb.bmp", + "straight sign": "res/straigh.bmp", + "turn left sign": "res/lefts.bmp", + "turn right sign": "res/rights.bmp", + "stop sign": "res/stop.bmp", + "prize": "res/prize.bmp", + "losing ticket": "res/losing.bmp", + "Retry": "res/retry.bmp", + "Thumbs up": "res/thumbs.bmp", + "Scissors": "res/scissors.bmp", + "Rock": "res/rock.bmp", + "Paper": "res/paper.bmp", + "up arrow": "res/up.bmp", + "down arrow": "res/down.bmp", + "Right arrow": "res/righta.bmp", + "Left arrow": "res/lefta.bmp", + "Heart": "res/heart.bmp", + "Musical note": "res/note.bmp", + "baby": "res/baby.bmp", + "Girl": "res/girl.bmp", + "Boy": "res/boy.bmp", + "Women": "res/women.bmp", + "Men": "res/men.bmp", + "Grandmother": "res/grandm.bmp", + "Grandfather": "res/grandf.bmp", + "Teacher": "res/teacher.bmp", + "Programmer": "res/program.bmp", + "Police": "res/police.bmp", + "Doctor": "res/doctor.bmp", + "Farmer": "res/farmer.bmp", + "game console": "res/game.bmp", + "Microphone": "res/microp.bmp", + "loud speaker": "res/speaker.bmp", + "Watch": "res/watch.bmp", + "Telephone": "res/tele.bmp", + "Camera": "res/camera.bmp", + "TV": "res/tv.bmp", + "Radio": "res/radio.bmp", + "Book": "res/book.bmp", + "Microscope": "res/micros.bmp", + "Telescope": "res/teles.bmp", + "Wastebasket": "res/waste.bmp", + "Mask": "res/mask.bmp", + "Flag": "res/flag.bmp", + "Letter": "res/letter.bmp", + "Soccer ball": "res/soccer.bmp", + "basketball": "res/basket.bmp", + "Piano": "res/piano.bmp", + "Gittar": "res/gittar.bmp", + "Drum": "res/drum.bmp", + "Siren": "res/siren.bmp", + "Gift box": "res/giftbox.bmp", + "Crown": "res/crown.bmp", + "Dice": "res/dice.bmp", + "Medal": "res/medal.bmp", + "Key": "res/key.bmp", + "jewerly": "res/jewerly.bmp", + "Coin": "res/coin.bmp", + } + + @staticmethod + def preset_pictures() -> List[str]: + return list(Display.PRESET_PICTURE.keys()) + + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._text = "" + + @property + def text(self) -> str: + return self._text + + @text.setter + def text(self, text: str) -> None: + self.write_text(text) + + def write_text(self, text: str) -> None: + """Show the input string on the display. + + :param text: Text to display. + :type text: str + :return: None + """ + + n = Display.TEXT_SPLIT_LEN + encoding_data = str.encode(str(text)) + bytes(1) + splited_data = [encoding_data[x - n:x] for x in range(n, len(encoding_data) + n, n)] + for index, data in enumerate(splited_data): + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_WRITE_TEXT, + property_values=(("bytes", data), ), + force=True + ) + + self._text = text + time.sleep(0.02 + 0.003 * len(encoding_data)) + + def write_variable_xy(self, x: int, y: int, variable: float) -> None: + """Show the input variable on the display. + + :param x: X coordinate of the desired position + :type x: int + :param y: Y coordinate of te desired position + :type y: int + :param variable: Variable to display. + :type variable: float + :return: None + """ + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_WRITE_VARIABLE, + property_values=(("u8", x), + ("u8", y), + ("float", variable), ) + ) + self._text += str(variable) + time.sleep(0.01) + + def write_variable_line(self, line: int, variable: float) -> None: + """Show the input variable on the display. + + :param line: display line number of the desired position + :type line: int + :param variable: Variable to display. + :type variable: float + :return: None + """ + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_WRITE_VARIABLE, + property_values=(("u8", 0), + ("u8", line * 20), + ("float", variable), ) + ) + self._text += str(variable) + time.sleep(0.01) + + def draw_picture(self, name: int) -> None: + """Clears the display and show the input picture on the display. + + :param x: X coordinate of the desired position + :type x: int + :param y: Y coordinate of te desired position + :type y: int + :param name: Picture name to display. + :type name: float + :return: None + """ + + file_name = Display.PRESET_PICTURE.get(name) + if file_name is None: + raise ValueError(f"{file_name} is not on the list, check 'Display.preset_pictures()'") + + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_DRAW_PICTURE, + property_values=(("u8", 0), + ("u8", 0), + ("u8", Display.WIDTH), + ("u8", Display.HEIGHT), + ("string", file_name), ) + ) + time.sleep(0.05) + + def draw_dot(self, dot: bytes) -> None: + """Clears the display and show the input dot on the display. + + :param dot: Dot to display + :type dot: bytes + :return: None + """ + + if not isinstance(dot, bytes): + raise ValueError("dot type must bytes") + + if len(dot) != Display.DOT_LEN: + raise ValueError(f"dot length must be {Display.DOT_LEN}") + + n = Display.DOT_SPLIT_LEN + splited_data = [dot[x - n:x] for x in range(n, len(dot) + n, n)] + for index, data in enumerate(splited_data): + send_data = bytes([index]) + data + + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_DRAW_DOT, + property_values=(("bytes", send_data), ) + ) + time.sleep(0.3) + + def set_offset(self, x: int, y: int) -> None: + """Set origin point on the screen + + :param x: X-axis offset on screen + :type x: int + :param y: Y-axis offset on screen + :type y: int + :return: None + """ + + self._set_property( + self.id, + Display.PROPERTY_DISPLAY_SET_OFFSET, + property_values=(("s8", x), ("s8", y), ) + ) + time.sleep(0.01) + + def reset(self, mode=0) -> None: + """Clear the screen. + + :param mode: Erase mode + - mode 0 : Erase inside buffer(it looks like nothing has changed) + - mode 1 : Erase display + :return: None + """ + + if mode > 1: + mode = 0 + + self._set_property( + self._id, + Display.PROPERTY_DISPLAY_RESET, + property_values=(("u8", mode), ) + ) + self._text = "" + time.sleep(0.01) diff --git a/packages/core/modi_plus/module/output_module/led.py b/packages/core/modi_plus/module/output_module/led.py new file mode 100644 index 0000000..1ce71ac --- /dev/null +++ b/packages/core/modi_plus/module/output_module/led.py @@ -0,0 +1,145 @@ +"""Led module.""" + +import struct +from typing import Tuple +from modi_plus.module.module import OutputModule + + +class Led(OutputModule): + + PROPERTY_LED_STATE = 2 + + PROPERTY_LED_SET_RGB = 16 + + PROPERTY_OFFSET_RED = 0 + PROPERTY_OFFSET_GREEN = 2 + PROPERTY_OFFSET_BLUE = 4 + + @property + def rgb(self) -> Tuple[int, int, int]: + return self.red, self.green, self.blue + + @rgb.setter + def rgb(self, color: Tuple[int, int, int]) -> None: + """Sets the color of the LED light with given RGB values, and returns + the current RGB values. + + :param color: RGB value to set + :type color: Tuple[int, int, int] + :return: None + """ + + self.set_rgb(color[0], color[1], color[2]) + + @property + def red(self) -> int: + """Returns the current value of the red component of the LED + + :return: Red component + :rtype: int + """ + + offset = Led.PROPERTY_OFFSET_RED + raw = self._get_property(Led.PROPERTY_LED_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @red.setter + def red(self, red: int) -> None: + """Sets the red component of the LED light by given value + + :param red: Red component to set + :type red: int + :return: None + """ + + self.rgb = red, self.green, self.blue + + @property + def green(self) -> int: + """Returns the current value of the green component of the LED + + :return: Green component + :rtype: int + """ + + offset = Led.PROPERTY_OFFSET_GREEN + raw = self._get_property(Led.PROPERTY_LED_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @green.setter + def green(self, green: int) -> None: + """Sets the green component of the LED light by given value + + :param green: Green component to set + :type green: int + :return: None + """ + + self.rgb = self.red, green, self.blue + + @property + def blue(self) -> int: + """Returns the current value of the blue component of the LED + + :return: Blue component + :rtype: int + """ + + offset = Led.PROPERTY_OFFSET_BLUE + raw = self._get_property(Led.PROPERTY_LED_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @blue.setter + def blue(self, blue: int) -> None: + """Sets the blue component of the LED light by given value + + :param blue: Blue component to set + :type blue: int + :return: None + """ + + self.rgb = self.red, self.green, blue + + def set_rgb(self, red: int, green: int, blue: int) -> None: + """Sets the color of the LED light with given RGB values, and returns + the current RGB values. + + :param red: Red component to set + :type red: int + :param green: Green component to set + :type green: int + :param blue: Blue component to set + :type blue: int + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Led.PROPERTY_LED_SET_RGB, + property_values=(("u16", red), + ("u16", green), + ("u16", blue), ) + ) + + # + # Legacy Support + # + def turn_on(self) -> None: + """Turn on led at maximum brightness. + + :return: RGB value of the LED set to maximum brightness + :rtype: None + """ + + self.rgb = 100, 100, 100 + + def turn_off(self) -> None: + """Turn off led. + + :return: None + """ + + self.rgb = 0, 0, 0 diff --git a/packages/core/modi_plus/module/output_module/motor.py b/packages/core/modi_plus/module/output_module/motor.py new file mode 100644 index 0000000..2795b8c --- /dev/null +++ b/packages/core/modi_plus/module/output_module/motor.py @@ -0,0 +1,168 @@ +"""Motor module.""" + +import time +import struct +from typing import Tuple +from modi_plus.module.module import OutputModule + + +class Motor(OutputModule): + + PROPERTY_MOTOR_STATE = 2 + + PROPERTY_MOTOR_SPEED = 17 + PROPERTY_MOTOR_ANGLE = 18 + PROPERTY_MOTOR_ANGLE_APPEND = 19 + PROPERTY_MOTOR_STOP = 20 + + PROPERTY_OFFSET_CURRENT_ANGLE = 0 + PROPERTY_OFFSET_CURRENT_SPEED = 2 + PROPERTY_OFFSET_TARGET_ANGLE = 4 + PROPERTY_OFFSET_TARGET_SPEED = 6 + + @property + def angle(self) -> int: + """Returns current angle + + :return: Current angle value + :rtype: int + """ + + offset = Motor.PROPERTY_OFFSET_CURRENT_ANGLE + raw = self._get_property(Motor.PROPERTY_MOTOR_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @angle.setter + def angle(self, angle_value: Tuple[int, int]) -> None: + """Sets the angle of the motor + + :param angle_value: Value of angle and speed to reach target angle + :type angle_value: Tuple[int, int] + :return: None + """ + + self.set_angle(angle_value[0], angle_value[1]) + + @property + def target_angle(self) -> int: + """Returns target angle + + :return: Target angle value + :rtype: int + """ + + offset = Motor.PROPERTY_OFFSET_TARGET_ANGLE + raw = self._get_property(Motor.PROPERTY_MOTOR_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @property + def speed(self) -> int: + """Returns current speed + + :return: Current speed value + :rtype: int + """ + + offset = Motor.PROPERTY_OFFSET_CURRENT_SPEED + raw = self._get_property(Motor.PROPERTY_MOTOR_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @speed.setter + def speed(self, target_speed: int) -> None: + """Sets the speed of the motor + + :param target_speed: Speed to set the motor. + :type target_speed: int + :return: None + """ + + self.set_speed(target_speed) + + @property + def target_speed(self) -> int: + """Returns target speed + + :return: Target speed value + :rtype: int + """ + + offset = Motor.PROPERTY_OFFSET_TARGET_SPEED + raw = self._get_property(Motor.PROPERTY_MOTOR_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + def set_angle(self, target_angle: int, target_speed: int = 70) -> None: + """Sets the angle of the motor + + :param target_angle: Angle to set the motor. + :type target_angle: int + :param target_speed: Speed to reach target angle. + :type target_speed: int + :return: None + """ + + invalid_angle = (target_angle < 0 or target_angle > 360) + invalid_speed = (target_speed < 0 or target_speed > 100) + + if invalid_angle or invalid_speed: + return + + self._set_property( + destination_id=self._id, + property_num=Motor.PROPERTY_MOTOR_ANGLE, + property_values=(("u16", target_angle), + ("u16", target_speed), + ("u16", 0), + ("u16", 0), ) + ) + time.sleep(0.01) + + def set_speed(self, target_speed: int) -> None: + """Sets the speed of the motor + + :param target_speed: Speed to set the motor. + :type target_speed: int + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Motor.PROPERTY_MOTOR_SPEED, + property_values=(("s32", target_speed), ) + ) + time.sleep(0.01) + + def append_angle(self, target_angle: int, target_speed: int = 70) -> None: + """append the angle form current angle of the motor + + :param target_angle: Angle to append the motor angle. + :type target_angle: int + :param target_speed: Speed to reach target angle. + :type target_speed: int + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Motor.PROPERTY_MOTOR_ANGLE_APPEND, + property_values=(("s16", target_angle), + ("u16", target_speed), ), + force=True + ) + time.sleep(0.01) + + def stop(self) -> None: + """Stop operating motor + + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Motor.PROPERTY_MOTOR_STOP, + property_values=() + ) + time.sleep(0.01) diff --git a/packages/core/modi_plus/module/output_module/speaker.py b/packages/core/modi_plus/module/output_module/speaker.py new file mode 100644 index 0000000..79083b1 --- /dev/null +++ b/packages/core/modi_plus/module/output_module/speaker.py @@ -0,0 +1,311 @@ +"""Speaker module.""" + +import time +import struct +from typing import List, Tuple, Union +from modi_plus.module.module import OutputModule + + +class Speaker(OutputModule): + + STATE_STOP = 0 + STATE_START = 1 + STATE_PAUSE = 2 + STATE_RESUME = 3 + + PROPERTY_SPEAKER_STATE = 2 + + PROPERTY_SPEAKER_SET_TUNE = 16 + PROPERTY_SPEAKER_RESET = 17 + PROPERTY_SPEAKER_MUSIC = 18 + PROPERTY_SPEAKER_MELODY = 19 + + PROPERTY_OFFSET_CURRENT_VOLUME = 0 + PROPERTY_OFFSET_CURRENT_FREQUENCY = 2 + + SCALE_TABLE = { + "FA5": 698, + "SOL5": 783, + "LA5": 880, + "TI5": 988, + "DO#5": 554, + "RE#5": 622, + "FA#5": 739, + "SOL#5": 830, + "LA#5": 932, + "DO6": 1046, + "RE6": 1174, + "MI6": 1318, + "FA6": 1397, + "SOL6": 1567, + "LA6": 1760, + "TI6": 1975, + "DO#6": 1108, + "RE#6": 1244, + "FA#6": 1479, + "SOL#6": 1661, + "LA#6": 1864, + "DO7": 2093, + "RE7": 2349, + "MI7": 2637 + } + + PRESET_MUSIC = { + # .mid + "Sylvia : Pizzicato": "res/Delibes.mid", + "London Bridge is Falling Down": "res/London.mid", + "Old MacDonald Had a Farm": "res/OldMac.mid", + "Piano Concerto No.21": "res/Mozart21.mid", + "Le Donna E mobile": "res/Verdi.mid", + "Four Seasons: Spring": "res/Vivaldi.mid", + "Carmen : Les Toreadors": "res/Bizet.mid", + "The Washington Post": "res/Sousa.mid", + "Die Forelle(The Trout)": "res/SchubeD.mid", + "The Cuckoo Waltz": "res/Jonasson.mid", + "Entry of the Gladiators": "res/Fucik.mid", + "Mary had a Little Lamb": "res/Mary.mid", + "Symphony No.9": "res/Dvorak.mid", + "William Tell Overture": "res/Rossini.mid", + "Symphony No.40": "res/Mozart40.mid", + "Queen of the Night": "res/MozartQ.mid", + "Orpheus in the Underworld": "res/BachO.mid", + "Piano Concerto": "res/Grieg.mid", + "Toccata and Fugue in D minor": "res/BachD.mid", + "Symphony No. 5: I": "res/Beeth5.mid", + "For Elise": "res/BeethF.mid", + "Blue Danube": "res/Straus.mid", + "Carmina Burana: O Fortuna": "res/Orff.mid", + "Piano Concerto No.1": "res/Tchaiko1.mid", + "Csikos Post": "res/Necke.mid", + "Turkish March": "res/MozartR.mid", + "Hungarian Dance No.5": "res/Brahms5.mid", + "Dance of the Sugar Plum Fairy": "res/TchaikoD.mid", + "Itsy Bitsy Spider": "res/Spider.mid", + "The Farmer in The Dell": "res/Farmer.mid", + "Liebestraum No.3(Love Dream)": "res/Liszt.mid", + "Piano Sonata No.16": "res/Mozart16.mid", + "Bach: Minuet in G": "res/BachG.mid", + "Twinkle Twinkle Little Star": "res/twinkle.mid", + "Beethoven: Minuet in G": "res/BeethG.mid", + "Minuet": "res/Bocc.mid", + "16 Waltzes": "res/Brahms16.mid", + "Brahms: Lullaby": "res/BrahmsL.mid", + "Schubert: Lullaby": "res/SchubeW.mid", + "Yankee Doodle": "res/yankee.mid", + "Salut d'Amour(Love's Greeting)": "res/ElgarS.mid", + "Silver Waves": "res/Wyman.mid", + "Waltz of the Flowers": "res/TchaikoW.mid", + "Swan Lake : Scene": "res/TchaikoS.mid", + "Wedding March": "res/Mendel.mid", + "Bridal Chorus": "res/Wagner.mid", + "Pomp and Circumstance March": "res/ElgarP.mid", + "Happy Birthday to You": "res/Birthday.mid", + "Jingle Bells": "res/Jingle.mid", + "We Wish You a Merry Christmas": "res/Merry.mid", + "Excitement": "res/Emotion1.mid", + "Depressed": "res/Emotion2.mid", + "Joy": "res/Emotion3.mid", + "Warning 1": "res/Warning1.mid", + "Warning 2": "res/Warning2.mid", + "Start 1": "res/Start1.mid", + "Start 2": "res/Start2.mid", + "Complete 1": "res/Complet1.mid", + "Complete 2": "res/Complet2.mid", + + # .wav + "Alarm": "res/Alarm.wav", + "Bomb": "res/Bomb.wav", + "Camera": "res/Camera.wav", + "Car": "res/Car.wav", + "Complete": "res/Complete.wav", + "Exciting": "res/Exciting.wav", + "Robot": "res/Robot.wav", + "Siren": "res/Siren.wav", + "Start": "res/Start.wav", + "Success": "res/Success.wav", + "Win": "res/Win.wav", + "Bouncing": "res/bouncing.wav", + } + + @staticmethod + def preset_notes() -> List[str]: + return list(Speaker.SCALE_TABLE.keys()) + + @staticmethod + def preset_musics() -> List[str]: + return list(Speaker.PRESET_MUSIC.keys()) + + @property + def tune(self) -> Tuple[int, int]: + return self.frequency, self.volume + + @tune.setter + def tune(self, tune_value: Tuple[Union[int, str], int]) -> None: + """Set tune for the speaker + + :param tune_value: Value of frequency and volume + :type tune_value: Tuple[Union[int, str], int] + :return: None + """ + + self.set_tune(tune_value[0], tune_value[1]) + + @property + def frequency(self) -> int: + """Returns Current frequency + + :return: Frequency value + :rtype: int + """ + + offset = Speaker.PROPERTY_OFFSET_CURRENT_FREQUENCY + raw = self._get_property(Speaker.PROPERTY_SPEAKER_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @frequency.setter + def frequency(self, frequency_value: int) -> None: + """Set the frequency for the speaker + + :param frequency_value: Frequency to set + :type frequency_value: int + :return: None + """ + self.tune = frequency_value, self.volume + + @property + def volume(self) -> int: + """Returns Current volume + + :return: Volume value + :rtype: int + """ + + offset = Speaker.PROPERTY_OFFSET_CURRENT_VOLUME + raw = self._get_property(Speaker.PROPERTY_SPEAKER_STATE) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @volume.setter + def volume(self, volume_value: int) -> None: + """Set the volume for the speaker + + :param volume_value: Volume to set + :type volume_value: int + :return: None + """ + self.tune = self.frequency, volume_value + + def set_tune(self, frequency: Union[int, str], volume: int) -> None: + """Set tune for the speaker + + :param frequency: Frequency value + :type frequency: int + :param volume: Volume value + :type volume: int + :return: None + """ + + if isinstance(frequency, str): + frequency = Speaker.SCALE_TABLE.get(frequency, -1) + + if frequency < 0: + raise ValueError("Not a supported frequency value") + + self._set_property( + destination_id=self._id, + property_num=Speaker.PROPERTY_SPEAKER_SET_TUNE, + property_values=(("u16", frequency), ("u16", volume), ) + ) + time.sleep(0.01) + + def play_music(self, name: str, volume: int) -> None: + """Play music in speaker module + + :param name: Music name for playing + :type name: str + :param volume: Volume of speaker + :type volume: int + :return: None + """ + + file_name = Speaker.PRESET_MUSIC.get(name) + if file_name is None: + raise ValueError(f"{file_name} is not on the list, check 'Speaker.preset_musics()'") + + property_num = Speaker.PROPERTY_SPEAKER_MELODY if ".mid" in file_name else Speaker.PROPERTY_SPEAKER_MUSIC + self.playing_file_name = file_name + + self._set_property( + self._id, + property_num, + property_values=(("u8", Speaker.STATE_START), + ("u8", volume), + ("string", self.playing_file_name), ) + ) + time.sleep(0.1) + + def stop_music(self) -> None: + """Stop music in speaker module + + :return: None + """ + + if not len(self.playing_file_name): + return + + property_num = Speaker.PROPERTY_SPEAKER_MELODY if ".mid" in self.playing_file_name else Speaker.PROPERTY_SPEAKER_MUSIC + + self._set_property( + self._id, + property_num, + property_values=(("u8", Speaker.STATE_STOP), + ("u8", 0), + ("string", self.playing_file_name), ) + ) + + def pause_music(self) -> None: + """Pause music in speaker module + + :return: None + """ + + if not len(self.playing_file_name): + return + + property_num = Speaker.PROPERTY_SPEAKER_MELODY if ".mid" in self.playing_file_name else Speaker.PROPERTY_SPEAKER_MUSIC + + self._set_property( + self._id, + property_num, + property_values=(("u8", Speaker.STATE_PAUSE), + ("u8", 0), + ("string", self.playing_file_name), ) + ) + + def resume_music(self) -> None: + """Resume music in speaker module + + :return: None + """ + + if not len(self.playing_file_name): + return + + property_num = Speaker.PROPERTY_SPEAKER_MELODY if ".mid" in self.playing_file_name else Speaker.PROPERTY_SPEAKER_MUSIC + + self._set_property( + self._id, + property_num, + property_values=(("u8", Speaker.STATE_RESUME), + ("u8", 0), + ("string", self.playing_file_name), ) + ) + + def reset(self) -> None: + """Turn off the sound + + :return: None + """ + + self.set_tune(0, 0) diff --git a/packages/core/modi_plus/module/setup_module/__init__.py b/packages/core/modi_plus/module/setup_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/module/setup_module/battery.py b/packages/core/modi_plus/module/setup_module/battery.py new file mode 100644 index 0000000..a809c53 --- /dev/null +++ b/packages/core/modi_plus/module/setup_module/battery.py @@ -0,0 +1,24 @@ +"""Battery module.""" + +import struct +from modi_plus.module.module import SetupModule + + +class Battery(SetupModule): + + PROPERTY_BATTERY_STATE = 2 + + PROPERTY_OFFSET_LEVEL = 0 + + @property + def level(self) -> float: + """Returns the level value + + :return: The battery's level. + :rtype: float + """ + + offset = Battery.PROPERTY_OFFSET_LEVEL + raw = self._get_property(Battery.PROPERTY_BATTERY_STATE) + data = struct.unpack("f", raw[offset:offset + 4])[0] + return data diff --git a/packages/core/modi_plus/module/setup_module/network.py b/packages/core/modi_plus/module/setup_module/network.py new file mode 100644 index 0000000..bf3bdc5 --- /dev/null +++ b/packages/core/modi_plus/module/setup_module/network.py @@ -0,0 +1,395 @@ +"""Network module.""" + +import time +import struct +from importlib import import_module as im + +from modi_plus.util.connection_util import get_ble_task_path +from modi_plus.module.module import SetupModule + + +def check_connection(func): + """Check connection decorator + """ + def wrapper(*args, **kwargs): + if isinstance(args[0]._connection, im(get_ble_task_path()).BleTask): + raise ValueError(f"{func.__name__} doen't supported for ble connection") + return func(*args, **kwargs) + return wrapper + + +class Network(SetupModule): + + STATE_TRUE = 100 + STATE_FALSE = 0 + + STATE_JOYSTICK_UP = 100 + STATE_JOYSTICK_DOWN = -100 + STATE_JOYSTICK_LEFT = -50 + STATE_JOYSTICK_RIGHT = 50 + STATE_JOYSTICK_UNPRESSED = 0 + + STATE_TIMER_REACHED = 100 + STATE_TIMER_UNREACHED = 0 + + STATE_IMU_FRONT = 100 + STATE_IMU_REAR = -100 + STATE_IMU_LEFT = -50 + STATE_IMU_RIGHT = 50 + STATE_IMU_ORIGIN = 0 + + STATE_BUZZER_ON = 100 + STATE_BUZZER_OFF = 0 + + STATE_CAMERA_PICTURE = 100 + + PROPERTY_NETWORK_RECEIVE_DATA = 2 + PROPERTY_NETWORK_BUTTON = 3 + PROPERTY_NETWORK_SWITCH = 4 + PROPERTY_NETWORK_DIAL = 5 + PROPERTY_NETWORK_JOYSTICK = 6 + PROPERTY_NETWORK_SLIDER = 7 + PROPERTY_NETWORK_TIMER = 8 + PROPERTY_NETWORK_IMU = 9 + PROPERTY_NETWORK_IMU_DIRECTION = 0 + + PROPERTY_NETWORK_SEND_DATA = 2 + PROPERTY_NETWORK_SEND_TEXT = 3 + PROPERTY_NETWORK_BUZZER = 4 + PROPERTY_NETWORK_CAMERA = 5 + + PROPERTY_OFFSET_BUTTON_PRESSED = 0 + PROPERTY_OFFSET_BUTTON_CLICKED = 2 + PROPERTY_OFFSET_BUTTON_DOUBLE_CLICKED = 4 + + PROPERTY_OFFSET_IMU_ROLL = 0 + PROPERTY_OFFSET_IMU_PITCH = 2 + PROPERTY_OFFSET_IMU_YAW = 4 + + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self.__buzzer_flag = True + self.__esp_version = None + + @property + def esp_version(self): + if self.__esp_version: + return self.__esp_version + self._conn.send('{"c":160,"s":25,"d":4095,"b":"AAAAAAAAAA==","l":8}') + while not self.__esp_version: + time.sleep(0.01) + return self.__esp_version + + @esp_version.setter + def esp_version(self, version): + self.__esp_version = version + + @check_connection + def received_data(self, index: int = 0) -> int: + """Returns received data from MODI Play + + :param index: Data's index + :type index: int + :return: Received data + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_RECEIVE_DATA + 100 * index + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("i", raw[offset:offset + 4])[0] + return data + + @check_connection + def button_pressed(self, index: int = 0) -> bool: + """Returns whether MODI Play button is pressed + + :param index: Button's index + :type index: int + :return: True is pressed + :rtype: bool + """ + + property_num = Network.PROPERTY_NETWORK_BUTTON + 100 * index + offset = Network.PROPERTY_OFFSET_BUTTON_PRESSED + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Network.STATE_TRUE + + @check_connection + def button_clicked(self, index: int = 0) -> bool: + """Returns whether MODI Play button is clicked + + :param index: Button's index + :type index: int + :return: True is clicked + :rtype: bool + """ + + property_num = Network.PROPERTY_NETWORK_BUTTON + 100 * index + offset = Network.PROPERTY_OFFSET_BUTTON_CLICKED + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Network.STATE_TRUE + + @check_connection + def button_double_clicked(self, index: int = 0) -> bool: + """Returns whether MODI Play button is double clicked + + :param index: Button's index + :type index: int + :return: True is double clicked + :rtype: bool + """ + + property_num = Network.PROPERTY_NETWORK_BUTTON + 100 * index + offset = Network.PROPERTY_OFFSET_BUTTON_DOUBLE_CLICKED + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Network.STATE_TRUE + + @check_connection + def switch_toggled(self, index: int = 0) -> bool: + """Returns whether MODI Play switch is toggled + + :param index: Switch's index + :type index: int + :return: `True` if toggled or `False`. + :rtype: bool + """ + + property_num = Network.PROPERTY_NETWORK_SWITCH + 100 * index + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Network.STATE_TRUE + + @check_connection + def dial_turn(self, index: int = 0) -> int: + """Returns the current degree of MODI Play dial + + :param index: Dial's index + :type index: int + :return: Current degree + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_DIAL + 100 * index + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data + + @check_connection + def joystick_direction(self, index: int = 0) -> str: + """Returns the direction of the MODI Play joystick + + :param index: Joystick's index + :type index: int + :return: 'up', 'down', 'left', 'right', 'unpressed' + :rtype: str + """ + + property_num = Network.PROPERTY_NETWORK_JOYSTICK + 100 * index + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + + return { + Network.STATE_JOYSTICK_UP: "up", + Network.STATE_JOYSTICK_DOWN: "down", + Network.STATE_JOYSTICK_LEFT: "left", + Network.STATE_JOYSTICK_RIGHT: "right", + Network.STATE_JOYSTICK_UNPRESSED: "unpressed" + }.get(data) + + @check_connection + def slider_position(self, index: int = 0) -> int: + """Returns the current percentage of MODI Play slider + + :param index: Slider's index + :type index: int + :return: Current percentage + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_SLIDER + 100 * index + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + @check_connection + def time_up(self) -> bool: + """Returns if the MODI Play timer ticks + + :return: True if timer is up + :rtype: bool + """ + + property_num = Network.PROPERTY_NETWORK_TIMER + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("H", raw[offset:offset + 2])[0] + return data == Network.STATE_TIMER_REACHED + + @property + @check_connection + def imu_roll(self) -> int: + """Returns the roll angle of the MODI Play imu + + :return: Roll angle. + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_IMU + offset = Network.PROPERTY_OFFSET_IMU_ROLL + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + @check_connection + def imu_pitch(self) -> int: + """Returns the pitch angle of the MODI Play imu + + :return: Pitch angle. + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_IMU + offset = Network.PROPERTY_OFFSET_IMU_PITCH + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + @check_connection + def imu_yaw(self) -> int: + """Returns the yaw angle of the MODI Play imu + + :return: Yaw angle. + :rtype: int + """ + + property_num = Network.PROPERTY_NETWORK_IMU + offset = Network.PROPERTY_OFFSET_IMU_YAW + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + return data + + @property + @check_connection + def imu_direction(self) -> str: + """Returns the direction of the MODI Play imu + + :return: 'front', 'rear', 'left', 'right', 'origin' + :rtype: str + """ + + property_num = Network.PROPERTY_NETWORK_IMU_DIRECTION + offset = 0 + + raw = self._get_property(property_num) + data = struct.unpack("h", raw[offset:offset + 2])[0] + + return { + Network.STATE_IMU_FRONT: "front", + Network.STATE_IMU_REAR: "rear", + Network.STATE_IMU_LEFT: "left", + Network.STATE_IMU_RIGHT: "right", + Network.STATE_IMU_ORIGIN: "origin" + }.get(data) + + @check_connection + def send_data(self, index: int, data: int) -> None: + """Send text to MODI Play + + :param index: Data's index + :type index: int + :param data: Data to send. + :type data: int + :return: None + """ + + property_num = Network.PROPERTY_NETWORK_SEND_DATA + 0x100 * index + + self._set_property( + destination_id=self._id, + property_num=property_num, + property_values=(("s32", data),), + force=True + ) + + @check_connection + def send_text(self, text: str) -> None: + """Send text to MODI Play + + :param text: Text to send. + :type text: str + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Network.PROPERTY_NETWORK_SEND_TEXT, + property_values=(("string", text),), + force=True + ) + + @check_connection + def buzzer_on(self) -> None: + """Turns on MODI Play buzzer + + :return: None + """ + + if self.__buzzer_flag: + self.buzzer_off() + self.__buzzer_flag = False + + self._set_property( + destination_id=self._id, + property_num=Network.PROPERTY_NETWORK_BUZZER, + property_values=(("u8", Network.STATE_BUZZER_ON),) + ) + + @check_connection + def buzzer_off(self) -> None: + """Turns off MODI Play buzzer + + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Network.PROPERTY_NETWORK_BUZZER, + property_values=(("u8", Network.STATE_BUZZER_OFF),) + ) + self.__buzzer_flag = False + + @check_connection + def take_picture(self) -> None: + """Takes a picture on MODI Play + + :return: None + """ + + self._set_property( + destination_id=self._id, + property_num=Network.PROPERTY_NETWORK_CAMERA, + property_values=(("u8", Network.STATE_CAMERA_PICTURE),) + ) diff --git a/packages/core/modi_plus/task/__init__.py b/packages/core/modi_plus/task/__init__.py new file mode 100644 index 0000000..0152acc --- /dev/null +++ b/packages/core/modi_plus/task/__init__.py @@ -0,0 +1,34 @@ +"""MODI+ Task module - 통신 레이어 + +조건부 import를 통해 pyserial/bleak 없이도 패키지 import 가능 +""" + +# 플래그 초기화 +HAS_SERIAL = False +HAS_BLE = False + +# Serial Task (pyserial 필요) +try: + from modi_plus.task.serialport_task import SerialportTask + HAS_SERIAL = True +except ImportError: + SerialportTask = None + +# BLE Task (bleak 필요) +try: + from modi_plus.util.connection_util import get_ble_task_path + from importlib import import_module + # BLE task는 플랫폼별로 다르므로 여기서 직접 import하지 않음 + HAS_BLE = True +except ImportError: + HAS_BLE = False + +# Connection Task (항상 사용 가능) +from modi_plus.task.connection_task import ConnectionTask + +__all__ = [ + 'ConnectionTask', + 'HAS_SERIAL', + 'HAS_BLE', + 'SerialportTask', +] diff --git a/packages/core/modi_plus/task/ble_task/__init__.py b/packages/core/modi_plus/task/ble_task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/task/ble_task/ble_task_linux.py b/packages/core/modi_plus/task/ble_task/ble_task_linux.py new file mode 100644 index 0000000..c699434 --- /dev/null +++ b/packages/core/modi_plus/task/ble_task/ble_task_linux.py @@ -0,0 +1,29 @@ +from typing import Optional + +from modi_plus.task.connection_task import ConnectionTask + + +class BleTask(ConnectionTask): + CHAR_UUID = "00008421-0000-1000-8000-00805f9b34fb" + + def __init__(self, verbose=False, uuid=None): + super().__init__(verbose=verbose) + + def open_connection(self): + pass + + def close_connection(self): + pass + + def handle_disconnected(self, _): + print("Device is being properly disconnected...") + + def recv(self) -> Optional[str]: + return "" + + @ConnectionTask.wait + def send(self, pkt: str) -> None: + pass + + def send_nowait(self, pkt: str) -> None: + pass diff --git a/packages/core/modi_plus/task/ble_task/ble_task_mac.py b/packages/core/modi_plus/task/ble_task/ble_task_mac.py new file mode 100644 index 0000000..f74656a --- /dev/null +++ b/packages/core/modi_plus/task/ble_task/ble_task_mac.py @@ -0,0 +1,162 @@ +import sys +import json +import time +import base64 +import asyncio +import nest_asyncio + +from typing import Optional +from queue import Queue +from threading import Thread + +from bleak import BleakClient, BleakError, BleakScanner + +from modi_plus.task.connection_task import ConnectionTask +from modi_plus.util.connection_util import MODIConnectionError + + +nest_asyncio.apply() + + +class BleTask(ConnectionTask): + CHAR_UUID = "00008421-0000-1000-8000-00805f9b34fb" + + def __init__(self, verbose=False, uuid=None): + super().__init__(verbose=verbose) + self.modi_name = f"MODI+_{uuid.upper()}" + print(f"Initiating ble_task connection with {self.modi_name}") + self._loop = asyncio.get_event_loop() + self._recv_q = Queue() + self._send_q = Queue() + self.__close_event = False + + if sys.platform == "darwin": + from bleak.backends.corebluetooth import client as mac_client + self.__get_service = mac_client.BleakClientCoreBluetooth.get_services + mac_client.BleakClientCoreBluetooth.get_services = self.mac_get_service + + @staticmethod + async def mac_get_service(client): + return None + + def match_device(self, device, _): + return device.name == self.modi_name + + async def __connect(self, address): + client = BleakClient(address, disconnected_callback=self.handle_disconnected, timeout=2) + await client.connect(timeout=2) + await asyncio.sleep(1) + if sys.platform == "darwin": + await self.__get_service(client) + return client + + def __run_loop(self): + asyncio.set_event_loop(self._loop) + tasks = asyncio.gather(self.__send_handler(), self.__watch_notify()) + self._loop.run_until_complete(tasks) + + async def __watch_notify(self): + await self._bus.start_notify(self.CHAR_UUID, self.__recv_handler) + while True: + await asyncio.sleep(0.001) + if self.__close_event: + break + + async def __send_handler(self): + while True: + if self._send_q.empty(): + await asyncio.sleep(0.001) + else: + try: + pkt = self._send_q.get() + await self._bus.write_gatt_char(self.CHAR_UUID, pkt, response=True) + except BleakError: + self.__close_event = True + if self.__close_event: + break + + def __recv_handler(self, _, data): + self._recv_q.put(data) + + def open_connection(self): + modi_device = self._loop.run_until_complete(BleakScanner.find_device_by_filter(self.match_device)) + if modi_device: + self._bus = self._loop.run_until_complete(self.__connect(modi_device.address)) + Thread(target=self.__run_loop, daemon=True).start() + print(f"Connected to {modi_device.name}") + else: + raise MODIConnectionError(f"Network module of {self.modi_name} not found! Perhaps, the module is already paired with your device?") + + async def __close_client(self): + try: + await self._bus.stop_notify(self.CHAR_UUID) + await self._bus.disconnect() + except BleakError: + pass + + def close_connection(self): + if self._bus: + self.__close_event = True + while self._loop.is_running(): + time.sleep(0.1) + self._loop.run_until_complete(self.__close_client()) + self._loop.close() + + def handle_disconnected(self, _): + print("Device is being properly disconnected...") + + def recv(self) -> Optional[str]: + if self._recv_q.empty(): + return None + json_pkt = self.__parse_ble_msg(self._recv_q.get()) + if self.verbose: + print(f"recv: {json_pkt}") + return json_pkt + + @ConnectionTask.wait + def send(self, pkt: str) -> None: + self._send_q.put(self.__compose_ble_msg(pkt)) + while not self._send_q.empty(): + time.sleep(0.01) + if self.verbose: + print(f"send: {pkt}") + + def send_nowait(self, pkt: str) -> None: + self._send_q.put(self.__compose_ble_msg(pkt)) + if self.verbose: + print(f"send: {pkt}") + + # + # Non-Async Methods + # + @staticmethod + def __parse_ble_msg(ble_msg): + json_msg = dict() + json_msg["c"] = ble_msg[1] << 8 | ble_msg[0] + json_msg["s"] = ble_msg[3] << 8 | ble_msg[2] + json_msg["d"] = int.from_bytes(ble_msg[4:6], byteorder="little") + json_msg["b"] = base64.b64encode(ble_msg[8:]).decode("utf-8") + json_msg["l"] = ble_msg[7] << 8 | ble_msg[6] + return json.dumps(json_msg, separators=(",", ":")) + + @staticmethod + def __compose_ble_msg(json_msg): + json_msg = json.loads(json_msg) + ins = json_msg["c"] + sid = json_msg["s"] + did = json_msg["d"] + dlc = json_msg["l"] + data = json_msg["b"] + + ble_msg = bytearray(8 + dlc) + ble_msg[0] = ins & 0xFF + ble_msg[1] = ins >> 8 & 0xFF + ble_msg[2] = sid & 0xFF + ble_msg[3] = sid >> 8 & 0xFF + ble_msg[4] = did & 0xFF + ble_msg[5] = did >> 8 & 0xFF + ble_msg[6] = dlc & 0xFF + ble_msg[7] = dlc >> 8 & 0xFF + + ble_msg[8:8 + dlc] = bytearray(base64.b64decode(data)) + return ble_msg diff --git a/packages/core/modi_plus/task/ble_task/ble_task_rpi.py b/packages/core/modi_plus/task/ble_task/ble_task_rpi.py new file mode 100644 index 0000000..f4dc89a --- /dev/null +++ b/packages/core/modi_plus/task/ble_task/ble_task_rpi.py @@ -0,0 +1,182 @@ +import time +import os +import json +import queue +import base64 +import pexpect +import subprocess + +from typing import Optional +from threading import Thread + +from modi_plus.task.connection_task import ConnectionTask +from modi_plus.util.connection_util import ask_modi_device + + +class BleTask(ConnectionTask): + + def __init__(self, verbose=False, uuid=None): + print("Initiating ble_task connection...") + script = os.path.join(os.path.dirname(__file__), "change_interval.sh") + + # Security: Validate script path to prevent path traversal + script_abs = os.path.abspath(script) + expected_dir = os.path.abspath(os.path.dirname(__file__)) + if not script_abs.startswith(expected_dir): + raise ValueError("Invalid script path") + + # Security: Use subprocess instead of os.system to prevent command injection + # Change permissions to 755 (rwxr-xr-x) instead of 777 for better security + try: + subprocess.run(['chmod', '755', script], check=True, timeout=5) + subprocess.run(['sudo', script], check=True, timeout=10) + except subprocess.CalledProcessError as e: + print(f"Warning: Failed to execute Bluetooth configuration script: {e}") + except subprocess.TimeoutExpired: + print("Warning: Bluetooth configuration script timed out") + + super().__init__(verbose=verbose) + self._bus = None + self.__uuid = uuid + self._recv_q = queue.Queue() + self.__close_event = False + + @property + def bus(self): + return self._bus + + def __find_modi_device(self): + scanner = pexpect.spawn("sudo hcitool lescan") + init_time = time.time() + devices = [] + while time.time() - init_time < 1: + info = scanner.readline() + info = info.decode().split() + if "MODI+" in info[1] and info[1] not in (d[1] for d in devices): + devices.append(info) + scanner.terminate() + if not self.__uuid: + self.__uuid = ask_modi_device([d[1].upper() for d in devices]) + for info in devices: + if self.__uuid.upper() in info[1].upper(): + return info + raise ValueError("MODI+ network module does not exist!") + + def __reset(self): + # Security: Use subprocess instead of os.system to prevent command injection + try: + subprocess.run(['sudo', 'hciconfig', 'hci0', 'down'], + check=True, timeout=5, capture_output=True) + subprocess.run(['sudo', 'hciconfig', 'hci0', 'up'], + check=True, timeout=5, capture_output=True) + except subprocess.CalledProcessError as e: + print(f"Warning: Bluetooth reset failed: {e.stderr.decode() if e.stderr else e}") + except subprocess.TimeoutExpired: + print("Warning: Bluetooth reset timed out") + + def open_connection(self): + self.__reset() + modi_device = self.__find_modi_device() + print(f"Connecting to {modi_device[1]}...") + self.__reset() + self._bus = pexpect.spawn("gatttool -I") + self._bus.expect("LE") + for _ in range(5): + try: + self._bus.sendline(f"connect {modi_device[0]}") + self._bus.expect("Connection successful", timeout=1) + Thread(daemon=True, target=self.__ble_read).start() + break + except Exception: + print("...") + + def close_connection(self): + # Reboot modules to stop receiving channel messages + self.__close_event = True + self.send('{"c":9,"s":0,"d":4095,"b":"Bgg=","l":2}') + time.sleep(0.5) + self._bus.sendline("disconnect") + self._bus.terminate() + + # Security: Use subprocess instead of os.system to prevent command injection + try: + subprocess.run(['sudo', 'hciconfig', 'hci0', 'down'], + check=True, timeout=5, capture_output=True) + except subprocess.CalledProcessError as e: + print(f"Warning: Failed to shut down Bluetooth: {e.stderr.decode() if e.stderr else e}") + except subprocess.TimeoutExpired: + print("Warning: Bluetooth shutdown timed out") + + def __ble_read(self): + """ + handle -- integer, characteristic read handle the data was received on + value -- bytearray, the data returned in the notification + """ + + while True: + try: + self._bus.expect("value: .*?\r", timeout=0.5) + except Exception: + continue + msg = self._bus.after.decode().lstrip("value: ").split() + json_msg = self.__parse_ble_msg(bytearray([int(b, len(msg)) for b in msg])) + if self.verbose: + print(f"recv: {json_msg}") + self._recv_q.put(json_msg) + if self.__close_event: + break + time.sleep(0.002) + + @ConnectionTask.wait + def send(self, pkt: str) -> None: + self.send_nowait(pkt) + + def send_nowait(self, pkt: str) -> None: + json_msg = json.loads(pkt) + ble_msg = self.__compose_ble_msg(json_msg) + if self.verbose: + print(f"send: {json_msg}") + self._bus.sendline(f"char-write-cmd 0x002a {ble_msg}") + + def recv(self) -> Optional[str]: + if self._recv_q.empty(): + return None + return self._recv_q.get() + + # + # Ble Helper Methods + # + @staticmethod + def __parse_ble_msg(ble_msg): + json_msg = dict() + json_msg["c"] = ble_msg[1] << 8 | ble_msg[0] + json_msg["s"] = ble_msg[3] << 8 | ble_msg[2] + json_msg["d"] = ble_msg[5] << 8 | ble_msg[4] + json_msg["l"] = ble_msg[7] << 8 | ble_msg[6] + json_msg["b"] = base64.b64encode(ble_msg[8:]).decode("utf-8") + return json.dumps(json_msg, separators=(",", ":")) + + @staticmethod + def __compose_ble_msg(json_msg): + + ins = json_msg["c"] + sid = json_msg["s"] + did = json_msg["d"] + dlc = json_msg["l"] + data = json_msg["b"] + + ble_msg = bytearray(8 + dlc) + ble_msg[0] = ins & 0xFF + ble_msg[1] = ins >> 8 & 0xFF + ble_msg[2] = sid & 0xFF + ble_msg[3] = sid >> 8 & 0xFF + ble_msg[4] = did & 0xFF + ble_msg[5] = did >> 8 & 0xFF + ble_msg[6] = dlc & 0xFF + ble_msg[7] = dlc >> 8 & 0xFF + + ble_msg[8:8 + dlc] = bytearray(base64.b64decode(data)) + data = "" + for b in ble_msg: + data += f"{b:02X}" + return data diff --git a/packages/core/modi_plus/task/ble_task/ble_task_win.py b/packages/core/modi_plus/task/ble_task/ble_task_win.py new file mode 100644 index 0000000..5364d1f --- /dev/null +++ b/packages/core/modi_plus/task/ble_task/ble_task_win.py @@ -0,0 +1,145 @@ +import time +import json +import base64 +import asyncio + +from typing import Optional +from queue import Queue +from threading import Thread + +from bleak import BleakClient, BleakError, BleakScanner + +from modi_plus.task.connection_task import ConnectionTask +from modi_plus.util.connection_util import MODIConnectionError + + +class BleTask(ConnectionTask): + CHAR_UUID = "00008421-0000-1000-8000-00805f9b34fb" + + def __init__(self, verbose=False, uuid=None): + super().__init__(verbose=verbose) + self.modi_name = f"MODI+_{uuid.upper()}" + print(f"Initiating ble_task connection with {self.modi_name}") + self._loop = asyncio.get_event_loop() + self._recv_q = Queue() + self._send_q = Queue() + self.__close_event = False + + def match_device(self, device, _): + return device.name == self.modi_name + + async def __connect(self, address): + client = BleakClient(address, disconnected_callback=self.handle_disconnected, timeout=5) + await client.connect(timeout=2) + return client + + def __run_loop(self): + asyncio.set_event_loop(self._loop) + tasks = asyncio.gather(self.__send_handler(), self.__watch_notify()) + self._loop.run_until_complete(tasks) + + async def __watch_notify(self): + await self._bus.start_notify(self.CHAR_UUID, self.__recv_handler) + while True: + await asyncio.sleep(0.001) + if self.__close_event: + break + + async def __send_handler(self): + while True: + if self._send_q.empty(): + await asyncio.sleep(0.001) + else: + try: + pkt = self._send_q.get() + await self._bus.write_gatt_char(self.CHAR_UUID, pkt, response=True) + except BleakError: + self.__close_event = True + if self.__close_event: + break + + def __recv_handler(self, _, data): + self._recv_q.put(data) + + def open_connection(self): + modi_device = self._loop.run_until_complete(BleakScanner.find_device_by_filter(self.match_device)) + if modi_device: + self._bus = self._loop.run_until_complete(self.__connect(modi_device.address)) + Thread(target=self.__run_loop, daemon=True).start() + print(f"Connected to {modi_device.name}") + else: + raise MODIConnectionError(f"Network module of 0x{self.__uuid:X} not found!") + + async def __close_client(self): + try: + await self._bus.stop_notify(self.CHAR_UUID) + await self._bus.disconnect() + except BleakError: + pass + + def close_connection(self): + if self._bus: + self.__close_event = True + while self._loop.is_running(): + time.sleep(0.1) + self._loop.run_until_complete(self.__close_client()) + self._loop.close() + + def handle_disconnected(self, _): + print("Device is being properly disconnected...") + + def recv(self) -> Optional[str]: + if self._recv_q.empty(): + return None + json_pkt = self.__parse_ble_msg(self._recv_q.get()) + if self.verbose: + print(f"recv: {json_pkt}") + return json_pkt + + @ConnectionTask.wait + def send(self, pkt: str) -> None: + self._send_q.put(self.__compose_ble_msg(pkt)) + while not self._send_q.empty(): + time.sleep(0.01) + if self.verbose: + print(f"send: {pkt}") + + def send_nowait(self, pkt: str) -> None: + self._send_q.put(self.__compose_ble_msg(pkt)) + if self.verbose: + print(f"send: {pkt}") + + # + # Non-Async Methods + # + @staticmethod + def __parse_ble_msg(ble_msg): + json_msg = dict() + json_msg["c"] = ble_msg[1] << 8 | ble_msg[0] + json_msg["s"] = ble_msg[3] << 8 | ble_msg[2] + json_msg["d"] = int.from_bytes(ble_msg[4:6], byteorder="little") + json_msg["b"] = base64.b64encode(ble_msg[8:]).decode("utf-8") + json_msg["l"] = ble_msg[7] << 8 | ble_msg[6] + return json.dumps(json_msg, separators=(",", ":")) + + @staticmethod + def __compose_ble_msg(json_msg): + json_msg = json.loads(json_msg) + ins = json_msg["c"] + sid = json_msg["s"] + did = json_msg["d"] + dlc = json_msg["l"] + data = json_msg["b"] + + ble_msg = bytearray(8 + dlc) + ble_msg[0] = ins & 0xFF + ble_msg[1] = ins >> 8 & 0xFF + ble_msg[2] = sid & 0xFF + ble_msg[3] = sid >> 8 & 0xFF + ble_msg[4] = did & 0xFF + ble_msg[5] = did >> 8 & 0xFF + ble_msg[6] = dlc & 0xFF + ble_msg[7] = dlc >> 8 & 0xFF + + ble_msg[8:8 + dlc] = bytearray(base64.b64decode(data)) + return ble_msg diff --git a/packages/core/modi_plus/task/ble_task/change_interval.sh b/packages/core/modi_plus/task/ble_task/change_interval.sh new file mode 100644 index 0000000..a89340c --- /dev/null +++ b/packages/core/modi_plus/task/ble_task/change_interval.sh @@ -0,0 +1,2 @@ +echo 6 > /sys/kernel/debug/bluetooth/hci0/conn_min_interval +echo 20 > /sys/kernel/debug/bluetooth/hci0/conn_max_interval diff --git a/packages/core/modi_plus/task/connection_task.py b/packages/core/modi_plus/task/connection_task.py new file mode 100644 index 0000000..8e3e59a --- /dev/null +++ b/packages/core/modi_plus/task/connection_task.py @@ -0,0 +1,54 @@ +import time +from abc import ABC +from abc import abstractmethod +from typing import Optional + + +class ConnectionTask(ABC): + + def __init__(self, verbose=False): + self._bus = None + self.verbose = verbose + + @property + def bus(self): + return self._bus + + @bus.setter + def bus(self, new_bus): + if not isinstance(new_bus, type(self._bus)): + raise ValueError() + else: + self._bus = new_bus + + # + # Abstract Methods + # + @abstractmethod + def close_connection(self): + pass + + @abstractmethod + def open_connection(self): + pass + + @abstractmethod + def recv(self) -> Optional[str]: + pass + + @abstractmethod + def send(self, pkt: str) -> None: + pass + + @staticmethod + def wait(func): + """Wait decorator + Make sure this is attached to inherited send method + """ + + def decorator(self, pkt: str) -> None: + init_time = time.perf_counter() + func(self, pkt) + while time.perf_counter() - init_time < 0.04: + pass + return decorator diff --git a/packages/core/modi_plus/task/exe_task.py b/packages/core/modi_plus/task/exe_task.py new file mode 100644 index 0000000..6b0c05d --- /dev/null +++ b/packages/core/modi_plus/task/exe_task.py @@ -0,0 +1,217 @@ +import json +import time +from packaging import version +from base64 import b64decode + +from modi_plus.module.module import Module, BROADCAST_ID, get_module_from_name, get_module_type_from_uuid +from modi_plus.util.message_util import unpack_data, parse_message + + +class ExeTask: + + def __init__(self, modules, connection_task): + self._modules = modules + self._connection = connection_task + + # Reboot all modules + self.__request_reboot(BROADCAST_ID) + + def run(self, delay): + """ Run in ExecutorThread + + :param delay: time value to wait in seconds + :type delay: float + """ + + json_pkt = self._connection.recv() + if not json_pkt: + time.sleep(delay) + else: + try: + json_msg = json.loads(json_pkt) + self.__command_handler(json_msg["c"])(json_msg) + except json.decoder.JSONDecodeError: + print("current json message:", json_pkt) + + def __command_handler(self, command): + """ Execute task based on command message + + :param command: command code + :type command: int + :return: a function the corresponds to the command code + :rtype: Callable[[Dict[str, int]], None] + """ + + return { + 0x00: self.__update_health, + 0x05: self.__update_assign_id, + 0x1F: self.__update_channel, + 0xA1: self.__update_esp_version, + }.get(command, lambda _: None) + + def __get_module_by_id(self, module_id): + for module in self._modules: + if module.id == module_id: + return module + + def __compare_version(self, left, right): + if version.parse(left) > version.parse(right): + return 1 + elif version.parse(left) == version.parse(right): + return 0 + else: + return -1 + + def __update_health(self, message): + """ Update information by health message + + :param message: Dictionary format message of the module + :type message: Dictionary + :return: None + """ + + # Record battery information and user code state + module_id = message["s"] + curr_time = time.time() + + # Checking starts only when module is registered + if module_id in (module.id for module in self._modules): + module = self.__get_module_by_id(module_id) + module.last_updated_time = curr_time + module.is_connected = True + + if module.module_type == "network" and message["l"] == 6: + _, dir = unpack_data(message["b"], (5, 1)) + + # usb로 연결된 네트워크 모듈인 경우 interpreter 삭제 + if dir & 2 and module.is_usb_connected is False: + self.__request_erase_interpreter() + self.__request_reboot(BROADCAST_ID) + time.sleep(1) + self.__request_pnp_off() + module.is_usb_connected = True + + # 일반 모듈의 OS 버전이 1.3.1 이상일 경우, health data에 pnp on/off 상태가 포함되어 있다. + if module.module_type != "network" and self.__compare_version(module.os_version, "1.3.1") != -1: + _, pnp = unpack_data(message["b"], (3, 1)) + if pnp == 0: + # pnp 상태일 경우, pnp off + self.__request_pnp_off(module_id) + + # Reset disconnection alert status + if module.has_printed: + module.has_printed = False + else: + self.__request_find_id(module_id) + self.__request_find_network_id(module_id) + + # Disconnect module with no health message for more than 2 second + for module in self._modules: + if (curr_time - module.last_updated_time > 2) and (module.is_connected is True): + module.is_connected = False + module._last_set_message = None + + def __update_assign_id(self, message): + """ Update module information + :param message: Dictionary format module info + :type message: Dictionary + :return: None + """ + + module_id = message["s"] + module_uuid, module_os_version_info, module_app_version_info = unpack_data(message["b"], (6, 2, 2)) + module_type = get_module_type_from_uuid(module_uuid) + + # Handle new modules + if module_id not in (module.id for module in self._modules): + new_module = self.__add_new_module(module_type, module_id, module_uuid, module_app_version_info, module_os_version_info) + new_module.module_type = module_type + new_module.first_connected_time = time.time() + if module_type == "network": + self.__request_esp_version(module_id) + else: + module = self.__get_module_by_id(module_id) + if not module.is_connected: + # Handle Reconnected modules + module.is_connected = True + self.__request_pnp_off() + print(f"{str(module)} has been reconnected!") + + def __add_new_module(self, module_type, module_id, module_uuid, module_app_version_info, module_os_version_info): + module_template = get_module_from_name(module_type) + module_instance = module_template(module_id, module_uuid, self._connection) + self.__request_pnp_off() + module_instance.app_version = module_app_version_info + module_instance.os_version = module_os_version_info + self._modules.append(module_instance) + print(f"{str(module_instance)} has been connected!") + return module_instance + + def __update_channel(self, message): + """ Update module property + + :param message: Dictionary format message + :type message: Dictionary + :return: None + """ + + module_id = message["s"] + property_number = message["d"] + property_data = bytearray(b64decode(message["b"])) + + # Do not update reserved property + if property_number == 0 or property_number == 1: + return + + module = self.__get_module_by_id(module_id) + if not module: + return + + module.update_property(property_number, property_data) + + def __update_esp_version(self, message): + network_module = None + for module in self._modules: + if module.module_type == "network": + network_module = module + break + if not network_module: + return + + raw_data = b64decode(message["b"]) + network_module.esp_version = raw_data.lstrip(b"\x00").decode() + + def __set_module_state(self, destination_id, module_state, pnp_state): + """ Generate message for set module state and pnp state + + :param destination_id: Id to target destination + :type destination_id: int + :param module_state: State value of the module + :type module_state: int + :param pnp_state: Pnp state value + :type pnp_state: int + :return: None + """ + + self._connection.send_nowait(parse_message(0x09, 0, destination_id, (module_state, pnp_state))) + + def __request_reboot(self, id=BROADCAST_ID): + self.__set_module_state(id, Module.REBOOT, Module.PNP_OFF) + + def __request_pnp_on(self, id=BROADCAST_ID): + self.__set_module_state(id, Module.RUN, Module.PNP_ON) + + def __request_pnp_off(self, id=BROADCAST_ID): + self.__set_module_state(id, Module.RUN, Module.PNP_OFF) + + def __request_find_id(self, id=BROADCAST_ID): + self._connection.send_nowait(parse_message(0x08, 0x00, id, (0xFF, 0x0F))) + + def __request_find_network_id(self, id=BROADCAST_ID): + self._connection.send_nowait(parse_message(0x28, 0x00, id, (0xFF, 0x0F))) + + def __request_esp_version(self, id): + self._connection.send_nowait(parse_message(0xA0, 25, id, (0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF))) + + def __request_erase_interpreter(self): + self._connection.send_nowait(parse_message(160, 80, 4095, (0, 0, 0, 0, 0, 0, 0, 0))) diff --git a/packages/core/modi_plus/task/serialport_task.py b/packages/core/modi_plus/task/serialport_task.py new file mode 100644 index 0000000..3caf535 --- /dev/null +++ b/packages/core/modi_plus/task/serialport_task.py @@ -0,0 +1,150 @@ +from typing import Optional +from queue import Queue +import threading as th + +from serial.serialutil import SerialException + +from modi_plus.task.connection_task import ConnectionTask +from modi_plus.util.connection_util import list_modi_ports +from modi_plus.util.modi_serialport import ModiSerialPort + + +class SerialportTask(ConnectionTask): + + def __init__(self, verbose=False, port=None): + print("Initiating serial connection...") + super().__init__(verbose) + self.__port = port + self.__json_buffer = b"" + self._recv_queue = Queue() + self.__recv_thread = None + self.__stop_signal = False + + # + # Inherited Methods + # + def open_connection(self) -> None: + """ Open serial port + + :return: None + """ + + modi_ports = list_modi_ports() + if not modi_ports: + raise SerialException("No MODI+ network module is available") + + if self.__port: + if self.__port not in map(lambda info: info, modi_ports): + raise SerialException(f"{self.__port} is not connected to a MODI+ network module.") + else: + try: + self._bus = self.__init_serial(self.__port) + self._bus.open() + self.__open_recv_thread() + return + except SerialException: + raise SerialException(f"{self.__port} is not available.") + + for modi_port in modi_ports: + self._bus = self.__init_serial(modi_port) + try: + self._bus.open(modi_port) + self.__open_recv_thread() + print(f'Serial is open at "{modi_port}"') + return + except SerialException: + continue + raise SerialException("No MODI+ port is available now") + + @staticmethod + def __init_serial(port): + ser = ModiSerialPort(timeout=0.01) + return ser + + def __open_recv_thread(self): + self.__json_buffer = b"" + self._recv_queue = Queue() + self.__stop_signal = False + self.__recv_thread = th.Thread(target=self.__recv_handler, daemon=True) + self.__recv_thread.start() + + def __close_recv_thread(self): + self.__stop_signal = True + if self.__recv_thread: + self.__recv_thread.join() + + def __recv_handler(self): + while not self.__stop_signal: + recv = self._bus.read() + if recv: + self.__json_buffer += recv + + header_index = self.__json_buffer.find(b"{") + if header_index < 0: + self.__json_buffer = b"" + continue + self.__json_buffer = self.__json_buffer[header_index:] + + tail_index = self.__json_buffer.find(b"}") + if tail_index < 0: + continue + + json_pkt = self.__json_buffer[:tail_index + 1].decode("utf8") + self.__json_buffer = self.__json_buffer[tail_index + 1:] + self._recv_queue.put(json_pkt) + + def close_connection(self) -> None: + """ Close serial port + + :return: None + """ + + self.__close_recv_thread() + self._bus.close() + + def recv(self) -> Optional[str]: + """ Read serial message and put message to serial read queue + + :return: str + """ + + if self._recv_queue.empty(): + return None + + json_pkt = self._recv_queue.get() + if json_pkt is None: + return None + + if self.verbose: + print(f"recv: {json_pkt}") + + return json_pkt + + @ConnectionTask.wait + def send(self, pkt: str, verbose=False) -> None: + """ Send json pkt + + :param pkt: Json pkt to send + :type pkt: str + :param verbose: Verbosity parameter + :type verbose: bool + :return: None + """ + + self._bus.write(pkt.encode("utf8")) + if self.verbose or verbose: + print(f"send: {pkt}") + + def send_nowait(self, pkt: str, verbose=False) -> None: + """ Send json pkt + + :param pkt: Json pkt to send + :type pkt: str + :param verbose: Verbosity parameter + :type verbose: bool + :return: None + """ + + self._bus.write(pkt.encode("utf8")) + if self.verbose or verbose: + print(f"send: {pkt}") diff --git a/packages/core/modi_plus/util/__init__.py b/packages/core/modi_plus/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/modi_plus/util/connection_util.py b/packages/core/modi_plus/util/connection_util.py new file mode 100644 index 0000000..b9a1da7 --- /dev/null +++ b/packages/core/modi_plus/util/connection_util.py @@ -0,0 +1,89 @@ +import os +import sys +import platform +from typing import List + +# 조건부 import - pyserial 없어도 기본 기능은 사용 가능 +try: + import serial.tools.list_ports as stl + HAS_SERIAL = True +except ImportError: + stl = None + HAS_SERIAL = False + + +def list_modi_ports() -> List[str]: + """Returns a list of connected MODI ports + + :return: List[ListPortInfo] + """ + if not HAS_SERIAL: + return [] + + info_list = [] + + def __is_modi_port(port): + return (port.vid == 0x2FDE and port.pid == 0x0003) or port.description == "MODI+ Network Module" + modi_ports = [port for port in stl.comports() if __is_modi_port(port)] + for modi_port in modi_ports: + info_list.append(modi_port.device) + + if sys.platform.startswith("win"): + from modi_plus.util.winusb import list_modi_winusb_paths + path_list = list_modi_winusb_paths() + for index, value in enumerate(path_list): + info_list.append(value) + + return info_list + + +def is_on_pi() -> bool: + """Returns whether connected to pi + + :return: true if connected to pi + :rtype: bool + """ + return os.name != "nt" and os.uname()[4][:3] == "arm" + + +def is_network_module_connected() -> bool: + """Returns whether network module is connected + + :return: true if connected + :rtype: bool + """ + return bool(list_modi_ports()) + + +def ask_modi_device(devices): + if not devices: + raise ValueError( + "No MODI network module(s) available!\n" + "The network module that you're trying to connect, may in use." + ) + for idx, dev in enumerate(devices): + print(f"<{idx}>: {dev}") + i = input("Choose your device index (ex: 0) : ") + return devices[int(i)].lstrip("MODI+_") + + +def get_platform(): + if platform.uname().node == "raspberrypi": + return "rpi" + elif platform.uname().node == "penguin": + return "chrome" + return sys.platform + + +def get_ble_task_path(): + mod_path = { + "win32": "modi_plus.task.ble_task.ble_task_win", + "darwin": "modi_plus.task.ble_task.ble_task_mac", + "linux": "modi_plus.task.ble_task.ble_task_linux", + "rpi": "modi_plus.task.ble_task.ble_task_rpi", + }.get(get_platform()) + return mod_path + + +class MODIConnectionError(Exception): + pass diff --git a/packages/core/modi_plus/util/inspection_util.py b/packages/core/modi_plus/util/inspection_util.py new file mode 100644 index 0000000..d02a1ab --- /dev/null +++ b/packages/core/modi_plus/util/inspection_util.py @@ -0,0 +1,425 @@ +import os +import time + +import threading as th + +from textwrap import fill +from textwrap import dedent + + +class StoppableThread(th.Thread): + + def __init__(self, module, method): + super(StoppableThread, self).__init__(daemon=True) + self._stop = th.Event() + self._module = module + self._method = method + + def stop(self): + self._stop.set() + + def stopped(self): + return self._stop.isSet() + + def run(self): + # Security: Validate method name to prevent code injection + if not self._method.isidentifier(): + raise ValueError(f"Invalid method name: {self._method}") + + # Security: Check that the method exists before accessing it + if not hasattr(self._module, self._method): + raise AttributeError(f"Module has no attribute: {self._method}") + + while True: + # Security: Use getattr() instead of eval() to prevent code injection + prop = getattr(self._module, self._method) + print(f"\rObtained property value: {prop} ", end="") + time.sleep(0.1) + + +class Inspector: + """ + Inspector diagnoses malfunctioning modules (all modules but network) + """ + + row_len = 79 + + def __init__(self): + self.bundle = None + + @staticmethod + def clear(): + clear_cmd = "cls" if os.name == "nt" else "clear" + os.system(clear_cmd) + + def print_wrap(self, msg): + message = fill(dedent(msg), self.row_len).lstrip() + print(message) + + def print_module_page(self, module, i, nb_modules): + print("-" * self.row_len) + module_to_inspect = \ + f"| {' ' * 5} Diagnosing {module.module_type} ({module.id})" + progress_indicator = f"({i + 1} / {nb_modules}) {' ' * 5} |" + ls = f"{module_to_inspect:<{self.row_len}}" + s = progress_indicator.join( + ls.rsplit(" " * len(progress_indicator), 1) + ) + print(s) + print("-" * self.row_len) + + def inspect(self, module, i, nb_modules): + self.print_module_page(module, i, nb_modules) + + inspect_module = { + # inspection method for input modules + "battery": self.inspect_battery, + + # inspection method for input modules + "env": self.inspect_env, + "imu": self.inspect_imu, + "button": self.inspect_button, + "dial": self.inspect_dial, + "joystick": self.inspect_joystick, + "tof": self.inspect_tof, + + # inspection method for input modules + "display": self.inspect_display, + "motor": self.inspect_motor, + "led": self.inspect_led, + "speaker": self.inspect_speaker, + }.get(module.module_type) + inspect_module(module, i, nb_modules) + + self.clear() + + def inspect_battery(self, module, i, nb_modules): + self.print_wrap( + """ + Battery module has distance as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["level"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_env(self, module, i, nb_modules): + self.print_wrap( + """ + Environment module has illuminance, temperature, humidity and volume as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["illuminance", "temperature", "humidity", "volume"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_imu(self, module, i, nb_modules): + self.print_wrap( + """ + IMU module has angle, angular_velocity, acceleration and vibration as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["angle", "angular_velocity", "acceleration", "vibration"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_button(self, module, i, nb_modules): + self.print_wrap( + """ + Button module has cliked, double_clicked, pressed and toggled as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["cliked", "double_clicked", "pressed", "toggled"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_dial(self, module, i, nb_modules): + self.print_wrap( + """ + Dial module has turn and speed as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["turn", "speed"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_joystick(self, module, i, nb_modules): + self.print_wrap( + """ + Joystick module has turn and speed as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["x", "y", "direction"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_tof(self, module, i, nb_modules): + self.print_wrap( + """ + Tof module has distance as its property. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + properties = ["distance"] + + for prop in properties: + self.print_module_page(module, i, nb_modules) + print(f"If the {prop} shown below seems correct, press ENTER: \n") + t = StoppableThread(module, prop) + t.start() + input() + t.stop() + + def inspect_display(self, module, i, nb_modules): + self.print_wrap( + """ + Display module has a text field as its property. We wil inspect + this property for the module. + """ + ) + input("\nIf you are ready to inspect this module, Press ENTER: ") + self.clear() + + self.print_module_page(module, i, nb_modules) + module.set_text("Hello MODI+!") + input(dedent( + """ + We have set "Hello MODI+!" as its text, if you see this press ENTER: + """.lstrip().rstrip() + " " + )) + module.reset() + + def inspect_motor(self, module, i, nb_modules): + self.print_wrap( + """ + Motor module has degree (i.e. position) and speed as its + property. We will inspect position property of the module. + """ + ) + print() + self.print_wrap( + """ + Before continuing, we have set motors' initial position to zero + (your motor module may have moved a bit), so be clam :) + """ + ) + input("\nPress ENTER to continue: ") + self.clear() + module.set_angle(0, 70) + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + """ + Firstly, in order to inspect position property, we have rotated 360 degree. + """ + ) + module.set_angle(360, 70) + time.sleep(1.5) + input("\nIf the first motor has rotated 360 degrees, press ENTER: ") + self.clear() + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + f""" + It looks like the motor module ({module.id}) is properly + functioning! + """ + ) + input("\nTo inspect next module, press ENTER to continue: ") + + def inspect_led(self, module, i, nb_modules): + self.print_wrap( + """ + LED module has red, green and blue as its property. We will inspect + these properties each. + """ + ) + input("\nPress ENTER to continue: ") + self.clear() + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + """ + To inspect RED, We have set LED's RED to its maximum intensity. + """ + ) + module.set_rgb(255, 0, 0) + input("\nIf you see strong red from the led module, Press ENTER: ") + self.clear() + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + """ + To inspect GREEN, We have set LED's GREEN to its maximum intensity. + """ + ) + module.set_rgb(0, 255, 0) + input("\nIf you see strong green from the led module, Press ENTER: ") + self.clear() + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + """ + To inspect BLUE, We have set LED's BLUE to its maximum intensity. + """ + ) + module.set_rgb(0, 0, 255) + input("\nIf you see strong blue from the led module, Press ENTER: ") + self.clear() + + module.set_rgb(0, 0, 0) + self.print_module_page(module, i, nb_modules) + input(dedent( + f""" + It looks like the LED module ({module.id}) is properly functioning! + To inspect next module, press ENTER to continue: + """ + )) + + def inspect_speaker(self, module, i, nb_modules): + self.print_wrap( + """ + Speaker module has tune as its property, tune is composed of + frequency and volume. Thus inspecting the tune property consists of + inspecting frequency and volume properties. + """ + ) + self.clear() + + self.print_module_page(module, i, nb_modules) + self.print_wrap( + """ + To inspect tune property, we have set frequency of 880 and volume + of 50. + """ + ) + module.set_tune(880, 50) + input(dedent( + "\nPress ENTER if you hear a gentle sound from the speaker module!" + )) + module.set_tune(880, 0) + self.clear() + + # + # Main methods are defined below + # + def run_inspection(self): + self.clear() + print("=" * self.row_len) + print(f"= {'This is PyMODI+ Module Inspector':^{self.row_len - 4}} =") + print("=" * self.row_len) + + self.print_wrap( + """ + PyMODI+ provides a number of tools that can be utilized in different + purpose. One of them is the module (all modules but network) + inspector which diagnoses any malfunctioning MODI+ module. + """ + ) + + nb_modules = int(input(dedent( + """ + Connect network module to your local machine, attach other modi+ + modules to the network module. When attaching modi+ modules, make + sure that you provide sufficient power to the modules. Using modi+ + battery module is a good way of supplying the power to the modules. + + Type the number of modi+ modules (integer value) that are connected + to the network module (note that the maximum number of modules is + 20) and press ENTER: + """.rstrip() + " " + ))) + self.clear() + + if not (1 <= nb_modules <= 20): + print(f"ERROR: {nb_modules} is invalid for the number of modules") + os._exit(0) + + print("Importing modi_plus package and creating a modi+ bundle object...\n") + import modi_plus + self.bundle = modi_plus.MODIPlus() + + input("wait for connecting.....\nif check the module connected, press ENTER\n") + + modules = [m for m in self.bundle.modules if m.module_type != "network"] + nb_modules_detected = len(modules) + if nb_modules != nb_modules_detected: + self.print_wrap( + f""" + You said that you have attached {nb_modules} modules but PyMODI+ + detects only {nb_modules_detected} number of modules! Look at + the printed log above regarding module connection and check + which modules have not been printed above. + """ + ) + os._exit(0) + + input(dedent( + """ + It looks like all stm modules have been initialized properly! Let's + diagnose each module, one by one! + + Press ENTER to continue: + """.rstrip() + " " + )) + self.clear() + + # Let's inspect each stm module! + for i, module in enumerate(modules): + self.inspect(module, i, nb_modules) diff --git a/packages/core/modi_plus/util/message_util.py b/packages/core/modi_plus/util/message_util.py new file mode 100644 index 0000000..8f8a43e --- /dev/null +++ b/packages/core/modi_plus/util/message_util.py @@ -0,0 +1,101 @@ +import json +import struct +from base64 import b64encode, b64decode +from typing import Optional +from typing import Tuple + + +def parse_get_property_message(destination_id: int, property_type: int, property_frequency: int): + return parse_message(0x03, 0, destination_id, (property_type, 0x00, property_frequency, 0x00)) + + +def parse_set_property_message(destination_id: int, property_type: int, property_values: Tuple): + data = [] + for value_type, value in property_values: + data += parse_data(value, value_type) + return parse_message(0x04, property_type, destination_id, data) + + +def parse_message(command: int, source: int, destination: int, + byte_data: Tuple = + (None, None, None, None, None, None, None, None)): + message = dict() + message["c"] = command + message["s"] = source + message["d"] = destination + message["b"] = __encode_bytes(byte_data) + message["l"] = len(byte_data) + return json.dumps(message, separators=(",", ":")) + + +def __extract_length(begin: int, src: Tuple) -> int: + length = 1 + for i in range(begin + 1, len(src)): + if not src[i]: + length += 1 + else: + break + return length + + +def __encode_bytes(byte_data: Tuple): + idx = 0 + data = bytearray(len(byte_data)) + while idx < len(byte_data): + if not byte_data[idx]: + idx += 1 + elif byte_data[idx] > 256: + length = __extract_length(idx, byte_data) + data[idx: idx + length] = int.to_bytes( + byte_data[idx], byteorder="little", length=length, signed=True + ) + idx += length + elif byte_data[idx] < 0: + data[idx: idx + 4] = int.to_bytes( + int(byte_data[idx]), byteorder="little", length=4, signed=True + ) + idx += 4 + elif byte_data[idx] < 256: + data[idx] = int(byte_data[idx]) + idx += 1 + return b64encode(bytes(data)).decode("utf8") + + +def decode_message(message: str): + message = json.loads(message) + command = message["c"] + source = message["s"] + destination = message["d"] + data = message["b"] + length = message["l"] + return command, source, destination, data, length + + +def unpack_data(data: str, structure: Tuple = (1, 1, 1, 1, 1, 1, 1, 1)): + data = bytearray(b64decode(data.encode("utf8"))) + idx = 0 + result = [] + for size in structure: + result.append(int.from_bytes(data[idx:idx + size], byteorder="little")) + idx += size + return result + + +def parse_data(values, data_type: str) -> Optional[Tuple]: + if data_type == "string": + return tuple(str.encode(values)) + elif data_type == "float": + return tuple(bytearray(struct.pack("f", values))) + elif data_type == "bytes": + return values + elif data_type in ["s8", "s16", "s32"]: + return tuple(int.to_bytes(int(values), byteorder="little", signed=True, length=(int(data_type[1:])) // 8)) + elif data_type in ["u8", "u16", "u32"]: + return tuple(int.to_bytes(int(values), byteorder="little", signed=False, length=(int(data_type[1:])) // 8)) + else: + # error type + return None + + +def decode_data(data: str) -> float: + return round(struct.unpack("f", bytes(unpack_data(data)[:4]))[0], 2) diff --git a/packages/core/modi_plus/util/modi_serialport.py b/packages/core/modi_plus/util/modi_serialport.py new file mode 100644 index 0000000..40b1683 --- /dev/null +++ b/packages/core/modi_plus/util/modi_serialport.py @@ -0,0 +1,211 @@ +import sys +import time +import serial + + +class ModiSerialPort(): + SERIAL_MODE_COMPORT = 1 + SERIAL_MODI_WINUSB = 2 + + def __init__(self, port=None, baudrate=921600, timeout=0.2, write_timeout=None): + self.type = self.SERIAL_MODE_COMPORT + self._port = port + self._baudrate = baudrate + self._timeout = timeout + self._write_timeout = write_timeout + + self.serial_port = None + self._is_open = False + + if self._port is not None: + self.open(self._port) + + def open(self, port): + self._port = port + + if sys.platform.startswith("win"): + from modi_plus.util.winusb import ModiWinUsbComPort, list_modi_winusb_paths + if port in list_modi_winusb_paths(): + self.type = self.SERIAL_MODI_WINUSB + winusb = ModiWinUsbComPort(path=self._port, baudrate=self._baudrate, timeout=self._timeout) + self.serial_port = winusb + else: + ser = serial.Serial(port=self._port, baudrate=self._baudrate, timeout=self._timeout, write_timeout=self._write_timeout, exclusive=True) + self.serial_port = ser + else: + ser = serial.Serial(port=self._port, baudrate=self._baudrate, timeout=self._timeout, write_timeout=self._write_timeout, exclusive=True) + self.serial_port = ser + + self.is_open = True + + def close(self): + if self.is_open: + self.serial_port.close() + + def write(self, data): + if not self.is_open: + raise Exception("serialport is not opened") + if type(data) is str: + data = data.encode("utf8") + self.serial_port.write(data) + + def read(self, size=1): + if not self.is_open: + raise Exception("serialport is not opened") + if size is None and self.type == self.SERIAL_MODE_COMPORT: + size = 1 + return self.serial_port.read(size) + + def read_until(self, expected=b"\x0A", size=None): + if not self.is_open: + raise Exception("serialport is not opened") + + lenterm = len(expected) + line = bytearray() + modi_timeout = self.Timeout(self._timeout) + while True: + c = self.read(1) + if c: + line += c + if line[-lenterm:] == expected: + break + if size is not None and len(line) >= size: + break + else: + break + if modi_timeout.expired(): + break + return bytes(line) + + def read_all(self): + if not self.is_open: + raise Exception("serialport is not opened") + return self.serial_port.read_all() + + def flush(self): + if not self.is_open: + raise Exception("serialport is not opened") + self.serial_port.flush() + + def flushInput(self): + if not self.is_open: + raise Exception("serialport is not opened") + self.serial_port.flushInput() + + def flushOutput(self): + if not self.is_open: + raise Exception("serialport is not opened") + self.serial_port.flushOutput() + + def setDTR(self, state): + if not self.is_open: + raise Exception("serialport is not opened") + self.serial_port.setDTR(state) + + def setRTS(self, state): + if not self.is_open: + raise Exception("serialport is not opened") + self.serial_port.setRTS(state) + + def inWaiting(self): + if not self.is_open: + raise Exception("serialport is not opened") + + waiting = None + if self.type == self.SERIAL_MODE_COMPORT: + waiting = self.serial_port.inWaiting() + return waiting + + @property + def port(self): + return self._port + + @port.setter + def port(self, value): + self._port = value + self.serial_port.port = value + + @property + def baudrate(self): + return self._baudrate + + @baudrate.setter + def baudrate(self, value): + self._baudrate = value + self.serial_port.baudrate = value + + @property + def timeout(self): + return self._timeout + + @timeout.setter + def timeout(self, value): + self._timeout = value + self.serial_port.timeout = value + + @property + def write_timeout(self): + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, value): + self._write_timeout = value + self.serial_port.write_timeout = value + + class Timeout(object): + """\ + Abstraction for timeout operations. Using time.monotonic() if available + or time.time() in all other cases. + + The class can also be initialized with 0 or None, in order to support + non-blocking and fully blocking I/O operations. The attributes + is_non_blocking and is_infinite are set accordingly. + """ + if hasattr(time, "monotonic"): + # Timeout implementation with time.monotonic(). This function is only + # supported by Python 3.3 and above. It returns a time in seconds + # (float) just as time.time(), but is not affected by system clock + # adjustments. + TIME = time.monotonic + else: + # Timeout implementation with time.time(). This is compatible with all + # Python versions but has issues if the clock is adjusted while the + # timeout is running. + TIME = time.time + + def __init__(self, duration): + """Initialize a timeout with given duration""" + self.is_infinite = (duration is None) + self.is_non_blocking = (duration == 0) + self.duration = duration + if duration is not None: + self.target_time = self.TIME() + duration + else: + self.target_time = None + + def expired(self): + """Return a boolean, telling if the timeout has expired""" + return self.target_time is not None and self.time_left() <= 0 + + def time_left(self): + """Return how many seconds are left until the timeout expires""" + if self.is_non_blocking: + return 0 + elif self.is_infinite: + return None + else: + delta = self.target_time - self.TIME() + if delta > self.duration: + # clock jumped, recalculate + self.target_time = self.TIME() + self.duration + return self.duration + else: + return max(0, delta) + + def restart(self, duration): + """\ + Restart a timeout, only supported if a timeout was already set up + before. + """ + self.duration = duration + self.target_time = self.TIME() + duration diff --git a/packages/core/modi_plus/util/tutorial_util.py b/packages/core/modi_plus/util/tutorial_util.py new file mode 100644 index 0000000..735f35c --- /dev/null +++ b/packages/core/modi_plus/util/tutorial_util.py @@ -0,0 +1,357 @@ +import os +import time + +from textwrap import fill +from textwrap import dedent + + +class Tutor: + """ + Tutor teaches overall usage of PyMODI+ + """ + + row_len = 79 + + def __init__(self): + self.bundle = None + self.led = None + self.button = None + + @staticmethod + def clear(): + clear_cmd = "cls" if os.name == "nt" else "clear" + os.system(clear_cmd) + + def print_wrap(self, msg): + message = fill(dedent(msg), self.row_len).lstrip() + print(message) + + def print_lesson(self, lesson, title): + print("-" * self.row_len) + topic = f"Lesson {lesson}: {title}" + print(f"{topic:^{self.row_len}}") + print("-" * self.row_len) + + @staticmethod + def check_user_input(answer, give_answer=True, guide=">>> "): + response = input(guide) + nb_wrong = 1 + while response != answer: + if give_answer: + print(f"Write below code precisely.\n>>> {answer}\n") + elif nb_wrong > 2: + print(f"The answer is {answer}. Type it below.") + else: + print("Try again!") + response = input(guide) + nb_wrong += 1 + return response + + def run_introduction(self): + self.clear() + print("=" * self.row_len) + print(f"= {'Welcome to the PyMODI+ Tutor':^{self.row_len - 4}} =") + print("=" * self.row_len) + + self.print_wrap( + """ + PyMODI+ is a very powerful tool that can control the MODI+ modules + using python scripts. As long as you learn how to use built-in + functions of PyMODI+, you can easily control MODI+ modules. This + interactive CUI tutorial will guide you through the world of + PyMODI+. + """ + ) + + selection = dedent( + """ + Tutorial includes: + 1. Making MODI+ + 2. Accessing Modules + 3. Controlling Modules + 4. Your First PyMODI+ Project + """ + ) + print(selection) + + lesson_nb = int(input("Enter the lesson number and press ENTER: ")) + self.clear() + + if not (0 < lesson_nb < 5): + print("ERROR: lesson_nb must be one of 1, 2, 3 or 4") + os._exit(0) + + # Skip lesson 1 + if lesson_nb > 1: + print("=" * self.row_len) + print(f"= {'Preparing the modi_plus object...':^{self.row_len - 4}} =") + print("=" * self.row_len) + import modi_plus + self.print_wrap( + """ + In order to skip the first lesson, we need to set-up the + prerequisites. Thus, connect button and led module to your + device. + """ + ) + input("\nif the modules are ready, press ENTER to continue: \n") + print() + + self.bundle = modi_plus.MODIPlus() + + # Skip lesson 2 + if lesson_nb > 2: + self.button = self.bundle.buttons[0] + self.led = self.bundle.leds[0] + + run_selected_lesson = { + 1: self.run_lesson1, + 2: self.run_lesson2, + 3: self.run_lesson3, + 4: self.run_lesson4, + }.get(lesson_nb) + run_selected_lesson() + + def run_lesson1(self): + self.print_lesson(1, "Making MODI+") + self.print_wrap('First, you should import modi_plus. Type "import modi_plus"') + + self.check_user_input("import modi_plus") + import modi_plus + print("\nGreat! Now you can use all the features of MODI+!\n") + + self.print_wrap( + """ + To control the modules, make a MODIPlus object that contains all the + connected modules. Once you create it, it will automatically find + all the modules connected to the network module. + """ + ) + input("\nPress ENTER") + self.clear() + + self.print_wrap( + """ + Now, prepare real MODI+ modules. Connect a network module to your + computing device. Then, connect a Button module and an Led module. + Make a MODIPlus bundle object by typing bundle = modi_plus.MODIPlus() + """ + ) + self.check_user_input("bundle = modi_plus.MODIPlus()") + bundle = modi_plus.MODIPlus() + + self.print_wrap( + """ + Great! The "bundle" is your MODIPlus object. With it, you can control + all the modules connected to your device. + """ + ) + input("\nYou have completed this lesson. Press ENTER to continue.\n") + self.bundle = bundle + self.run_lesson2() + + def run_lesson2(self): + self.clear() + self.print_lesson(2, "Accessing modules") + self.print_wrap( + """ + In the previous lesson, you created a MODIPlus object. Let's figure out + how we can access modules connected to it. + """ + ) + print() + self.print_wrap( + """ + "bundle.modules" is a method to get all the modules connected to + the device. + """ + ) + print("\nType: bundle.modules") + self.check_user_input("bundle.modules") + print(self.bundle.modules) + print() + + self.print_wrap( + """ + You can see two modules connected (excluding the network module) to + the machine. You can access each module by the same method we use + with an array. + """ + ) + self.print_wrap( + """ + You can also access modules by types. + """ + ) + print("\nType: bundle.leds") + + self.check_user_input("bundle.leds") + print(self.bundle.leds) + print() + self.print_wrap( + """ + If you have followed previous instructions correctly, there must be + one led module connected to the network module. Now, make an led + variable by accessing the first led module. + """ + ) + print("\nType: led = bundle.leds[0]") + + self.check_user_input("led = bundle.leds[0]") + led = self.bundle.leds[0] + self.led = led + print() + self.print_wrap( + """ + Super! You can now do whatever you want with these modules. If you + have different modules connected, you can access the modules in a + same way, just typing bundle.s" + """ + ) + + input("\nYou have completed this lesson. Press ENTER to continue: \n") + self.run_lesson3() + + def run_lesson3(self): + led = self.led + led.set_rgb(0, 0, 0) + self.clear() + self.print_lesson(3, "Controlling modules") + self.print_wrap( + """ + Now you know how to access individual modules. + + Let's make an object named "button" as well for your button module. + You know how to do it (You have the modi_plus object, "bundle"). + """ + ) + + self.check_user_input("button = bundle.buttons[0]", False) + button = self.bundle.buttons[0] + self.button = button + print() + + self.print_wrap( + """ + Perfect. With your button module and led module, we can either get + data from the module or send command to the module. + """ + ) + + print() + self.print_wrap( + """ + "pressed" is a property method of a button module which returns + whether the button is pressed or not (i.e. press state). + """ + ) + print("Check the press state of the button by typing button.pressed") + + self.check_user_input("button.pressed") + print(button.pressed) + print() + + self.print_wrap( + """ + Now, see if the same command returns True when pressing the button. + """ + ) + + self.check_user_input("button.pressed") + print(button.pressed) + print() + + self.print_wrap( + """ + Good. Now let's send a command to the led module. Led's set_rgb is a + method of an led module. + """ + ) + print("Let there be light by typing led.set_rgb(0, 0, 100)") + + response = self.check_user_input("led.set_rgb(0, 0, 100)") + # Security: Use direct function call instead of exec() to prevent code injection + if response == "led.set_rgb(0, 0, 100)": + led.set_rgb(0, 0, 100) + else: + print(f"Invalid input. Expected 'led.set_rgb(0, 0, 100)', got '{response}'") + print() + + self.print_wrap( + """ + Perfect! You will see the blue light from the led module. + """ + ) + + input("\nYou have completed this lesson. Press ENTER to continue.\n") + self.run_lesson4() + + def run_lesson4(self): + button = self.button + led = self.led + + self.clear() + self.print_lesson(4, "Your First PyMODI+ Project(i.e. Creation)") + + self.print_wrap( + """ + Let's make a PyMODI+ project that blinks led when button is pressed. + """ + ) + self.print_wrap( + """ + In an infinite loop, we want our led to light up when button is + pressed, and turn off when not pressed. Complete the following code + based on the description. + """ + ) + + input("\nPress ENTER when you're ready! ") + self.clear() + + print(">>> while True:") + print("... # Check if button is pressed") + self.check_user_input( + "button.pressed:", give_answer=False, guide="... if " + ) + print("... # Set Led color to green") + self.check_user_input( + "led.set_rgb(0, 100, 0)", give_answer=False, guide="... " + ) + print("... elif button.double_clicked:") + print("... break") + print("... else:") + print("... # Turn off the led. (i.e. set color to (0, 0, 0))") + self.check_user_input( + "led.set_rgb(0, 0, 0)", give_answer=False, guide="... " + ) + print() + + self.print_wrap( + """ + Congrats!! Now let's see if the code works as we want. + Press the button to light up the led. Double click the button to + break out of the loop. + """ + ) + + while True: + if button.pressed: + led.set_rgb(0, 100, 0) + elif button.double_clicked: + break + else: + led.set_rgb(0, 0, 0) + time.sleep(0.01) + print() + self.print_wrap( + """ + It looks great! Now you know how to use PyMODI+ to control modules. + """ + ) + print( + 'You can check more functions at "pymodi-plus.readthedocs.io/en/latest"' + ) + + input("You have completed the tutorial. Press ENTER to exit: ") + os._exit(0) diff --git a/packages/core/modi_plus/util/unittest_util.py b/packages/core/modi_plus/util/unittest_util.py new file mode 100644 index 0000000..f48d905 --- /dev/null +++ b/packages/core/modi_plus/util/unittest_util.py @@ -0,0 +1,98 @@ +from modi_plus.module.setup_module.network import Network +from modi_plus.module.setup_module.battery import Battery +from modi_plus.module.input_module.env import Env +from modi_plus.module.input_module.imu import Imu +from modi_plus.module.input_module.button import Button +from modi_plus.module.input_module.dial import Dial +from modi_plus.module.input_module.joystick import Joystick +from modi_plus.module.input_module.tof import Tof +from modi_plus.module.output_module.display import Display +from modi_plus.module.output_module.motor import Motor +from modi_plus.module.output_module.led import Led +from modi_plus.module.output_module.speaker import Speaker + + +class MockConnection: + def __init__(self): + self.send_list = [] + + def send(self, pkt): + self.send_list.append(pkt) + + def send_nowait(self, pkt): + self.send_list.append(pkt) + + def recv(self): + return "Test" + + +class MockNetwork(Network): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockBattery(Battery): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockEnv(Env): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockImu(Imu): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockButton(Button): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockDial(Dial): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockJoystick(Joystick): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockTof(Tof): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockDisplay(Display): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockMotor(Motor): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockLed(Led): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False + + +class MockSpeaker(Speaker): + def __init__(self, id_, uuid, connection_task): + super().__init__(id_, uuid, connection_task) + self._enable_get_property_timeout = False diff --git a/packages/core/modi_plus/util/usage_util.py b/packages/core/modi_plus/util/usage_util.py new file mode 100644 index 0000000..fa8b347 --- /dev/null +++ b/packages/core/modi_plus/util/usage_util.py @@ -0,0 +1,308 @@ +import os + +from textwrap import fill +from textwrap import dedent + + +class UsageInstructor: + """ + Usage Instructor teaches basic module usage of PyMODI+. + It mainly teachs what methods are available for each module. + """ + + row_len = 79 + + def __init__(self): + self.bundle = None + self.led = None + self.button = None + + @staticmethod + def clear(): + clear_cmd = "cls" if os.name == "nt" else "clear" + os.system(clear_cmd) + + def print_wrap(self, msg): + message = fill(dedent(msg), self.row_len).lstrip() + print(message) + + def print_topic(self, module_type): + print("-" * self.row_len) + topic = f"Usage Manual {module_type}" + print(f"{topic:^{self.row_len}}") + print("-" * self.row_len) + + def run_usage_manual(self): + self.clear() + print("=" * self.row_len) + print(f"= {'Welcome to PyMODI+ Usage Manual':^{self.row_len - 4}} =") + print("=" * self.row_len) + + selection = dedent( + """ + Modules available for usage: + 1. Button + 2. Dial + 3. Env + 4. Imu + 5. Joystick + 7. Tof + 8. Display + 9. Led + 10. Motor + 11. Speaker + """ + ) + print(selection) + module_nb = int(input( + "Enter the module index (0 to exit) and press ENTER: " + )) + self.clear() + + if not (0 <= module_nb <= 11): + print("ERROR: invalid module index") + os._exit(0) + + run_selected_manual = { + 0: self.exit, + 1: self.run_button_manual, + 2: self.run_dial_manual, + 3: self.run_env_manual, + 4: self.run_imu_manual, + 5: self.run_joystick_manual, + 7: self.run_tof_manual, + 8: self.run_display_manual, + 9: self.run_led_manual, + 10: self.run_motor_manual, + 11: self.run_speaker_manual, + }.get(module_nb) + run_selected_manual() + + # + # Usage manuals for each module + # + def exit(self): + os._exit(0) + + def run_button_manual(self): + self.print_topic("Button") + + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + button = bundle.button[0] + + while True: + if button.clicked: + print(f"Button({button.id}) is clicked!") + if button.double_clicked: + print(f"Button({button.id}) is double clicked!") + if button.pressed: + print(f"Button({button.id}) is pressed!") + if button.toggled: + print(f"Button({button.id}) is toggled!") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_dial_manual(self): + self.print_topic("Dial") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + dial = bundle.dials[0] + + while True: + print(f"Dial ({dial.id}) turn: {dial.turn}") + print(f"Dial ({dial.id}) speed: {dial.speed}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_env_manual(self): + self.print_topic("Env") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + env = bundle.envs[0] + + while True: + print(f"Env ({env.id}) illuminance: {env.illuminance}") + print(f"Env ({env.id}) temperature: {env.temperature}") + print(f"Env ({env.id}) humidity: {env.humidity}") + print(f"Env ({env.id}) volume: {env.volume}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_imu_manual(self): + self.print_topic("Imu") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + imu = bundle.imus[0] + + while True: + print(f"Gyro ({imu.id}) angle_x: {imu.angle_x}") + print(f"Gyro ({imu.id}) angle_y: {imu.angle_y}") + print(f"Gyro ({imu.id}) angle_z: {imu.angle_z}") + print(f"Gyro ({imu.id}) angular_vel_x: {imu.angular_vel_x}") + print(f"Gyro ({imu.id}) angular_vel_y: {imu.angular_vel_y}") + print(f"Gyro ({imu.id}) angular_vel_z: {imu.angular_vel_z}") + print(f"Gyro ({imu.id}) acceleration_x: {imu.acceleration_x}") + print(f"Gyro ({imu.id}) acceleration_y: {imu.acceleration_y}") + print(f"Gyro ({imu.id}) acceleration_z: {imu.acceleration_z}") + print(f"Gyro ({imu.id}) vibration: {imu.vibration}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_joystick_manual(self): + self.print_topic("Joystick") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + joystick = bundle.joysticks[0] + + while True: + print(f"Joystick ({joystick.id}) x: {joystick.x}") + print(f"Joystick ({joystick.id}) y: {joystick.y}") + print(f"Joystick ({joystick.id}) direction: {joystick.direction}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_tof_manual(self): + self.print_topic("Tof") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + tof = bundle.tofs[0] + + while True: + print(f"ToF ({tof.id}) distance: {tof.distance}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_display_manual(self): + self.print_topic("Display") + print(dedent( + """ + import modi_plus + + bundle = modi_plus.MODIPlus() + display = bundle.displays[0] + + # Set text to display, you can check the text being displayed + display.set_text("Hello World!") + + # Check what text has been displayed currently (in program) + print(f"Display ({display.id}) text: {display.text}) + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_led_manual(self): + self.print_topic("Led") + print(dedent( + """ + import modi_plus + import time + + bundle = modi_plus.MODIPlus() + + led = bundle.leds[0] + + # Turn the led on for a second + led.set_rgb(100, 100, 100) + time.sleep(1) + + # Turn the led off for a second + led.set_rgb(0, 0, 0) + time.sleep(1) + + # Turn red on for a second + led.set_rgb(100, 0, 0) + time.sleep(1) + + led.set_rgb(0, 0, 0) + + # Turn green on for a second + led.set_rgb(0, 100, 0) + time.sleep(1) + + led.set_rgb(0, 0, 0) + + # Turn blue on for a second + led.set_rgb(0, 0, 100) + time.sleep(1) + + led.set_rgb(0, 0, 0) + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_motor_manual(self): + self.print_topic("Motor") + print(dedent( + """ + import modi_plus + import time + + bundle = modi_plus.MODIPlus() + motor = bundle.motors[0] + + motor.set_angle(0, 70) + time.sleep(1) + + motor.set_angle(60, 70) + time.sleep(1) + + print(f"motor ({motor.id}) angle: {motor.angle}") + + motor.set_speed(20) + time.sleep(1) + + print(f"motor ({motor.id}) speed: {motor.speed}") + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() + + def run_speaker_manual(self): + self.print_topic("Speaker") + print(dedent( + """ + import modi_plus + import time + + bundle = modi_plus.MODIPlus() + speaker = bundle.speakers[0] + + speaker.set_tune("SOL6", 50) + time.sleep(1) + """ + )) + input("Press ENTER to exit: ") + self.run_usage_manual() diff --git a/packages/core/modi_plus/util/winusb.py b/packages/core/modi_plus/util/winusb.py new file mode 100644 index 0000000..68f34ae --- /dev/null +++ b/packages/core/modi_plus/util/winusb.py @@ -0,0 +1,560 @@ +import time +import ctypes +from winusbcdc import WinUSBApi +from winusbcdc import UsbSetupPacket +from winusbcdc.usb_cdc import CDC_CMDS +from winusbcdc import GUID, DIGCF_PRESENT, DIGCF_DEVICE_INTERFACE, \ + SpDeviceInterfaceData, SpDeviceInterfaceDetailData, SpDevinfoData, GENERIC_WRITE, GENERIC_READ, FILE_SHARE_WRITE, \ + FILE_SHARE_READ, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OVERLAPPED, INVALID_HANDLE_VALUE, \ + UsbInterfaceDescriptor, PipeInfo, ERROR_IO_INCOMPLETE, ERROR_IO_PENDING, Overlapped +from ctypes import c_byte, byref, sizeof, c_ulong, resize, wstring_at, c_void_p, c_ubyte, create_string_buffer +from ctypes.wintypes import DWORD +from winusbcdc import SetupDiGetClassDevs, SetupDiEnumDeviceInterfaces, SetupDiGetDeviceInterfaceDetail, is_device, \ + CreateFile, WinUsb_Initialize, Close_Handle, WinUsb_Free, GetLastError, WinUsb_QueryDeviceInformation, \ + WinUsb_GetAssociatedInterface, WinUsb_QueryInterfaceSettings, WinUsb_QueryPipe, WinUsb_ControlTransfer, \ + WinUsb_WritePipe, WinUsb_ReadPipe, WinUsb_GetOverlappedResult, \ + WinUsb_SetPipePolicy, WinUsb_FlushPipe + + +def list_modi_winusb_paths(): + api = ModiWinUsb() + return api.list_usb_devices() + + +class ModiWinUsb(object): + + def __init__(self): + self.api = WinUSBApi() + byte_array = c_byte * 8 + self.usb_device_guid = GUID(0xA5DCBF10, 0x6530, 0x11D2, byte_array(0x90, 0x1F, 0x00, 0xC0, 0x4F, 0xB9, 0x51, 0xED)) + self.usb_winusb_guid = GUID(0xDEE824EF, 0x729b, 0x4A0E, byte_array(0x9C, 0x14, 0xB7, 0x11, 0x7D, 0x33, 0xA8, 0x17)) + self.usb_composite_guid = GUID(0x36FC9E60, 0xC465, 0x11CF, byte_array(0x80, 0x56, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00)) + self.handle_file = INVALID_HANDLE_VALUE + self.handle_winusb = [c_void_p()] + self._index = -1 + self.vid = 0x2FDE + self.pid = 0x0003 + + def list_usb_devices(self): + device_paths = [] + value = 0x00000000 + value |= DIGCF_PRESENT + value |= DIGCF_DEVICE_INTERFACE + + flags = DWORD(value) + self.handle = self.api.exec_function_setupapi(SetupDiGetClassDevs, byref(self.usb_winusb_guid), None, None, flags) + + sp_device_interface_data = SpDeviceInterfaceData() + sp_device_interface_data.cb_size = sizeof(sp_device_interface_data) + sp_device_interface_detail_data = SpDeviceInterfaceDetailData() + sp_device_info_data = SpDevinfoData() + sp_device_info_data.cb_size = sizeof(sp_device_info_data) + + i = 0 + required_size = DWORD(0) + member_index = DWORD(i) + cb_sizes = (8, 6, 5) # different on 64 bit / 32 bit etc + + while self.api.exec_function_setupapi(SetupDiEnumDeviceInterfaces, self.handle, None, byref(self.usb_winusb_guid), member_index, byref(sp_device_interface_data)): + self.api.exec_function_setupapi(SetupDiGetDeviceInterfaceDetail, self.handle, byref(sp_device_interface_data), None, 0, byref(required_size), None) + resize(sp_device_interface_detail_data, required_size.value) + + path = None + for cb_size in cb_sizes: + sp_device_interface_detail_data.cb_size = cb_size + ret = self.api.exec_function_setupapi(SetupDiGetDeviceInterfaceDetail, self.handle, byref(sp_device_interface_data), byref(sp_device_interface_detail_data), required_size, byref(required_size), byref(sp_device_info_data)) + if ret: + cb_sizes = (cb_size, ) + path = wstring_at(byref(sp_device_interface_detail_data, sizeof(DWORD))) + break + if path is None: + raise ctypes.WinError() + + if self.find_device(path) and path not in device_paths: + device_paths.append(path) + + i += 1 + member_index = DWORD(i) + required_size = c_ulong(0) + resize(sp_device_interface_detail_data, sizeof(SpDeviceInterfaceDetailData)) + + return device_paths + + def find_device(self, path): + return is_device(None, self.vid, self.pid, path) + + def init_winusb_device(self, path): + self.handle_file = self.api.exec_function_kernel32(CreateFile, path, GENERIC_WRITE | GENERIC_READ, + FILE_SHARE_WRITE | FILE_SHARE_READ, None, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, None) + + if self.handle_file == INVALID_HANDLE_VALUE: + return False + result = self.api.exec_function_winusb(WinUsb_Initialize, self.handle_file, byref(self.handle_winusb[0])) + if result == 0: + self.get_last_error_code() + raise ctypes.WinError() + # return False + else: + self._index = 0 + return True + + def close_winusb_device(self): + result_file = 1 + if self.handle_file: + result_file = self.api.exec_function_kernel32(Close_Handle, self.handle_file) + if result_file: + self.handle_file = None + + result_winusb = [self.api.exec_function_winusb(WinUsb_Free, h) for h in self.handle_winusb] + if 0 in result_winusb: + raise RuntimeError("Unable to close winusb handle") + self.handle_winusb = [] + return result_file != 0 + + def get_last_error_code(self): + return self.api.exec_function_kernel32(GetLastError) + + def query_device_info(self, query=1): + info_type = c_ulong(query) + buff = (c_void_p * 1)() + buff_length = c_ulong(sizeof(c_void_p)) + result = self.api.exec_function_winusb(WinUsb_QueryDeviceInformation, self.handle_winusb[self._index], info_type, byref(buff_length), buff) + if result != 0: + return buff[0] + else: + return -1 + + def query_interface_settings(self, index): + if self._index != -1: + temp_handle_winusb = self.handle_winusb[self._index] + interface_descriptor = UsbInterfaceDescriptor() + result = self.api.exec_function_winusb(WinUsb_QueryInterfaceSettings, temp_handle_winusb, c_ubyte(0), byref(interface_descriptor)) + if result != 0: + return interface_descriptor + else: + return None + else: + return None + + def change_interface(self, index, alternate=0): + new_handle = c_void_p() + result = self.api.exec_function_winusb(WinUsb_GetAssociatedInterface, self.handle_winusb[self._index], c_ubyte(alternate), byref(new_handle)) + if result != 0: + self._index = index + 1 + self.handle_winusb.append(new_handle) + return True + else: + return False + + def query_pipe(self, pipe_index): + pipe_info = PipeInfo() + result = self.api.exec_function_winusb(WinUsb_QueryPipe, self.handle_winusb[self._index], c_ubyte(0), pipe_index, byref(pipe_info)) + if result != 0: + return pipe_info + else: + return None + + def control_transfer(self, setup_packet, buff=None): + if buff is not None: + if setup_packet.length > 0: # Host 2 Device + buff = (c_ubyte * setup_packet.length)(*buff) + buffer_length = setup_packet.length + else: # Device 2 Host + buff = (c_ubyte * setup_packet.length)() + buffer_length = setup_packet.length + else: + buff = c_ubyte() + buffer_length = 0 + + result = self.api.exec_function_winusb(WinUsb_ControlTransfer, self.handle_winusb[0], setup_packet, byref(buff), c_ulong(buffer_length), byref(c_ulong(0)), None) + return {"result": result != 0, "buffer": [buff]} + + def write(self, pipe_id, write_buffer): + write_buffer = create_string_buffer(write_buffer) + written = c_ulong(0) + self.api.exec_function_winusb(WinUsb_WritePipe, self.handle_winusb[self._index], c_ubyte(pipe_id), write_buffer, c_ulong(len(write_buffer) - 1), byref(written), None) + return written.value + + def read(self, pipe_id, length_buffer): + read_buffer = create_string_buffer(length_buffer) + read = c_ulong(0) + result = self.api.exec_function_winusb(WinUsb_ReadPipe, self.handle_winusb[self._index], c_ubyte(pipe_id), read_buffer, c_ulong(length_buffer), byref(read), None) + if result != 0: + if read.value != length_buffer: + return read_buffer[:read.value] + else: + return read_buffer + else: + return None + + def set_timeout(self, pipe_id, timeout): + class POLICY_TYPE: + SHORT_PACKET_TERMINATE = 1 + AUTO_CLEAR_STALL = 2 + PIPE_TRANSFER_TIMEOUT = 3 + IGNORE_SHORT_PACKETS = 4 + ALLOW_PARTIAL_READS = 5 + AUTO_FLUSH = 6 + RAW_IO = 7 + + policy_type = c_ulong(POLICY_TYPE.PIPE_TRANSFER_TIMEOUT) + value_length = c_ulong(4) + value = c_ulong(int(timeout * 1000)) # in ms + result = self.api.exec_function_winusb(WinUsb_SetPipePolicy, self.handle_winusb[self._index], c_ubyte(pipe_id), policy_type, value_length, byref(value)) + return result + + def flush(self, pipe_id): + result = self.api.exec_function_winusb(WinUsb_FlushPipe, self.handle_winusb[self._index], c_ubyte(pipe_id)) + return result + + def _overlapped_read_do(self, pipe_id): + self.olread_ol.Internal = 0 + self.olread_ol.InternalHigh = 0 + self.olread_ol.Offset = 0 + self.olread_ol.OffsetHigh = 0 + self.olread_ol.Pointer = 0 + self.olread_ol.hEvent = 0 + result = self.api.exec_function_winusb(WinUsb_ReadPipe, self.handle_winusb[self._index], c_ubyte(pipe_id), self.olread_buf, c_ulong(self.olread_buflen), byref(c_ulong(0)), byref(self.olread_ol)) + if result != 0: + return True + else: + return False + + def overlapped_read_init(self, pipe_id, length_buffer): + self.olread_ol = Overlapped() + self.olread_buf = create_string_buffer(length_buffer) + self.olread_buflen = length_buffer + return self._overlapped_read_do(pipe_id) + + def overlapped_read(self, pipe_id): + """ keep on reading overlapped, return bytearray, empty if nothing to read, None if err""" + rl = c_ulong(0) + result = self.api.exec_function_winusb(WinUsb_GetOverlappedResult, self.handle_winusb[self._index], byref(self.olread_ol), byref(rl), False) + if result == 0: + if self.get_last_error_code() == ERROR_IO_PENDING or self.get_last_error_code() == ERROR_IO_INCOMPLETE: + return "" + else: + return None + else: + ret = str(self.olread_buf[0:rl.value]) + self._overlapped_read_do(pipe_id) + return ret + + +class ModiWinUsbComPort: + def __init__(self, path=None, baudrate=921600, timeout=0.2, write_timeout=0, start=True): + self.device = None + self.path = path + self._rxremaining = b"" + self.parity = 0 + self.stopbits = 1 + self.databits = 8 + self.maximum_packet_size = 0 + + self._baudrate = baudrate + self._timeout = timeout + self._write_timeout = write_timeout + + self.is_open = False + if start: + self.open() + + def open(self): + self.close() + + # Control interface + api = self._select_device(self.path) + if not api: + return False + + # Data Interface + api.change_interface(0) + interface2_descriptor = api.query_interface_settings(0) + + pipe_info_list = map(api.query_pipe, range(interface2_descriptor.b_num_endpoints)) + for item in pipe_info_list: + if item.pipe_id & 0x80: + self._ep_in = item.pipe_id + else: + self._ep_out = item.pipe_id + self.maximum_packet_size = min(item.maximum_packet_size, self.maximum_packet_size) or item.maximum_packet_size + + self.device = api + + self.is_open = True + + self.setControlLineState() + self.setLineCoding() + self.device.set_timeout(self._ep_in, self._timeout) + self.reset_input_buffer() + + @property + def in_waiting(self): + return False + + @property + def timeout(self): + return self._timeout + + @timeout.setter + def timeout(self, value): + self._timeout = value + if self.is_open: + self.device.set_timeout(self._ep_in, value) + + @property + def write_timeout(self): + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, value): + self._write_timeout = value + + @property + def baudrate(self): + return self._baudrate + + @baudrate.setter + def baudrate(self, value): + self._baudrate = value + self.setLineCoding(baudrate=value) + + def readinto(self, buf): + if not self.is_open: + return None + orig_size = len(buf) + read = 0 + if self._rxremaining: + remain_len = len(self._rxremaining) + read = min(remain_len, orig_size) + buf[0:read] = self._rxremaining[0:read] + self._rxremaining = self._rxremaining[read:] + end_timeout = time.time() + (self._timeout or 0.2) + self.device.set_timeout(self._ep_in, 2) + while read < orig_size: + remaining = orig_size - read + c = self.device.read(self._ep_in, min(remaining, 1024 * 4)) + if c is not None and len(c): + if len(c) > remaining: + end_timeout += 0.2 + buf[read:] = c[0:remaining] + self._rxremaining = c[remaining:] + return orig_size + else: + buf[read:] = c + read += len(c) + if time.time() > end_timeout: + break + return read + + def read(self, size=1): + if not self.is_open: + return None + rx = [self._rxremaining] + length = len(self._rxremaining) + self._rxremaining = b"" + end_timeout = time.time() + (self._timeout or 0.2) + if size: + self.device.set_timeout(self._ep_in, self._timeout) + while length < size: + c = self.device.read(self._ep_in, size - length) + if c is not None and len(c): + end_timeout += 0.2 + rx.append(c) + length += len(c) + if time.time() > end_timeout: + break + else: + self.device.set_timeout(self._ep_in, 0.2) + while True: + c = self.device.read(self._ep_in, self.maximum_packet_size) + if c is not None and len(c): + end_timeout += 0.2 + rx.append(c) + length += len(c) + else: + break + if time.time() > end_timeout: + break + chunk = b"".join(rx) + if size and len(chunk) >= size: + if self._rxremaining: + self._rxremaining = chunk[size:] + self._rxremaining + else: + self._rxremaining = chunk[size:] + chunk = chunk[0:size] + return chunk + + def readline(self, size=64 * 1024): + if not self.is_open: + return None + rx = [self._rxremaining] + length = len(self._rxremaining) + self._rxremaining = b"" + end_timeout = time.time() + self.timeout + self.device.set_timeout(self._ep_in, 0.2) + while b"\n" not in rx[-1]: # 10 == b"\n" + c = self.device.read(self._ep_in, size - length) + if c is not None and len(c): + end_timeout += 0.2 + length += len(c) + rx.append(c) + if time.time() > end_timeout: + break + line = b"".join(rx) + i = line.find(b"\n") + 1 + self._rxremaining = line[i:] + return line[0:i] + + def read_all(self): + return self.read(None) + + def write(self, data): + if not self.is_open: + return None + try: + self.device.write(self._ep_out, data) + except Exception: + # print("USB Error on write {}".format(e)) + return + + # if len(data) != ret: + # print("Bytes written mismatch {0} vs {1}".format(len(data), ret)) + # else: + # print("{} bytes written to ep".format(ret)) + + def setControlLineState(self, RTS=None, DTR=None): + if not self.is_open: + return None + ctrlstate = (2 if RTS else 0) + (1 if DTR else 0) + + txdir = 0 # 0:OUT, 1:IN + req_type = 1 # 0:std, 1:class, 2:vendor + # 0:device, 1:interface, 2:endpoint, 3:other + recipient = 1 + req_type = (txdir << 7) + (req_type << 5) + recipient + + pkt = UsbSetupPacket( + request_type=req_type, + request=CDC_CMDS["SET_CONTROL_LINE_STATE"], + value=ctrlstate, + index=0x00, + length=0x00 + ) + buff = None + + self.device.control_transfer(pkt, buff) + + def setLineCoding(self, baudrate=None, parity=None, databits=None, stopbits=None): + if not self.is_open: + return None + + sbits = {1: 0, 1.5: 1, 2: 2} + dbits = {5, 6, 7, 8, 16} + pmodes = {0, 1, 2, 3, 4} + brates = {300, 600, 1200, 2400, 4800, 9600, 14400, + 19200, 28800, 38400, 57600, 115200, 230400, 921600} + + if stopbits is not None: + if stopbits not in sbits.keys(): + valid = ", ".join(str(k) for k in sorted(sbits.keys())) + raise ValueError("Valid stopbits are " + valid) + self.stopbits = stopbits + + if databits is not None: + if databits not in dbits: + valid = ", ".join(str(d) for d in sorted(dbits)) + raise ValueError("Valid databits are " + valid) + self.databits = databits + + if parity is not None: + if parity not in pmodes: + valid = ", ".join(str(pm) for pm in sorted(pmodes)) + raise ValueError("Valid parity modes are " + valid) + self.parity = parity + + if baudrate is not None: + if baudrate not in brates: + brs = sorted(brates) + dif = [abs(br - baudrate) for br in brs] + best = brs[dif.index(min(dif))] + raise ValueError("Invalid baudrates, nearest valid is {}".format(best)) + self._baudrate = baudrate + + linecode = [ + self._baudrate & 0xff, + (self._baudrate >> 8) & 0xff, + (self._baudrate >> 16) & 0xff, + (self._baudrate >> 24) & 0xff, + sbits[self.stopbits], + self.parity, + self.databits] + + txdir = 0 # 0:OUT, 1:IN + req_type = 1 # 0:std, 1:class, 2:vendor + recipient = 1 # 0:device, 1:interface, 2:endpoint, 3:other + req_type = (txdir << 7) + (req_type << 5) + recipient + + pkt = UsbSetupPacket( + request_type=req_type, + request=CDC_CMDS["SET_LINE_CODING"], + value=0x0000, + index=0x00, + length=len(linecode) + ) + buff = linecode + + self.device.control_transfer(pkt, buff) + + def disconnect(self): + if not self.is_open: + return None + self.device.close_winusb_device() + self.is_open = False + + def __del__(self): + self.disconnect() + + def reset_input_buffer(self): + if self.is_open: + self.device.flush(self._ep_in) + self._rxremaining = b"" + + def reset_output_buffer(self): + pass + + def flush(self): + if not self.is_open: + return None + self.device.flush(self._ep_in) + + def close(self): + self.disconnect() + + def flushInput(self): + self.reset_input_buffer() + + def flushOutput(self): + self.reset_output_buffer() + + def setDTR(self, state): + pass + + def setRTS(self, state): + pass + + def _select_device(self, path): + api = ModiWinUsb() + + if not api.init_winusb_device(path): + return None + + return api + + +# main +if __name__ == "__main__": + paths = list_modi_winusb_paths() + for index, value in enumerate(paths): + print(index, value) diff --git a/packages/core/pytest.ini b/packages/core/pytest.ini new file mode 100644 index 0000000..4ef9582 --- /dev/null +++ b/packages/core/pytest.ini @@ -0,0 +1,24 @@ +[pytest] +# Test discovery patterns +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Directories to ignore during test collection +norecursedirs = .git .tox dist build *.egg .eggs __pycache__ .pytest_cache htmlcov + +# Output options +addopts = + --strict-markers + --tb=short + -ra + +# Logging +log_cli = false +log_cli_level = INFO + +# Disable warnings summary +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/packages/core/requirements-dev.txt b/packages/core/requirements-dev.txt new file mode 100644 index 0000000..af8db80 --- /dev/null +++ b/packages/core/requirements-dev.txt @@ -0,0 +1,4 @@ +flake8==4.0.1 +black==24.3.0 +tox==3.24.4 +coverage==6.1.1 diff --git a/packages/core/requirements.txt b/packages/core/requirements.txt new file mode 100644 index 0000000..c008908 --- /dev/null +++ b/packages/core/requirements.txt @@ -0,0 +1,15 @@ +# common +pyserial==3.5 +nest-asyncio==1.5.4 +websocket-client==1.2.3 +packaging>=21.3 + +# windows +bleak==0.13.0; sys_platform == 'win32' +winusbcdc==1.4; sys_platform == 'win32' + +# mac +bleak==0.13.0; sys_platform == 'darwin' + +# linux +pexpect; sys_platform == 'linux' diff --git a/packages/core/setup.cfg b/packages/core/setup.cfg new file mode 100644 index 0000000..c8ab39f --- /dev/null +++ b/packages/core/setup.cfg @@ -0,0 +1,15 @@ +[bumpversion] +current_version = 0.4.0 +commit = True +tag = False + +[bumpversion:file:./modi_plus/about.py] +search = __version__ = "{current_version}" +replace = __version__ = "{new_version}" + +[bdist_wheel] +universal = 1 + +[flake8] +exclude = docs +ignore = E203,W503,W504,E501 diff --git a/packages/core/setup.py b/packages/core/setup.py new file mode 100644 index 0000000..e386ec0 --- /dev/null +++ b/packages/core/setup.py @@ -0,0 +1,89 @@ +from os import path +from io import open +from setuptools import setup, find_packages + + +def get_spec(filename: str, mode: str="r"): + def wrapper(): + here = path.dirname(__file__) + result = {} + with open(path.join(here, filename), encoding="utf8") as src_file: + if mode == "d": + exec(src_file.read(), result) + else: + result = src_file.read() + return result + return wrapper + + +get_about = get_spec("./modi_plus/about.py", "d") +get_readme = get_spec("README.md") +get_history = get_spec("HISTORY.md") +get_requirements_dev = get_spec("requirements-dev.txt") + +# 최소 의존성 (Pyodide 호환) +INSTALL_REQUIRES = [ + 'nest-asyncio>=1.5.4', + 'packaging>=21.3', +] + +# Optional 의존성 +EXTRAS_REQUIRE = { + 'serial': [ + 'pyserial>=3.5', + ], + 'ble': [ + 'bleak>=0.13.0; sys_platform == "win32"', + 'bleak>=0.13.0; sys_platform == "darwin"', + ], + 'websocket': [ + 'websocket-client>=1.2.3', + ], + 'all': [ + 'pyserial>=3.5', + 'bleak>=0.13.0; sys_platform == "win32"', + 'bleak>=0.13.0; sys_platform == "darwin"', + 'websocket-client>=1.2.3', + 'winusbcdc>=1.4; sys_platform == "win32"', + 'pexpect; sys_platform == "linux"', + ], +} +EXTRAS_REQUIRE['dev'] = get_requirements_dev() + +about = get_about() +setup( + name=about["__title__"], + version=about["__version__"], + author=about["__author__"], + author_email=about["__email__"], + description=about["__summary__"], + long_description=get_readme() + "\n" + get_history(), + long_description_content_type="text/markdown", + install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, + license=about["__license__"], + include_package_data=True, + keywords=["python", "modi", "modi-plus", "modi_plus", "modi+"], + packages=find_packages( + include=[ + "modi_plus", "modi_plus.util", "modi_plus.task", "modi_plus.task.ble_task", + "modi_plus.module", + "modi_plus.module.setup_module", + "modi_plus.module.input_module", + "modi_plus.module.output_module" + ] + ), + test_suite="tests", + url=about["__url__"], + classifiers=[ + "Natural Language :: English", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + 'Programming Language :: Python :: 3', + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires='>=3.7', +) diff --git a/packages/core/tests/__init__.py b/packages/core/tests/__init__.py new file mode 100644 index 0000000..1e12afc --- /dev/null +++ b/packages/core/tests/__init__.py @@ -0,0 +1,9 @@ +""" +DO NOT DELETE THIS __init__.py FILE. + +The file is required in order to correctly run the command below. +>>> python setup.py test + +Setup detects test_suite="tests", as a package. +For tests/ to be recognized as a package, __init__.py is required. +""" diff --git a/packages/core/tests/module/__init__.py b/packages/core/tests/module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/tests/module/input_module/__init__.py b/packages/core/tests/module/input_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/tests/module/input_module/test_button.py b/packages/core/tests/module/input_module/test_button.py new file mode 100644 index 0000000..1d38615 --- /dev/null +++ b/packages/core/tests/module/input_module/test_button.py @@ -0,0 +1,65 @@ +import unittest + +from modi_plus.module.input_module.button import Button +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockButton + + +class TestButton(unittest.TestCase): + """Tests for 'Button' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.button = MockButton(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.button + + def test_get_clicked(self): + """Test get_clicked method.""" + + _ = self.button.clicked + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Button.PROPERTY_BUTTON_STATE, self.button.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_get_double_clicked(self): + """Test get_double_clicked method.""" + + _ = self.button.double_clicked + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Button.PROPERTY_BUTTON_STATE, self.button.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_get_pressed(self): + """Test get_pressed method.""" + + _ = self.button.pressed + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Button.PROPERTY_BUTTON_STATE, self.button.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_get_toggled(self): + """Test get_toggled method.""" + + _ = self.button.toggled + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Button.PROPERTY_BUTTON_STATE, self.button.prop_samp_freq) + ) + self.assertEqual(_, False) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/input_module/test_dial.py b/packages/core/tests/module/input_module/test_dial.py new file mode 100644 index 0000000..21ddddd --- /dev/null +++ b/packages/core/tests/module/input_module/test_dial.py @@ -0,0 +1,45 @@ +import unittest + +from modi_plus.module.input_module.dial import Dial +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockDial + + +class TestDial(unittest.TestCase): + """Tests for 'Dial' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.dial = MockDial(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.dial + + def test_get_degree(self): + """Test get_degree method.""" + + _ = self.dial.turn + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Dial.PROPERTY_DIAL_STATE, self.dial.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_speed(self): + """Test get_speed method.""" + + _ = self.dial.speed + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Dial.PROPERTY_DIAL_STATE, self.dial.prop_samp_freq) + ) + self.assertEqual(_, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/input_module/test_env.py b/packages/core/tests/module/input_module/test_env.py new file mode 100644 index 0000000..f5c2938 --- /dev/null +++ b/packages/core/tests/module/input_module/test_env.py @@ -0,0 +1,393 @@ +import unittest +import struct + +from modi_plus.module.input_module.env import Env +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockEnv + + +class TestEnv(unittest.TestCase): + """Tests for 'Env' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.env + + def test_get_temperature(self): + """Test get_temperature method.""" + + _ = self.env.temperature + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_ENV_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_humidity(self): + """Test get_humidity method.""" + + _ = self.env.humidity + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_ENV_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_illuminance(self): + """Test get_illuminance method.""" + + _ = self.env.illuminance + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_ENV_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_volume(self): + """Test get_volume method.""" + + _ = self.env.volume + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_ENV_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + +class TestEnvRGBVersion1(unittest.TestCase): + """Tests for RGB properties with app version 1.x (not supported).""" + + def setUp(self): + """Set up test fixtures with version 1.x.""" + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + + # Set app version to 1.5.0 (major version = 1) + # Version format: major << 13 | minor << 8 | patch + # 1.5.0 = (1 << 13) | (5 << 8) | 0 = 8192 + 1280 = 9472 + version_1_5_0 = (1 << 13) | (5 << 8) | 0 + self.env.app_version = version_1_5_0 + + def tearDown(self): + """Tear down test fixtures.""" + del self.env + + def test_rgb_not_supported_version_1(self): + """Test that RGB properties raise AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.red + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_green_not_supported_version_1(self): + """Test that green property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.green + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_blue_not_supported_version_1(self): + """Test that blue property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.blue + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_rgb_tuple_not_supported_version_1(self): + """Test that rgb tuple property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.rgb + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_is_rgb_supported_version_1(self): + """Test _is_rgb_supported returns False for version 1.x.""" + self.assertFalse(self.env._is_rgb_supported()) + + def test_white_not_supported_version_1(self): + """Test that white property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.white + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_black_not_supported_version_1(self): + """Test that black property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.black + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_color_class_not_supported_version_1(self): + """Test that color_class property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.color_class + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + def test_brightness_not_supported_version_1(self): + """Test that brightness property raises AttributeError in version 1.x.""" + with self.assertRaises(AttributeError) as context: + _ = self.env.brightness + self.assertIn("not supported in Env module version 1.x", str(context.exception)) + + +class TestEnvRGBVersion2(unittest.TestCase): + """Tests for RGB properties with app version 2.x (supported).""" + + def setUp(self): + """Set up test fixtures with version 2.x.""" + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + + # Set app version to 2.0.0 (major version = 2) + # Version format: major << 13 | minor << 8 | patch + # 2.0.0 = (2 << 13) | (0 << 8) | 0 = 16384 + version_2_0_0 = (2 << 13) | (0 << 8) | 0 + self.env.app_version = version_2_0_0 + + def tearDown(self): + """Tear down test fixtures.""" + del self.env + + def test_get_red(self): + """Test get_red method with version 2.x.""" + _ = self.env.red + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_green(self): + """Test get_green method with version 2.x.""" + _ = self.env.green + # Green is the second call (red was first in previous test setup) + # But in isolated test, this is first + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_blue(self): + """Test get_blue method with version 2.x.""" + _ = self.env.blue + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_rgb_tuple(self): + """Test get_rgb tuple method with version 2.x.""" + result = self.env.rgb + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 3) + self.assertEqual(result, (0, 0, 0)) + + def test_is_rgb_supported_version_2(self): + """Test _is_rgb_supported returns True for version 2.x.""" + self.assertTrue(self.env._is_rgb_supported()) + + def test_rgb_property_offsets(self): + """Test that RGB properties use correct offsets.""" + self.assertEqual(Env.PROPERTY_OFFSET_RED, 0) + self.assertEqual(Env.PROPERTY_OFFSET_GREEN, 2) + self.assertEqual(Env.PROPERTY_OFFSET_BLUE, 4) + self.assertEqual(Env.PROPERTY_OFFSET_WHITE, 6) + self.assertEqual(Env.PROPERTY_OFFSET_BLACK, 8) + self.assertEqual(Env.PROPERTY_OFFSET_COLOR_CLASS, 10) + self.assertEqual(Env.PROPERTY_OFFSET_BRIGHTNESS, 11) + + def test_get_white(self): + """Test get_white method with version 2.x.""" + _ = self.env.white + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_black(self): + """Test get_black method with version 2.x.""" + _ = self.env.black + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_color_class(self): + """Test get_color_class method with version 2.x.""" + _ = self.env.color_class + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + # Default value should be 0 (unknown) + self.assertEqual(_, 0) + + def test_get_brightness(self): + """Test get_brightness method with version 2.x.""" + _ = self.env.brightness + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Env.PROPERTY_RGB_STATE, self.env.prop_samp_freq) + ) + self.assertEqual(_, 0) + + +class TestEnvRGBVersion3(unittest.TestCase): + """Tests for RGB properties with app version 3.x (also supported).""" + + def setUp(self): + """Set up test fixtures with version 3.x.""" + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + + # Set app version to 3.2.1 (major version = 3) + # Version format: major << 13 | minor << 8 | patch + # 3.2.1 = (3 << 13) | (2 << 8) | 1 = 24576 + 512 + 1 = 25089 + version_3_2_1 = (3 << 13) | (2 << 8) | 1 + self.env.app_version = version_3_2_1 + + def tearDown(self): + """Tear down test fixtures.""" + del self.env + + def test_is_rgb_supported_version_3(self): + """Test _is_rgb_supported returns True for version 3.x.""" + self.assertTrue(self.env._is_rgb_supported()) + + def test_rgb_works_in_version_3(self): + """Test that RGB properties work in version 3.x.""" + # Should not raise any exception + _ = self.env.red + _ = self.env.green + _ = self.env.blue + rgb = self.env.rgb + self.assertEqual(rgb, (0, 0, 0)) + + def test_new_properties_work_in_version_3(self): + """Test that new color properties work in version 3.x.""" + # Should not raise any exception + _ = self.env.white + _ = self.env.black + _ = self.env.color_class + _ = self.env.brightness + # All should be 0 in mock + self.assertEqual(self.env.white, 0) + self.assertEqual(self.env.black, 0) + self.assertEqual(self.env.color_class, 0) + self.assertEqual(self.env.brightness, 0) + + +class TestEnvRGBNoVersion(unittest.TestCase): + """Tests for RGB properties when app version is not set.""" + + def setUp(self): + """Set up test fixtures without setting version.""" + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + # Don't set app_version - it should be None by default + + def tearDown(self): + """Tear down test fixtures.""" + del self.env + + def test_rgb_not_supported_no_version(self): + """Test that RGB properties raise AttributeError when version is not set.""" + with self.assertRaises(AttributeError): + _ = self.env.red + + def test_is_rgb_supported_no_version(self): + """Test _is_rgb_supported returns False when version is not set.""" + self.assertFalse(self.env._is_rgb_supported()) + + +class TestEnvRGBDataTypes(unittest.TestCase): + """Tests for RGB properties data types and values with app version 2.x.""" + + def setUp(self): + """Set up test fixtures with version 2.x and mock data.""" + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.env = MockEnv(*mock_args) + + # Set app version to 2.0.0 + version_2_0_0 = (2 << 13) | (0 << 8) | 0 + self.env.app_version = version_2_0_0 + + # Create mock RGB data with known values + # red=50, green=75, blue=100, white=25, black=10, color_class=2 (green), brightness=80 + self.mock_rgb_data = struct.pack("HHHHHBB", 50, 75, 100, 25, 10, 2, 80) + + def tearDown(self): + """Tear down test fixtures.""" + del self.env + + def test_rgb_values_with_mock_data(self): + """Test RGB values are correctly parsed from mock data.""" + # Override _get_property to return our mock data + original_get_property = self.env._get_property + + def mock_get_property(prop_id): + if prop_id == Env.PROPERTY_RGB_STATE: + return self.mock_rgb_data + return original_get_property(prop_id) + + self.env._get_property = mock_get_property + + # Test uint16_t values (0-100%) + self.assertEqual(self.env.red, 50) + self.assertEqual(self.env.green, 75) + self.assertEqual(self.env.blue, 100) + self.assertEqual(self.env.white, 25) + self.assertEqual(self.env.black, 10) + + # Test uint8_t values + self.assertEqual(self.env.color_class, 2) # green + self.assertEqual(self.env.brightness, 80) + + # Test rgb tuple + self.assertEqual(self.env.rgb, (50, 75, 100)) + + def test_color_class_values(self): + """Test color_class returns correct values for each color.""" + original_get_property = self.env._get_property + + # Test different color classes + color_class_tests = [ + (0, "unknown"), + (1, "red"), + (2, "green"), + (3, "blue"), + (4, "white"), + (5, "black"), + ] + + for color_value, color_name in color_class_tests: + mock_data = struct.pack("HHHHHBB", 0, 0, 0, 0, 0, color_value, 0) + + def mock_get_property(prop_id): + if prop_id == Env.PROPERTY_RGB_STATE: + return mock_data + return original_get_property(prop_id) + + self.env._get_property = mock_get_property + self.assertEqual(self.env.color_class, color_value, + f"color_class should be {color_value} for {color_name}") + + def test_property_rgb_state_constant(self): + """Test that PROPERTY_RGB_STATE constant is correctly defined.""" + self.assertEqual(Env.PROPERTY_RGB_STATE, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/input_module/test_imu.py b/packages/core/tests/module/input_module/test_imu.py new file mode 100644 index 0000000..1d23bfd --- /dev/null +++ b/packages/core/tests/module/input_module/test_imu.py @@ -0,0 +1,155 @@ +import unittest + +from modi_plus.module.input_module.imu import Imu +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockImu + + +class TestImu(unittest.TestCase): + """Tests for 'Imu' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.imu = MockImu(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.imu + + def test_get_angle_x(self): + """Test get_angle_x method.""" + + _ = self.imu.angle_x + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ANGLE_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angle_y(self): + """Test get_angle_y method.""" + + _ = self.imu.angle_y + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ANGLE_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angle_z(self): + """Test get_angle_z method.""" + + _ = self.imu.angle_z + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ANGLE_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angular_vel_x(self): + """Test get_angular_vel_x method.""" + + _ = self.imu.angular_vel_x + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_GYRO_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angular_vel_y(self): + """Test get_angular_vel_y method.""" + + _ = self.imu.angular_vel_y + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_GYRO_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angular_vel_z(self): + """Test get_angular_vel_z method.""" + + _ = self.imu.angular_vel_z + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_GYRO_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_acceleration_x(self): + """Test get_acceleration_x method.""" + + _ = self.imu.acceleration_x + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ACC_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_acceleration_y(self): + """Test get_acceleration_x method.""" + + _ = self.imu.acceleration_y + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ACC_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_acceleration_z(self): + """Test get_acceleration_z method.""" + + _ = self.imu.acceleration_z + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ACC_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_vibration(self): + """Test get_vibration method.""" + + _ = self.imu.vibration + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_VIBRATION_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_get_angle(self): + """Test get_angle_z method.""" + + _ = self.imu.angle + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ANGLE_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, (0.0, 0.0, 0.0)) + + def test_get_angular_velocity(self): + """Test get_angular_velocity method.""" + + _ = self.imu.angular_velocity + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_GYRO_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, (0.0, 0.0, 0.0)) + + def test_get_acceleration(self): + """Test get_acceleration method.""" + + _ = self.imu.acceleration + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Imu.PROPERTY_ACC_STATE, self.imu.prop_samp_freq) + ) + self.assertEqual(_, (0.0, 0.0, 0.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/input_module/test_joystick.py b/packages/core/tests/module/input_module/test_joystick.py new file mode 100644 index 0000000..ee0203c --- /dev/null +++ b/packages/core/tests/module/input_module/test_joystick.py @@ -0,0 +1,55 @@ +import unittest + +from modi_plus.module.input_module.joystick import Joystick +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockJoystick + + +class TestJoystick(unittest.TestCase): + """Tests for 'Joystick' package.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.joystick = MockJoystick(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.joystick + + def test_get_x(self): + """Test get_x method.""" + + _ = self.joystick.x + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Joystick.PROPERTY_POSITION_STATE, self.joystick.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_y(self): + """Test get_y method.""" + + _ = self.joystick.y + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Joystick.PROPERTY_POSITION_STATE, self.joystick.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_dirction(self): + """Test get_dirction method.""" + + _ = self.joystick.direction + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Joystick.PROPERTY_DIRECTION_STATE, self.joystick.prop_samp_freq) + ) + self.assertEqual(_, "origin") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/input_module/test_tof.py b/packages/core/tests/module/input_module/test_tof.py new file mode 100644 index 0000000..d0ae840 --- /dev/null +++ b/packages/core/tests/module/input_module/test_tof.py @@ -0,0 +1,35 @@ +import unittest + +from modi_plus.module.input_module.tof import Tof +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockTof + + +class TestTof(unittest.TestCase): + """Tests for 'Tof' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + mock_args = (-1, -1, self.connection) + self.tof = MockTof(*mock_args) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.tof + + def test_get_distance(self): + """Test get_distance method.""" + + _ = self.tof.distance + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Tof.PROPERTY_DISTANCE_STATE, self.tof.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/output_module/__init__.py b/packages/core/tests/module/output_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/tests/module/output_module/test_display.py b/packages/core/tests/module/output_module/test_display.py new file mode 100644 index 0000000..94dcd5f --- /dev/null +++ b/packages/core/tests/module/output_module/test_display.py @@ -0,0 +1,148 @@ +import unittest + +from modi_plus.module.output_module.display import Display +from modi_plus.util.message_util import parse_set_property_message +from modi_plus.util.unittest_util import MockConnection, MockDisplay + + +class TestDisplay(unittest.TestCase): + """Tests for 'Display' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = [-1, -1, self.connection] + self.display = MockDisplay(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.display + + def test_write_text(self): + """Test write_text method.""" + + mock_text = "0123456789abcdefghijklmnopqrstuvwxyz" + self.display.text = mock_text + set_messages = [] + + n = Display.TEXT_SPLIT_LEN + encoding_data = str.encode(str(mock_text)) + bytes(1) + splited_data = [encoding_data[x - n:x] for x in range(n, len(encoding_data) + n, n)] + for index, data in enumerate(splited_data): + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_WRITE_TEXT, + (("bytes", data), ) + ) + set_messages.append(set_message) + + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + for set_message in set_messages: + self.assertTrue(set_message in sent_messages) + self.assertEqual(self.display.text, mock_text) + + def test_write_variable_xy(self): + """Test write_variable_xy method.""" + + mock_variable = 123 + mock_position = 5 + self.display.write_variable_xy(mock_position, mock_position, mock_variable) + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_WRITE_VARIABLE, + (("u8", mock_position), ("u8", mock_position), + ("float", mock_variable), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_write_variable_line(self): + """Test write_variable_line method.""" + + mock_variable = 123 + mock_line = 2 + self.display.write_variable_line(mock_line, mock_variable) + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_WRITE_VARIABLE, + (("u8", 0), ("u8", mock_line * 20), + ("float", mock_variable), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_draw_picture(self): + """Test draw_picture method.""" + + mock_name = Display.preset_pictures()[0] + self.display.draw_picture(mock_name) + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_DRAW_PICTURE, + (("u8", 0), ("u8", 0), + ("u8", Display.WIDTH), ("u8", Display.HEIGHT), + ("string", Display.PRESET_PICTURE[mock_name]), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_draw_dot(self): + """Test draw_dot method.""" + + mock_dot = bytes([0 for i in range(Display.DOT_LEN)]) + self.display.draw_dot(mock_dot) + set_messages = [] + + n = Display.DOT_SPLIT_LEN + splited_data = [mock_dot[x - n:x] for x in range(n, len(mock_dot) + n, n)] + for index, data in enumerate(splited_data): + send_data = bytes([index]) + data + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_DRAW_DOT, + (("bytes", send_data), ) + ) + set_messages.append(set_message) + + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + for set_message in set_messages: + self.assertTrue(set_message in sent_messages) + + def test_set_offset(self): + """Test set_offset method.""" + + mock_x = 10 + mock_y = 20 + self.display.set_offset(mock_x, mock_y) + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_SET_OFFSET, + (("s8", mock_x), ("s8", mock_y), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_reset(self): + """Test reset method.""" + + self.display.reset() + set_message = parse_set_property_message( + -1, Display.PROPERTY_DISPLAY_RESET, + (("u8", 0), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/output_module/test_led.py b/packages/core/tests/module/output_module/test_led.py new file mode 100644 index 0000000..6755a87 --- /dev/null +++ b/packages/core/tests/module/output_module/test_led.py @@ -0,0 +1,149 @@ +import unittest + +from modi_plus.module.output_module.led import Led +from modi_plus.util.message_util import parse_set_property_message, parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockLed + + +class TestLed(unittest.TestCase): + """Tests for 'Led' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = -1, -1, self.connection + self.led = MockLed(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.led + + def test_set_rgb(self): + """Test set_rgb method with user-defined inputs.""" + + mock_red, mock_green, mock_blue = 10, 20, 100 + self.led.set_rgb(mock_red, mock_green, mock_blue) + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", mock_red), ("u16", mock_green), ("u16", mock_blue), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_set_red(self): + """Test set_red method with user-defined inputs.""" + + mock_red = 10 + self.led.red = mock_red + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", mock_red), ("u16", 0), ("u16", 0), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_set_green(self): + """Test set_green method with user-defined inputs.""" + + mock_green = 10 + self.led.green = mock_green + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", 0), ("u16", mock_green), ("u16", 0), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_set_blue(self): + """Test set_blue method with user-defined inputs.""" + + mock_blue = 10 + self.led.blue = mock_blue + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", 0), ("u16", 0), ("u16", mock_blue), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_red(self): + """Test get_red method with none input.""" + + _ = self.led.red + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Led.PROPERTY_LED_STATE, self.led.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_rgb(self): + """Test get_rgb method with none input.""" + + _ = self.led.rgb + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Led.PROPERTY_LED_STATE, self.led.prop_samp_freq) + ) + self.assertEqual(_, (0, 0, 0)) + + def test_get_green(self): + """Test set_green method with none input.""" + + _ = self.led.green + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Led.PROPERTY_LED_STATE, self.led.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_blue(self): + """Test get blue method with none input.""" + + _ = self.led.blue + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Led.PROPERTY_LED_STATE, self.led.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_turn_on(self): + """Test turn_on method.""" + + mock_red, mock_green, mock_blue = 100, 100, 100 + self.led.turn_on() + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", mock_red), ("u16", mock_green), ("u16", mock_blue), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_turn_off(self): + """Test turn_off method.""" + + mock_red, mock_green, mock_blue = 0, 0, 0 + self.led.turn_off() + set_message = parse_set_property_message( + -1, Led.PROPERTY_LED_SET_RGB, + (("u16", mock_red), ("u16", mock_green), ("u16", mock_blue), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/output_module/test_motor.py b/packages/core/tests/module/output_module/test_motor.py new file mode 100644 index 0000000..0e6c15c --- /dev/null +++ b/packages/core/tests/module/output_module/test_motor.py @@ -0,0 +1,120 @@ +import unittest + +from modi_plus.module.output_module.motor import Motor +from modi_plus.util.message_util import parse_set_property_message, parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockMotor + + +class TestMotor(unittest.TestCase): + """Tests for 'Motor' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = [-1, -1, self.connection] + self.motor = MockMotor(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.motor + + def test_set_speed(self): + """Test set_speed method.""" + + mock_speed = 50 + self.motor.speed = mock_speed + set_message = parse_set_property_message( + -1, Motor.PROPERTY_MOTOR_SPEED, + (("s32", mock_speed), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_speed(self): + """Test get_speed method with none input.""" + + _ = self.motor.speed + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Motor.PROPERTY_MOTOR_STATE, self.motor.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_target_speed(self): + """Test get_target_speed method with none input.""" + + _ = self.motor.target_speed + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Motor.PROPERTY_MOTOR_STATE, self.motor.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_set_angle(self): + """Test set_angle method.""" + + mock_angle, mock_speed = 90, 50 + self.motor.angle = mock_angle, mock_speed + set_message = parse_set_property_message( + -1, Motor.PROPERTY_MOTOR_ANGLE, + (("u16", mock_angle), + ("u16", mock_speed), + ("u16", 0), + ("u16", 0), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_angle(self): + """Test get_angle method with none input.""" + + _ = self.motor.angle + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Motor.PROPERTY_MOTOR_STATE, self.motor.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_get_target_angle(self): + """Test get_target_angle method with none input.""" + + _ = self.motor.target_angle + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Motor.PROPERTY_MOTOR_STATE, self.motor.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_append_angle(self): + """Test append_angle method with none input.""" + + mock_angle, mock_speed = 90, 50 + self.motor.append_angle(mock_angle, mock_speed) + set_message = parse_set_property_message( + -1, Motor.PROPERTY_MOTOR_ANGLE_APPEND, + (("u16", mock_angle), ("u16", mock_speed), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_stop(self): + """Test stop method with none input.""" + + self.motor.stop() + set_message = parse_set_property_message(-1, Motor.PROPERTY_MOTOR_STOP, ()) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/output_module/test_speaker.py b/packages/core/tests/module/output_module/test_speaker.py new file mode 100644 index 0000000..0621eb1 --- /dev/null +++ b/packages/core/tests/module/output_module/test_speaker.py @@ -0,0 +1,193 @@ +import unittest + +from modi_plus.module.output_module.speaker import Speaker +from modi_plus.util.message_util import parse_set_property_message, parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockSpeaker + + +class TestSpeaker(unittest.TestCase): + """Tests for 'Speaker' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = [-1, -1, self.connection] + self.speaker = MockSpeaker(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.speaker + + def test_set_tune(self): + """Test set_tune method.""" + + mock_frequency, mock_volume = 500, 30 + self.speaker.tune = mock_frequency, mock_volume + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_SET_TUNE, + (("u16", mock_frequency), ("u16", mock_volume), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_set_tune_str(self): + """Test set_tune method.""" + + mock_note = Speaker.preset_notes()[0] + mock_frequency, mock_volume = Speaker.SCALE_TABLE[mock_note], 30 + self.speaker.tune = mock_note, mock_volume + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_SET_TUNE, + (("u16", mock_frequency), ("u16", mock_volume), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_tune(self): + """Test get_tune method.""" + + _ = self.speaker.tune + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Speaker.PROPERTY_SPEAKER_STATE, self.speaker.prop_samp_freq) + ) + self.assertEqual(_, (0, 0)) + + def test_set_frequency(self): + """Test set_frequency method.""" + + mock_frequency = 500 + self.speaker.frequency = mock_frequency + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_SET_TUNE, + (("u16", mock_frequency), ("u16", 0), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_frequency(self): + """Test get_frequency method with none input.""" + + _ = self.speaker.frequency + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Speaker.PROPERTY_SPEAKER_STATE, self.speaker.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_set_volume(self): + """Test set_volume method.""" + + mock_volume = 30 + self.speaker.volume = 30 + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_SET_TUNE, + (("u16", 0), ("u16", mock_volume), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_get_volume(self): + """Test get_volume method with none input.""" + + _ = self.speaker.volume + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Speaker.PROPERTY_SPEAKER_STATE, self.speaker.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_play_music(self): + """Test play_music method.""" + + mock_music = Speaker.preset_musics()[0] + mock_volume = 80 + self.speaker.play_music(mock_music, mock_volume) + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_MELODY, + (("u8", Speaker.STATE_START), ("u8", mock_volume), + ("string", Speaker.PRESET_MUSIC[mock_music]), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_stop_music(self): + """Test stop_music method.""" + + mock_music = Speaker.preset_musics()[0] + mock_volume = 80 + self.speaker.play_music(mock_music, mock_volume) + self.speaker.stop_music() + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_MELODY, + (("u8", Speaker.STATE_STOP), ("u8", 0), + ("string", Speaker.PRESET_MUSIC[mock_music]), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_pause_music(self): + """Test pause_music method.""" + + mock_music = Speaker.preset_musics()[0] + mock_volume = 80 + self.speaker.play_music(mock_music, mock_volume) + self.speaker.pause_music() + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_MELODY, + (("u8", Speaker.STATE_PAUSE), ("u8", 0), + ("string", Speaker.PRESET_MUSIC[mock_music]), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_resume_music(self): + """Test resume_music method.""" + + mock_music = Speaker.preset_musics()[0] + mock_volume = 80 + self.speaker.play_music(mock_music, mock_volume) + self.speaker.resume_music() + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_MELODY, + (("u8", Speaker.STATE_RESUME), ("u8", 0), + ("string", Speaker.PRESET_MUSIC[mock_music]), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_reset(self): + """Test reset method""" + + mock_frequency, mock_volume = 0, 0 + self.speaker.reset() + set_message = parse_set_property_message( + -1, Speaker.PROPERTY_SPEAKER_SET_TUNE, + (("u16", mock_frequency), ("u16", mock_volume), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/setup_module/__init__.py b/packages/core/tests/module/setup_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/tests/module/setup_module/test_battery.py b/packages/core/tests/module/setup_module/test_battery.py new file mode 100644 index 0000000..d847388 --- /dev/null +++ b/packages/core/tests/module/setup_module/test_battery.py @@ -0,0 +1,35 @@ +import unittest + +from modi_plus.module.setup_module.battery import Battery +from modi_plus.util.message_util import parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockBattery + + +class TestBattery(unittest.TestCase): + """Tests for 'Battery' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = [-1, -1, self.connection] + self.battery = MockBattery(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.battery + + def test_get_level(self): + """Test get_level method.""" + + _ = self.battery.level + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Battery.PROPERTY_BATTERY_STATE, self.battery.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/module/setup_module/test_network.py b/packages/core/tests/module/setup_module/test_network.py new file mode 100644 index 0000000..7a7f7ca --- /dev/null +++ b/packages/core/tests/module/setup_module/test_network.py @@ -0,0 +1,222 @@ +import unittest + +from modi_plus.module.setup_module.network import Network +from modi_plus.util.message_util import parse_set_property_message, parse_get_property_message +from modi_plus.util.unittest_util import MockConnection, MockNetwork + + +class TestNetwork(unittest.TestCase): + """Tests for 'Network' class.""" + + def setUp(self): + """Set up test fixtures, if any.""" + + self.connection = MockConnection() + self.mock_kwargs = [-1, -1, self.connection] + self.network = MockNetwork(*self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + + del self.network + + def test_received_data(self): + """Test received_data method.""" + + _ = self.network.received_data(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_RECEIVE_DATA, self.network.prop_samp_freq) + ) + self.assertEqual(_, 0.0) + + def test_button_pressed(self): + """Test button_pressed method.""" + + _ = self.network.button_pressed(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_BUTTON, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_button_clicked(self): + """Test button_clicked method.""" + + _ = self.network.button_clicked(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_BUTTON, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_button_double_clicked(self): + """Test button_double_clicked method.""" + + _ = self.network.button_double_clicked(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_BUTTON, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_switch_toggled(self): + """Test switch_toggled method.""" + + _ = self.network.switch_toggled(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_SWITCH, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_dial_turn(self): + """Test dial_turn method.""" + + _ = self.network.dial_turn(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_DIAL, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_joystick_direction(self): + """Test joystick_direction method.""" + + _ = self.network.joystick_direction(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_JOYSTICK, self.network.prop_samp_freq) + ) + self.assertEqual(_, "unpressed") + + def test_slider_position(self): + """Test slider_position method.""" + + _ = self.network.slider_position(0) + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_SLIDER, self.network.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_time_up(self): + """Test time_up method.""" + + _ = self.network.time_up + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_TIMER, self.network.prop_samp_freq) + ) + self.assertEqual(_, False) + + def test_imu_roll(self): + """Test imu_roll method.""" + + _ = self.network.imu_roll + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_IMU, self.network.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_imu_pitch(self): + """Test imu_pitch method.""" + + _ = self.network.imu_pitch + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_IMU, self.network.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_imu_yaw(self): + """Test imu_yaw method.""" + + _ = self.network.imu_yaw + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_IMU, self.network.prop_samp_freq) + ) + self.assertEqual(_, 0) + + def test_imu_direction(self): + """Test imu_direction method.""" + + _ = self.network.imu_direction + self.assertEqual( + self.connection.send_list[0], + parse_get_property_message(-1, Network.PROPERTY_NETWORK_IMU_DIRECTION, self.network.prop_samp_freq) + ) + self.assertEqual(_, "origin") + + def test_send_data(self): + """Test send_data method.""" + + data = 123 + self.network.send_data(0, data) + set_message = parse_set_property_message( + -1, Network.PROPERTY_NETWORK_SEND_DATA, + (("s32", data),) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_send_text(self): + """Test send_text method.""" + + text = "MODI+" + self.network.send_text(text) + set_message = parse_set_property_message( + -1, Network.PROPERTY_NETWORK_SEND_TEXT, + (("string", text), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_buzzer_on(self): + """Test buzzer_on method.""" + + self.network.buzzer_on() + set_message = parse_set_property_message( + -1, Network.PROPERTY_NETWORK_BUZZER, + (("u8", Network.STATE_BUZZER_ON), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_buzzer_off(self): + """Test buzzer_off method.""" + + self.network.buzzer_off() + set_message = parse_set_property_message( + -1, Network.PROPERTY_NETWORK_BUZZER, + (("u8", Network.STATE_BUZZER_OFF), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + def test_take_picture(self): + """Test take_picture method.""" + + self.network.take_picture() + set_message = parse_set_property_message( + -1, Network.PROPERTY_NETWORK_CAMERA, + (("u8", Network.STATE_CAMERA_PICTURE), ) + ) + sent_messages = [] + while self.connection.send_list: + sent_messages.append(self.connection.send_list.pop()) + self.assertTrue(set_message in sent_messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/task/__init__.py b/packages/core/tests/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/tests/task/test_conn_task.py b/packages/core/tests/task/test_conn_task.py new file mode 100644 index 0000000..5aeeef4 --- /dev/null +++ b/packages/core/tests/task/test_conn_task.py @@ -0,0 +1,21 @@ +import unittest +# from unittest import mock + +from modi_plus.task.connection_task import ConnectionTask + + +class TestConnTask(unittest.TestCase): + """Tests for 'ConnTask' class""" + + def setUp(self): + """Set up test fixtures, if any.""" + self.mock_kwargs = {"serialport_recv_q": None, "serialport_send_q": None} + self.connection_task = ConnectionTask(**self.mock_kwargs) + + def tearDown(self): + """Tear down test fixtures, if any.""" + del self.connection_task + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/core/tests/task/test_serialport_task.py b/packages/core/tests/task/test_serialport_task.py new file mode 100644 index 0000000..2bb12e0 --- /dev/null +++ b/packages/core/tests/task/test_serialport_task.py @@ -0,0 +1,48 @@ +import unittest + +from unittest import mock + +from modi_plus.task.serialport_task import SerialportTask + + +class TestSerialportTask(unittest.TestCase): + """Tests for 'SerialportTask' class""" + class MockSerial: + def __init__(self): + self.in_waiting = 1 + self.write = mock.Mock() + self.close = mock.Mock() + + def read_mock(self): + self.in_waiting = 0 + return "complete" + + def setUp(self): + """Set up test fixtures, if any.""" + self.serialport_task = SerialportTask() + + def tearDown(self): + """Tear down test fixtures, if any.""" + del self.serialport_task + + def test_close_conn(self): + """Test close_conn method""" + self.serialport_task._bus = self.MockSerial() + self.serialport_task.close_connection() + self.serialport_task.bus.close.assert_called_once_with() + + def test_recv_data(self): + """Test _read_data method""" + self.serialport_task._bus = self.MockSerial() + self.serialport_task._recv_queue.put("complete") + self.assertEqual(self.serialport_task.recv(), "complete") + + def test_send_data(self): + """Test _write_data method""" + self.serialport_task._bus = self.MockSerial() + self.serialport_task.send("foo") + self.serialport_task._bus.write.assert_called_once_with("foo".encode()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/web/LICENSE b/packages/web/LICENSE new file mode 100644 index 0000000..8a81302 --- /dev/null +++ b/packages/web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LUXROBO + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000..64ef318 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,139 @@ +# pymodi-plus-web + +MODI+ Python Library for Web/Pyodide environments. + +This package provides a `PostMessageTask` communication layer that enables `pymodi-plus` to work in web browsers via Pyodide, communicating with WebUSB through JavaScript postMessage. + +## Installation + +### In Pyodide (Browser) + +```python +import micropip +await micropip.install('pymodi-plus-web') +``` + +### Local Development + +```bash +pip install pymodi-plus-web +``` + +### 내부 테스트 설치 + +#### TestPyPI에서 설치 +```bash +pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pymodi-plus-web +``` + +#### Git에서 직접 설치 +```bash +# feature/web-support 브랜치에서 설치 +pip install git+https://github.com/LUXROBO/pymodi-plus.git@feature/web-support#subdirectory=packages/web +``` + +#### 로컬 wheel 파일에서 설치 +```bash +pip install pymodi_plus_web-0.1.0-py3-none-any.whl +``` + +## Usage + +### Basic Usage (Pyodide) + +```python +from modi_plus_web import MODIPlusWeb + +# Create MODI+ instance with postMessage communication +modi = MODIPlusWeb() + +# Use standard MODI+ API +led = modi.led(0x1234) +led.rgb = (255, 0, 0) +``` + +### With JavaScript Integration + +```javascript +// JavaScript side +const pyodide = await loadPyodide(); +await pyodide.loadPackage('micropip'); +await pyodide.runPythonAsync(` + import micropip + await micropip.install('pymodi-plus-web') + + from modi_plus_web import MODIPlusWeb + modi = MODIPlusWeb() +`); + +// Send data from WebUSB to Python +window.addEventListener('message', (e) => { + if (e.data.type === 'modi_packet') { + pyodide.runPython(`modi.task.on_message('${JSON.stringify(e.data.payload)}')`); + } +}); + +// Set up Python to JavaScript callback +pyodide.runPython(` + import js + def send_to_webusb(pkt): + js.parent.postMessage({'type': 'modi_packet', 'payload': pkt}, '*') + modi.set_send_callback(send_to_webusb) +`); +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌─────────────────┐ postMessage ┌─────────────┐ │ +│ │ modi_flutter │ <──────────────────> │ iframe │ │ +│ │ (WebUSB) │ JSON packets │ │ │ +│ │ │ │ ┌───────┐ │ │ +│ │ MODI Hardware │ │ │Pyodide│ │ │ +│ │ Connection │ │ │pymodi │ │ │ +│ │ │ │ │-plus │ │ │ +│ └─────────────────┘ │ │-web │ │ │ +│ │ └───────┘ │ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## API Reference + +### MODIPlusWeb + +Inherits from `modi_plus.MODIPlus` with `PostMessageTask` as the default communication layer. + +```python +class MODIPlusWeb(MODIPlus): + def set_send_callback(self, callback): ... + def on_message(self, data): ... +``` + +### PostMessageTask + +Communication task for JavaScript postMessage integration. + +```python +class PostMessageTask(ConnectionTask): + def set_send_callback(self, callback): ... + def on_message(self, data): ... + def send(self, pkt): ... + def recv(self): ... +``` + +## Requirements + +- Python >= 3.8 +- pymodi-plus (automatically installed) + +## License + +MIT License - see LICENSE file for details. + +## Related Projects + +- [pymodi-plus](https://github.com/LUXROBO/pymodi-plus) - Desktop version with USB/BLE support +- [modi_flutter](https://github.com/LUXROBO/modi_flutter) - Flutter app with WebUSB support diff --git a/packages/web/modi_plus_web/__init__.py b/packages/web/modi_plus_web/__init__.py new file mode 100644 index 0000000..80e1e6a --- /dev/null +++ b/packages/web/modi_plus_web/__init__.py @@ -0,0 +1,13 @@ +"""pymodi-plus-web - MODI+ Python Library for Web/Pyodide + +This package provides web-compatible communication layer for pymodi-plus, +enabling MODI+ hardware control through JavaScript postMessage in Pyodide environments. +""" + +from modi_plus_web.modi_plus_web import MODIPlusWeb +from modi_plus_web.task.postmessage_task import PostMessageTask + +__version__ = "0.1.0" +__all__ = ["MODIPlusWeb", "PostMessageTask"] + +print(f"Running PyMODI+ Web (v{__version__})") diff --git a/packages/web/modi_plus_web/modi_plus_web.py b/packages/web/modi_plus_web/modi_plus_web.py new file mode 100644 index 0000000..a6bbc04 --- /dev/null +++ b/packages/web/modi_plus_web/modi_plus_web.py @@ -0,0 +1,70 @@ +"""MODIPlusWeb - 웹 환경용 MODI+ 클래스 + +pymodi-plus의 MODIPlus를 상속하여 PostMessageTask를 기본 통신 레이어로 사용합니다. +Pyodide 환경에서 JavaScript와 postMessage를 통해 MODI+ 모듈과 통신할 수 있습니다. +""" + +from typing import Callable, Optional + +from modi_plus import MODIPlus +from modi_plus_web.task.postmessage_task import PostMessageTask + + +class MODIPlusWeb(MODIPlus): + """웹 환경용 MODI+ 클래스 + + PostMessageTask를 사용하여 JavaScript와 postMessage로 통신합니다. + modi_flutter의 WebUSB와 연동하여 MODI+ 모듈을 제어할 수 있습니다. + + Usage: + from modi_plus_web import MODIPlusWeb + + modi = MODIPlusWeb() + + # JavaScript 콜백 설정 + import js + modi.set_send_callback(lambda pkt: js.postMessage({'type': 'modi', 'data': pkt})) + + # 표준 MODI+ API 사용 + led = modi.led(0x1234) + led.rgb = (255, 0, 0) + """ + + def __init__(self, verbose: bool = False): + """MODIPlusWeb 초기화 + + Args: + verbose: 디버그 메시지 출력 여부 + """ + # PostMessageTask를 주입하여 부모 클래스 초기화 + self._postmessage_task = PostMessageTask(verbose=verbose) + super().__init__(task=self._postmessage_task, verbose=verbose) + + @property + def task(self) -> PostMessageTask: + """PostMessageTask 인스턴스 접근""" + return self._postmessage_task + + def set_send_callback(self, callback: Callable[[str], None]) -> None: + """JavaScript로 메시지를 전송할 콜백 함수 설정 + + Args: + callback: 메시지를 받아 JavaScript로 전송하는 함수 + + Example: + import js + modi.set_send_callback(lambda pkt: js.postMessage({'type': 'modi', 'data': pkt})) + """ + self._postmessage_task.set_send_callback(callback) + + def on_message(self, data) -> None: + """JavaScript에서 메시지 수신 시 호출 + + Args: + data: 수신된 데이터 (JSON 문자열 또는 dict) + + Example: + # JavaScript에서 + pyodide.runPython(`modi.on_message('${jsonData}')`) + """ + self._postmessage_task.on_message(data) diff --git a/packages/web/modi_plus_web/task/__init__.py b/packages/web/modi_plus_web/task/__init__.py new file mode 100644 index 0000000..022879a --- /dev/null +++ b/packages/web/modi_plus_web/task/__init__.py @@ -0,0 +1,5 @@ +"""MODI+ Web Task module - postMessage 통신 레이어""" + +from modi_plus_web.task.postmessage_task import PostMessageTask + +__all__ = ["PostMessageTask"] diff --git a/packages/web/modi_plus_web/task/postmessage_task.py b/packages/web/modi_plus_web/task/postmessage_task.py new file mode 100644 index 0000000..b4f97da --- /dev/null +++ b/packages/web/modi_plus_web/task/postmessage_task.py @@ -0,0 +1,143 @@ +"""PostMessageTask - JavaScript postMessage 통신용 Task + +Pyodide 환경에서 JavaScript와 postMessage를 통해 통신하는 Task 클래스입니다. +modi_flutter의 WebUSB와 연동하여 MODI+ 모듈과 통신할 수 있습니다. +""" + +from queue import Queue +from typing import Optional, Callable +import json + +from modi_plus.task.connection_task import ConnectionTask + + +class PostMessageTask(ConnectionTask): + """JavaScript postMessage 통신용 Task + + Pyodide 환경에서 JavaScript와 postMessage를 통해 통신합니다. + + Usage: + task = PostMessageTask() + + # JavaScript에서 Python으로 메시지 전달 + task.on_message('{"c":0,"s":1,"d":2,"b":"AA==","l":1}') + + # Python에서 JavaScript로 메시지 전달 + task.set_send_callback(lambda pkt: js.postMessage(pkt)) + task.send('{"c":0,"s":1,"d":2,"b":"AA==","l":1}') + """ + + def __init__(self, verbose: bool = False): + """PostMessageTask 초기화 + + Args: + verbose: 디버그 메시지 출력 여부 + """ + super().__init__(verbose) + self._recv_queue: Queue = Queue() + self._send_callback: Optional[Callable[[str], None]] = None + self._connected: bool = False + + def set_send_callback(self, callback: Callable[[str], None]) -> None: + """JavaScript로 메시지를 전송할 콜백 함수 설정 + + Args: + callback: 메시지를 받아 JavaScript로 전송하는 함수 + + Example: + # Pyodide에서 + import js + task.set_send_callback(lambda pkt: js.postMessage({'type': 'modi', 'data': pkt})) + """ + self._send_callback = callback + + def on_message(self, data) -> None: + """JavaScript에서 메시지 수신 시 호출 + + Args: + data: 수신된 데이터 (JSON 문자열 또는 dict) + + Example: + # JavaScript에서 + pyodide.runPython(`task.on_message('${jsonData}')`) + """ + if isinstance(data, dict): + data = json.dumps(data) + elif isinstance(data, str): + # 이미 JSON 문자열인 경우 그대로 사용 + try: + # 유효한 JSON인지 확인 + json.loads(data) + except json.JSONDecodeError: + if self.verbose: + print(f"Invalid JSON received: {data}") + return + else: + if self.verbose: + print(f"Unsupported data type: {type(data)}") + return + + self._recv_queue.put(data) + + if self.verbose: + print(f"on_message: {data}") + + def open_connection(self) -> None: + """연결 시작 (JavaScript 측에서 WebUSB 연결 처리)""" + self._connected = True + self._recv_queue = Queue() + if self.verbose: + print("PostMessageTask connection opened") + + def close_connection(self) -> None: + """연결 종료""" + self._connected = False + self._send_callback = None + if self.verbose: + print("PostMessageTask connection closed") + + def recv(self) -> Optional[str]: + """수신 큐에서 메시지 가져오기 + + Returns: + JSON 문자열 또는 None (큐가 비어있는 경우) + """ + if self._recv_queue.empty(): + return None + + json_pkt = self._recv_queue.get() + + if self.verbose: + print(f"recv: {json_pkt}") + + return json_pkt + + def send(self, pkt: str, verbose: bool = False) -> None: + """JavaScript로 메시지 전송 + + Args: + pkt: 전송할 JSON 패킷 + verbose: 디버그 메시지 출력 여부 + """ + if self._send_callback: + self._send_callback(pkt) + + if self.verbose or verbose: + print(f"send: {pkt}") + else: + if self.verbose or verbose: + print(f"send (no callback): {pkt}") + + def send_nowait(self, pkt: str, verbose: bool = False) -> None: + """JavaScript로 메시지 전송 (send와 동일) + + Args: + pkt: 전송할 JSON 패킷 + verbose: 디버그 메시지 출력 여부 + """ + self.send(pkt, verbose) + + @property + def is_connected(self) -> bool: + """연결 상태 확인""" + return self._connected diff --git a/packages/web/pytest.ini b/packages/web/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/packages/web/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/packages/web/setup.py b/packages/web/setup.py new file mode 100644 index 0000000..e202851 --- /dev/null +++ b/packages/web/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="pymodi-plus-web", + version="0.1.0", + author="LUXROBO", + author_email="tech@luxrobo.com", + description="MODI+ Python Library for Web/Pyodide - postMessage communication", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/LUXROBO/pymodi-plus-web", + packages=find_packages(), + install_requires=[ + "pymodi-plus", # 코어 재사용 (serial/ble 없이 설치됨) + ], + python_requires=">=3.8", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Environment :: Web Environment", + "Topic :: Education", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords=["modi", "modi-plus", "pyodide", "web", "webusb", "postmessage"], +) diff --git a/packages/web/test_coding.html b/packages/web/test_coding.html new file mode 100644 index 0000000..bf02fcf --- /dev/null +++ b/packages/web/test_coding.html @@ -0,0 +1,621 @@ + + + + + + PyMODI+ Web - Python Coding + + + +
+

PyMODI+ Web Coding

+
+
+
+ Pyodide +
+
+
+ Flutter +
+
+
+ MODI+ +
+
+
+ +
+
+
+ Python Editor + +
+ +
+ + + + + +
+
+ +
+
Output
+
+ No modules +
+
+
+
+ +
+
+ Communication Log + +
+
+
+
+
+ + + + diff --git a/packages/web/test_flutter_integration.html b/packages/web/test_flutter_integration.html new file mode 100644 index 0000000..b01adbb --- /dev/null +++ b/packages/web/test_flutter_integration.html @@ -0,0 +1,724 @@ + + + + + + PyMODI+ Web - Flutter Integration + + + + +
+

PyMODI+ Web - Flutter Integration

+
+
+
+ Pyodide +
+
+
+ MODI+ +
+
+
+ Flutter +
+
+
+ +
+
+
Python Code Editor (Ctrl+Enter to run)
+ +
+ + + + +
+
+ +
+
Communication Log
+
+ Connected Modules: +
+ Waiting for connection... +
+
+
+
+
+ + + + diff --git a/packages/web/test_pyodide.html b/packages/web/test_pyodide.html new file mode 100644 index 0000000..3848668 --- /dev/null +++ b/packages/web/test_pyodide.html @@ -0,0 +1,742 @@ + + + + + pymodi-plus-web Pyodide IDE + + + + +
+

pymodi-plus-web IDE

+
Loading Pyodide...
+
+ +
+
+

Python Editor - pymodi-plus 스타일

+
+ + + + + +
+
+
1
+ +
+
+ + + +
+
+ +
+

Output

+

+        
+ +
+

Hardware Simulation (WebUSB 시뮬레이션)

+
+
+
LED
+
ID: 0x1001
+
+
+
+
Button
+
ID: 0x2001
+
OFF
+ +
+
+
Dial
+
ID: 0x3001
+
0
+ +
+
+
ENV
+
ID: 0x4001
+
25°C
+
+
+
하드웨어 통신 로그...
+
+ +
+

Communication Log (Python ↔ JS)

+
+ + +
+
패킷 통신 로그가 여기에 표시됩니다...
+
+
+ + + + diff --git a/packages/web/test_simple.html b/packages/web/test_simple.html new file mode 100644 index 0000000..6c8a4da --- /dev/null +++ b/packages/web/test_simple.html @@ -0,0 +1,63 @@ + + + + + PyMODI+ Simple Test + + + +

PyMODI+ Web - Simple Test

+
Loading Pyodide...
+

+
+    
+
+
diff --git a/packages/web/test_standalone.html b/packages/web/test_standalone.html
new file mode 100644
index 0000000..25ddb60
--- /dev/null
+++ b/packages/web/test_standalone.html
@@ -0,0 +1,172 @@
+
+
+
+    
+    PyMODI+ Web - Standalone Test (Mock Mode)
+    
+
+
+    

PyMODI+ Web - Standalone Test (Mock Mode)

+

This test runs without Flutter, using mock modules for testing.

+ +
+

Code

+ +
+ + +
+
+ +
+

Output

+
+
+ + + + + diff --git a/packages/web/tests/__init__.py b/packages/web/tests/__init__.py new file mode 100644 index 0000000..913e766 --- /dev/null +++ b/packages/web/tests/__init__.py @@ -0,0 +1 @@ +# pymodi-plus-web tests diff --git a/packages/web/tests/test_modi_plus_web.py b/packages/web/tests/test_modi_plus_web.py new file mode 100644 index 0000000..ff493de --- /dev/null +++ b/packages/web/tests/test_modi_plus_web.py @@ -0,0 +1,93 @@ +"""MODIPlusWeb 단위 테스트""" + +import unittest +from unittest.mock import MagicMock, patch +import json + +from modi_plus_web.task.postmessage_task import PostMessageTask +from modi_plus_web.modi_plus_web import MODIPlusWeb + + +class TestMODIPlusWeb(unittest.TestCase): + """MODIPlusWeb 클래스 테스트""" + + def setUp(self): + """각 테스트 전 실행 - MODIPlus import 모킹""" + # MODIPlus가 하드웨어 연결을 시도하지 않도록 모킹 + self.mock_modi_plus_patcher = patch('modi_plus_web.modi_plus_web.MODIPlus.__init__') + self.mock_modi_plus = self.mock_modi_plus_patcher.start() + self.mock_modi_plus.return_value = None + + def tearDown(self): + """각 테스트 후 실행""" + self.mock_modi_plus_patcher.stop() + + def test_initialization(self): + """초기화 테스트""" + modi = MODIPlusWeb() + + self.assertIsNotNone(modi._postmessage_task) + self.assertIsInstance(modi._postmessage_task, PostMessageTask) + + def test_task_property(self): + """task 프로퍼티 테스트""" + modi = MODIPlusWeb() + + self.assertIsInstance(modi.task, PostMessageTask) + self.assertEqual(modi.task, modi._postmessage_task) + + def test_set_send_callback(self): + """set_send_callback 메서드 테스트""" + modi = MODIPlusWeb() + + callback = MagicMock() + modi.set_send_callback(callback) + + self.assertEqual(modi._postmessage_task._send_callback, callback) + + def test_on_message(self): + """on_message 메서드 테스트""" + modi = MODIPlusWeb() + + test_data = '{"c":0,"s":1,"d":2}' + modi.on_message(test_data) + + received = modi._postmessage_task.recv() + self.assertEqual(received, test_data) + + def test_verbose_mode(self): + """verbose 모드 전달 테스트""" + modi = MODIPlusWeb(verbose=True) + + self.assertTrue(modi._postmessage_task.verbose) + + +class TestMODIPlusWebIntegration(unittest.TestCase): + """MODIPlusWeb 통합 테스트 (모킹 없이)""" + + @patch('modi_plus_web.modi_plus_web.MODIPlus.__init__') + def test_full_workflow(self, mock_init): + """전체 워크플로우 테스트""" + mock_init.return_value = None + + modi = MODIPlusWeb() + + # 콜백 설정 + sent_packets = [] + modi.set_send_callback(lambda pkt: sent_packets.append(pkt)) + + # 외부 메시지 수신 + modi.on_message('{"c":0,"s":100,"d":0}') + + # 내부에서 처리 + received = modi.task.recv() + self.assertIsNotNone(received) + + # 응답 전송 + modi.task.send('{"c":4,"s":0,"d":100}') + + self.assertEqual(len(sent_packets), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/packages/web/tests/test_postmessage_task.py b/packages/web/tests/test_postmessage_task.py new file mode 100644 index 0000000..5b04e50 --- /dev/null +++ b/packages/web/tests/test_postmessage_task.py @@ -0,0 +1,195 @@ +"""PostMessageTask 단위 테스트""" + +import unittest +import json +from modi_plus_web.task.postmessage_task import PostMessageTask + + +class TestPostMessageTask(unittest.TestCase): + """PostMessageTask 클래스 테스트""" + + def setUp(self): + """각 테스트 전 실행""" + self.task = PostMessageTask() + + def tearDown(self): + """각 테스트 후 실행""" + self.task.close_connection() + + # =================== + # 연결 테스트 + # =================== + def test_initial_state(self): + """초기 상태 테스트""" + self.assertFalse(self.task.is_connected) + self.assertIsNone(self.task._send_callback) + self.assertTrue(self.task._recv_queue.empty()) + + def test_open_connection(self): + """연결 열기 테스트""" + self.task.open_connection() + self.assertTrue(self.task.is_connected) + + def test_close_connection(self): + """연결 닫기 테스트""" + self.task.open_connection() + self.task.close_connection() + self.assertFalse(self.task.is_connected) + self.assertIsNone(self.task._send_callback) + + # =================== + # 메시지 수신 테스트 + # =================== + def test_on_message_with_string(self): + """JSON 문자열 수신 테스트""" + self.task.open_connection() + test_msg = '{"c":0,"s":1,"d":2,"b":"AA==","l":1}' + + self.task.on_message(test_msg) + + self.assertFalse(self.task._recv_queue.empty()) + received = self.task.recv() + self.assertEqual(received, test_msg) + + def test_on_message_with_dict(self): + """딕셔너리 수신 테스트""" + self.task.open_connection() + test_data = {"c": 0, "s": 1, "d": 2, "b": "AA==", "l": 1} + + self.task.on_message(test_data) + + received = self.task.recv() + self.assertEqual(json.loads(received), test_data) + + def test_on_message_invalid_json(self): + """유효하지 않은 JSON 테스트""" + self.task.open_connection() + self.task.verbose = True # 디버그 모드 + + self.task.on_message("invalid json {{{") + + # 유효하지 않은 JSON은 무시됨 + self.assertTrue(self.task._recv_queue.empty()) + + def test_recv_empty_queue(self): + """빈 큐에서 수신 테스트""" + self.task.open_connection() + received = self.task.recv() + self.assertIsNone(received) + + def test_recv_multiple_messages(self): + """다중 메시지 수신 테스트""" + self.task.open_connection() + messages = [ + '{"c":0,"s":1}', + '{"c":1,"s":2}', + '{"c":2,"s":3}', + ] + + for msg in messages: + self.task.on_message(msg) + + for expected in messages: + received = self.task.recv() + self.assertEqual(received, expected) + + # 큐가 비어있어야 함 + self.assertIsNone(self.task.recv()) + + # =================== + # 메시지 전송 테스트 + # =================== + def test_send_without_callback(self): + """콜백 없이 전송 테스트""" + self.task.open_connection() + # 콜백 없이 send해도 에러 없어야 함 + self.task.send('{"test": 1}') + + def test_send_with_callback(self): + """콜백 있을 때 전송 테스트""" + self.task.open_connection() + sent_messages = [] + + def mock_callback(pkt): + sent_messages.append(pkt) + + self.task.set_send_callback(mock_callback) + + test_pkt = '{"c":4,"s":0,"d":1234}' + self.task.send(test_pkt) + + self.assertEqual(len(sent_messages), 1) + self.assertEqual(sent_messages[0], test_pkt) + + def test_send_nowait(self): + """send_nowait 테스트 (send와 동일 동작)""" + self.task.open_connection() + sent_messages = [] + + self.task.set_send_callback(lambda pkt: sent_messages.append(pkt)) + self.task.send_nowait('{"test": 1}') + + self.assertEqual(len(sent_messages), 1) + + # =================== + # 콜백 테스트 + # =================== + def test_set_send_callback(self): + """콜백 설정 테스트""" + callback = lambda x: x + self.task.set_send_callback(callback) + self.assertEqual(self.task._send_callback, callback) + + def test_callback_cleared_on_close(self): + """연결 종료 시 콜백 초기화 테스트""" + self.task.set_send_callback(lambda x: x) + self.task.open_connection() + self.task.close_connection() + self.assertIsNone(self.task._send_callback) + + # =================== + # verbose 모드 테스트 + # =================== + def test_verbose_mode(self): + """verbose 모드 테스트""" + task = PostMessageTask(verbose=True) + self.assertTrue(task.verbose) + + task2 = PostMessageTask(verbose=False) + self.assertFalse(task2.verbose) + + +class TestPostMessageTaskIntegration(unittest.TestCase): + """PostMessageTask 통합 테스트""" + + def test_full_communication_cycle(self): + """전체 통신 사이클 테스트""" + task = PostMessageTask() + task.open_connection() + + # 콜백 설정 + responses = [] + task.set_send_callback(lambda pkt: responses.append(pkt)) + + # 메시지 수신 (외부에서 온 것 시뮬레이션) + incoming = {"c": 0, "s": 100, "d": 0, "b": "VEVTVA==", "l": 4} + task.on_message(incoming) + + # 메시지 처리 + received = task.recv() + data = json.loads(received) + + # 응답 전송 + response = {"c": 4, "s": 0, "d": data["s"], "b": "T0s=", "l": 2} + task.send(json.dumps(response)) + + # 검증 + self.assertEqual(len(responses), 1) + sent_data = json.loads(responses[0]) + self.assertEqual(sent_data["d"], 100) # 원래 소스로 응답 + + task.close_connection() + + +if __name__ == '__main__': + unittest.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f803ce4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +# PyMODI+ Monorepo Configuration +[project] +name = "pymodi-plus-monorepo" +description = "PyMODI+ Monorepo - Core library and Web extension" +readme = "README.md" +requires-python = ">=3.7" + +[tool.pytest.ini_options] +testpaths = [ + "packages/core/tests", + "packages/web/tests", +] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" + +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] + +# Monorepo packages configuration +# Each package has its own setup.py for independent publishing +# - packages/core/ -> pymodi-plus (PyPI) +# - packages/web/ -> pymodi-plus-web (PyPI) diff --git a/setup.py b/setup.py index 975d7de..e386ec0 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,37 @@ def wrapper(): get_about = get_spec("./modi_plus/about.py", "d") get_readme = get_spec("README.md") get_history = get_spec("HISTORY.md") -get_requirements = get_spec("requirements.txt") get_requirements_dev = get_spec("requirements-dev.txt") +# 최소 의존성 (Pyodide 호환) +INSTALL_REQUIRES = [ + 'nest-asyncio>=1.5.4', + 'packaging>=21.3', +] + +# Optional 의존성 +EXTRAS_REQUIRE = { + 'serial': [ + 'pyserial>=3.5', + ], + 'ble': [ + 'bleak>=0.13.0; sys_platform == "win32"', + 'bleak>=0.13.0; sys_platform == "darwin"', + ], + 'websocket': [ + 'websocket-client>=1.2.3', + ], + 'all': [ + 'pyserial>=3.5', + 'bleak>=0.13.0; sys_platform == "win32"', + 'bleak>=0.13.0; sys_platform == "darwin"', + 'websocket-client>=1.2.3', + 'winusbcdc>=1.4; sys_platform == "win32"', + 'pexpect; sys_platform == "linux"', + ], +} +EXTRAS_REQUIRE['dev'] = get_requirements_dev() + about = get_about() setup( name=about["__title__"], @@ -31,8 +59,8 @@ def wrapper(): description=about["__summary__"], long_description=get_readme() + "\n" + get_history(), long_description_content_type="text/markdown", - install_requires=get_requirements(), - extras_require={"dev": get_requirements_dev()}, + install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, license=about["__license__"], include_package_data=True, keywords=["python", "modi", "modi-plus", "modi_plus", "modi+"],