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 @@
[](https://pymodi-plus.readthedocs.io/en/latest/?badge=master)
[](https://github.com/LUXROBO/pymodi-plus/actions)
[](https://github.com/LUXROBO/pymodi-plus/blob/master/LICENSE)
-[](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|
-|:---:|:---:|
-| [](https://github.com/LUXROBO/pymodi-plus/actions) | [](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 | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions)
-| Mac OS | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions)
-| Windows | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](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]
-[](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 @@
+
+
+[](https://pypi.python.org/pypi/pymodi-plus)
+[](https://pypi.python.org/pypi/pymodi-plus)
+[](https://pymodi-plus.readthedocs.io/en/latest/?badge=master)
+[](https://github.com/LUXROBO/pymodi-plus/actions)
+[](https://github.com/LUXROBO/pymodi-plus/blob/master/LICENSE)
+[](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|
+|:---:|:---:|
+| [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions)
+
+System Support
+--------------
+| System | 3.7 | 3.8 | 3.9 | 3.10 | 3.11 |
+| :---: | :---: | :---: | :---: | :---: | :---: |
+| Linux | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions)
+| Mac OS | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions)
+| Windows | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](https://github.com/LUXROBO/pymodi-plus/actions) | [](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.
+
+[](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
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
Python Editor - pymodi-plus 스타일
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hardware Simulation (WebUSB 시뮬레이션)
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
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+"],