diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..a2ca3645 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ + + +## What type of change is this? +- [ ] New module +- [ ] Change to an existing module +- [ ] Core improvement +- [ ] Other (please describe) + +## 📝 What does this change do? + + +## ❓ Why is this change needed? + + +## 🛠️ How was this implemented? + + +## 🧪 How was this tested? + + +## 💥 Breaking changes + +- [ ] Yes (please describe) +- [ ] No + +## 🗂 Related issues + + +## ✅ PR Checklist +- [ ] **Title & Description:** PR title and description are clear and complete. +- [ ] **Documentation:** PR links to idea in github discussion group containing complete documentation. +- [ ] **Scope & Size:** PR is focused on a single issue/feature and is a reasonable size. +- [ ] **Code Quality:** Code is clean, consistent, and follows the project style guide. +- [ ] **Tests:** Tests have been added/updated if needed. +- [ ] **Manual Testing:** Changes have been tested on the latest release of the project. +- [ ] **Self-Review:** I’ve reviewed my own code and ensured there are no obvious issues. + +🚀 Thank you for your contribution to the project! diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..379ff1c6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Python Unittest on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + run-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Run unittests + run: | + python -m unittest discover -s tests -p 'test_*.py' + + permissions: + pull-requests: write + contents: read \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2cbc6415..e4d2250c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,145 +14,16 @@ battery*.csv timelapse/* .coverage -.venv/ -Adafruit_Blinka-8.28.0.dist-info/ -Adafruit_PlatformDetect-3.57.0.dist-info/ -Adafruit_PureIO-1.1.11.dist-info/ -Adafruit_PureIO/ -PIL/ -Pillow-10.1.0.dist-info/ -Pillow.libs/ -PyYAML-6.0.1.dist-info/ -Pypubsub-4.0.3.dist-info/ -RPi.GPIO-0.7.1.dist-info/ -RPi/ -_distutils_hack/ -_rpi_ws281x.cpython-311-aarch64-linux-gnu.so -_yaml/ -adafruit_blinka/ -adafruit_bus_device/ -adafruit_circuitpython_busdevice-5.2.6.dist-info/ -adafruit_circuitpython_pixelbuf-2.0.4.dist-info/ -adafruit_circuitpython_requests-2.0.3.dist-info/ -adafruit_circuitpython_seesaw-1.16.1.dist-info/ -adafruit_circuitpython_typing-1.9.6.dist-info/ -adafruit_pixelbuf.py -adafruit_platformdetect/ -adafruit_requests.py -adafruit_seesaw/ -analogio.py -bin/ -bitbangio.py -board.py -busio.py -certifi-2023.11.17.dist-info/ -certifi/ -chardet-3.0.4.dist-info/ -chardet/ -circuitpython_typing/ -click-8.1.7.dist-info/ -click/ -colorzero-2.0.dist-info/ -colorzero/ -colour-0.1.5.dist-info/ -colour.py +# storing old dependencies that aren't used anymore +dependencies.bu + data/ -digitalio.py -distutils-precedence.pth -google/ -googleapis_common_protos-1.62.0.dist-info/ -googleapis_common_protos-1.63.0.dist-info/ -googletrans-3.1.0a0.dist-info/ -googletrans/ -gpiozero-2.0.dist-info/ -gpiozero/ -gpiozerocli/ -grpclib-0.4.6.dist-info/ -grpclib/ -h11-0.9.0.dist-info/ -h11/ -h2-3.2.0.dist-info/ -h2-4.1.0.dist-info/ -h2/ -hpack-3.0.0.dist-info/ -hpack-4.0.0.dist-info/ -hpack/ -hstspreload-2023.1.1.dist-info/ -hstspreload/ -httpcore-0.9.1.dist-info/ -httpcore/ -httpx-0.13.3.dist-info/ -httpx/ -hyperframe-5.2.0.dist-info/ -hyperframe-6.0.1.dist-info/ -hyperframe/ -idna-2.10.dist-info/ -idna/ -include/ -joblib-1.3.2.dist-info/ -joblib/ -keypad.py -microcontroller/ -micropython-stubs/ -micropython.py -modular-biped-venv/ -multidict-6.0.4.dist-info/ -multidict-6.0.5.dist-info/ -multidict/ -neopixel_write.py -nltk-3.8.1.dist-info/ -nltk/ -numpy-1.26.2.dist-info/ -numpy.libs/ -numpy/ -onewireio.py -pillow-10.2.0.dist-info/ -pillow.libs/ -pkg_resources/ -protobuf-4.25.1.dist-info/ -protobuf-4.25.3.dist-info/ -pubsub/ -pulseio.py -pwmio.py -pyftdi-0.55.0.dist-info/ -pyftdi/ -pygame-2.5.2.dist-info/ -pygame.libs/ -pygame/ -pyserial-3.5.dist-info/ -pyusb-1.2.1.dist-info/ -rainbowio.py -regex-2023.10.3.dist-info/ -regex/ -rfc3986-1.5.0.dist-info/ -rfc3986/ -rpi_ws281x-5.0.0.dist-info/ -rpi_ws281x/ -run_viam.sh -schedule-1.2.1.dist-info/ -schedule/ -serial/ -setenv.py -setuptools-69.0.2.dist-info/ -setuptools/ -sniffio-1.3.0.dist-info/ -sniffio/ -sysv_ipc-1.1.0.dist-info/ -sysv_ipc.cpython-311-aarch64-linux-gnu.so -tqdm-4.66.1.dist-info/ -tqdm/ -typing_extensions-4.10.0.dist-info/ -typing_extensions-4.9.0.dist-info/ -typing_extensions.py -usb/ -usb_hid.py -venv/ -viam-config-example.json -/viam/ -viam_sdk-0.12.0.dist-info/ -viam_sdk-0.15.0.dist-info/ -yaml/ -myvenv/ .circleci/ .vscode myenv + +# installation flags and installed libraries +installers/i2s_mic_installed.flag +installers/i2s_speech_recognition_installed.flag + +speech.wav \ No newline at end of file diff --git a/README.md b/README.md index 10f60fb9..cb0d1040 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The **Modular Bipedal Robot** project aims to educate and inspire individuals in - TTS: Converts text to speech using the onboard speaker. - Viam: Uses the VIAM API to integrate Viam modules for additional functionality. - Vision: Handles image processing and computer vision tasks using the onboard IMX500 Raspberry Pi AI camera. + - Voice Recognition: Uses the Google Speech API to convert speech to text. - [Read more](https://github.com/makerforgetech/modular-biped/wiki/Software#modules)! ## Project Background @@ -41,3 +42,113 @@ The open source framework is designed for flexibility, allowing users to easily - **Code**: Check out the modular open source software on [GitHub](https://github.com/makerforgetech/modular-biped) - **YouTube Playlist**: Explore the development process through our build videos: [Watch on YouTube](https://www.youtube.com/watch?v=2DVJ5xxAuWY&list=PL_ua9QbuRTv6Kh8hiEXXVqywS8pklZraT) - **Community**: Have a question or want to show off your build? Join the communities on [GitHub](https://bit.ly/maker-forge-community) and [Discord](https://bit.ly/makerforge-community)! + + +## A Note On Branches + +The `main` branch is the latest stable release of the Modular Biped Robot project. This is compatible with the 'buddy' release, the latest supported release. + +The `develop` branch is the development branch and may contain experimental features and changes that are not yet stable. Please use the `main` branch for the most stable experience. This will eventually become the next release, 'cody'. To facilitate testing modules are disabled by default in the `develop` branch. Enable each as needed in the config yaml files to test. + +## Modules + +The 'Cody' release includes a new BaseModule class that must be extended by all modules. This class provides a common interface for all modules to interact with the main robot controller. The BaseModule class includes a messaging_service object that references the main robot controller's messaging service. This object is used to send and receive messages between modules and the main robot controller. + +Both `pypubsub` and `paho-mqtt` can be used to facilitate message passing between modules (mqtt support is not implemented yet). Which service is used can be set in the messaging_service configuration YAML file. + +```yaml +messaging_service: + enabled: true + config: + protocol: 'pubsub' # 'mqtt' or 'pubsub' + mqtt_host: 'localhost' + mqtt_port: 1883 +``` + +The introduction of mqtt allows distributed communication between modules, even across different devices. + +The methods publish() and subscribe() can be utilised from within any module to send and receive messages to topics. + +For example: + + ```python + class MyModule(BaseModule): + def __init__(self): + # Don't subscribe here + pass + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('my_topic', self.my_callback) + + def my_callback(self, message): + print(f'Received message: {message}') + self.publish('my_response_topic', 'Hello from MyModule!') + self.log(level='info', message='MyModule received a message!') + ``` +Core and common topics include: +- `log` - Used for logging messages, accepts a string. or kwargs `type` (info by default) and `message`. +- `log/info` - Used for logging informational messages. +- `log/warning` - Used for logging warning messages. +- `log/error` - Used for logging error messages. +- `log/debug` - Used for logging debug messages. +- `log/critical` - Used for logging critical messages. +- `system/loop` - The main loop event. Subscribe to this for an action to trigger every loop. +- `system/loop/1` - Triggers a loop every second. +- `system/loop/10` - Triggers a loop every 10 seconds. +- `system/loop/60` - Triggers a loop every 60 seconds. +- `system/loop/exit` - Triggers a loop exit event. +- `system/temperature` - The current temperature of the Pi. +- `motion` - Output from the motion sensor, only triggered if motion is detected. +- `speech` - Input from speech recognition module converted to text. +- `tts` - Output to be spoken by the TTS module. +- `animate` - Output to be animated by the Animation module. +- `vision/detections` - Output from the Vision module, containing detected objects. +- `led` - Output to the Neopixel LED module. + +## Logging + +An upgraded log manager has been included in this version. This allows logs to be published either via the above messaging service, or directly to a 'log' method within the BaseModule class. The log manager can be configured in the `logwrapper.yaml` file. + +```yaml +logwrapper: + enabled: true # Highly recommended to enable this module + path: modules.logwrapper.LogWrapper + config: + filename: app.log + log_level: 'debug' # debug, info, warning, error, critical + cli_level: 'info' # debug, info, warning, error, critical + dependencies: + python: + - pypubsub +``` + +Both the log level of the app.log file and the output to the CLI during runtime can be determined and defined separately. For any log level set, logs of a level equal to or above that level will be output. For example, if the log level is set to 'info', all logs of level 'info', 'warning', 'error', and 'critical' will be output. + +Example usage: + +```python +class MyModule(BaseModule): + def my_method(self): + self.log(level='info', message='MyModule has been initialised!') + self.log(f"Current value: {value}") + self.log(message=f"Current value is too high: {value}", level='critical') +``` + +In addition, for modules that extend BaseModule, the class, method and line number are prefixed to the message for output, making it easier to track down where the log was generated. + +``` +log/info: [Personality.random_neopixel_status:117] [Personality] Neopixel status triggered set to green +log/info: [PiTemperature.monitor:30] Temperature: 45.5°C +log/critical: [PiTemperature.monitor:28] Temperature is critical: 45.5°C +``` + +The app.log also includes timestamps and log levels for easy reference. + +``` +INFO: 01/30/2025 12:05:26 PM [Main] Loop started using pubsub protocol +INFO: 01/30/2025 12:05:27 PM [Personality.random_neopixel_status:117] [Personality] Neopixel status triggered set to green +INFO: 01/30/2025 12:05:27 PM [PiTemperature.monitor:30] Temperature: 42.2°C +INFO: 01/30/2025 12:05:28 PM [PiTemperature.monitor:30] Temperature: 42.2°C +INFO: 01/30/2025 12:05:28 PM [Main] Loop ended +``` \ No newline at end of file diff --git a/circuits/CM5 Modules.drawio.svg b/circuits/CM5 Modules.drawio.svg new file mode 100644 index 00000000..14308a90 --- /dev/null +++ b/circuits/CM5 Modules.drawio.svg @@ -0,0 +1,4 @@ + + + +Raspberry PiCompute Module 5Raspberry Pi...BreakoutBreakoutRCWL-0516RCWL-0516GPIOGPIOBreakoutBreakoutI2CI2CBreakoutBreakoutUARTUARTTFT DisplayTFT DisplayNeopixelsNeopixelsSPISPISPH0645 MEMSMicrophoneSPH0645 MEMS...MAX98347AmplifierMAX98347...I2SI2SCamera (x2)Camera (x2)CSICSIUSB3 PortUSB3 PortUSBUSBServoServoGPIO 7,8,9,10,11GPIO 7,8,9,10,11GPIO 2,3GPIO 2,3GPIO 14,15GPIO 14,15GPIO 18,19,20,21GPIO 18,19,20,21GPIO (all remaining)GPIO (all remaining)PCA9865 ServoControllerPCA9865 Servo...Neopixel ControllerNeopixel ControllerArduino Controller BoardArduino Controller B...Serial Bus Servo Controller BoardSerial Bus Servo Con...Text is not SVG - cannot display \ No newline at end of file diff --git a/config/animate.yml b/config/animate.yml index 6d370931..216ba289 100644 --- a/config/animate.yml +++ b/config/animate.yml @@ -1,6 +1,3 @@ animate: - enabled: true - path: modules.animate.Animate - dependencies: - python: - - pypubsub \ No newline at end of file + enabled: false + path: modules.animate.Animate \ No newline at end of file diff --git a/config/braillespeak.yml b/config/braillespeak.yml index 0477ad14..45fa6dfe 100644 --- a/config/braillespeak.yml +++ b/config/braillespeak.yml @@ -2,7 +2,4 @@ braillespeak: enabled: false path: modules.audio.braillespeak.BrailleSpeak config: - pin: 27 - dependencies: - python: - - pypubsub \ No newline at end of file + pin: 27 \ No newline at end of file diff --git a/config/buzzer.yml b/config/buzzer.yml index a0dd5862..c69a77e7 100644 --- a/config/buzzer.yml +++ b/config/buzzer.yml @@ -7,4 +7,3 @@ buzzer: dependencies: python: - gpiozero - - pypubsub diff --git a/config/chatgpt.yml b/config/chatgpt.yml index 4e8d3306..607657ee 100644 --- a/config/chatgpt.yml +++ b/config/chatgpt.yml @@ -3,9 +3,12 @@ chatgpt: path: 'modules.chatgpt.ChatGPT' config: model: gpt-4o-mini + persona: "You are a helpful assistant robot. You respond with short phrases where possible. + Alternatively, you can respond with the following commands instead of text if you feel they are appropriate: + - animate:head_nod + - animate:head_shake" dependencies: python: - openai - - pypubsub additional: - https://platform.openai.com/api-keys \ No newline at end of file diff --git a/config/i2c_servo.yml b/config/i2c_servo.yml new file mode 100644 index 00000000..fab21bad --- /dev/null +++ b/config/i2c_servo.yml @@ -0,0 +1,11 @@ +i2c_servo: + enabled: false + path: modules.i2c_servo.I2CServo + config: + test_on_boot: false + servo_count: 16 + dependencies: + unix: + - python3-smbus + python: + - adafruit-circuitpython-servokit \ No newline at end of file diff --git a/config/logwrapper.yml b/config/logwrapper.yml index 51bb71dc..2a766da2 100644 --- a/config/logwrapper.yml +++ b/config/logwrapper.yml @@ -1,6 +1,7 @@ logwrapper: - enabled: false #TODO work in progress, currently hardcoded in main.py + enabled: true # Highly recommended to enable this module path: modules.logwrapper.LogWrapper - dependencies: - python: - - pypubsub + config: + filename: app.log + log_level: 'debug' # debug, info, warning, error, critical + cli_level: 'info' # debug, info, warning, error, critical diff --git a/config/messaging_service.yml b/config/messaging_service.yml new file mode 100644 index 00000000..e1d1769f --- /dev/null +++ b/config/messaging_service.yml @@ -0,0 +1,14 @@ +messaging_service: + enabled: true # Required for framework to function + path: modules.network.messaging_service.MessagingService + config: + protocol: 'pubsub' # 'mqtt' or 'pubsub' + mqtt_host: 'localhost' + mqtt_port: 1883 + dependencies: + python: + # - paho-mqtt + - pypubsub + # unix: + # - mosquitto + # - mosquitto-clients \ No newline at end of file diff --git a/config/motion.yml b/config/motion.yml index 873ec295..21b13f17 100644 --- a/config/motion.yml +++ b/config/motion.yml @@ -1,9 +1,9 @@ motion: - enabled: true + enabled: false path: 'modules.sensor.Sensor' config: pin: 26 + test_on_boot: false dependencies: python: - - pypubsub - gpiozero \ No newline at end of file diff --git a/config/mpu6050.yml b/config/mpu6050.yml new file mode 100644 index 00000000..4d9f851d --- /dev/null +++ b/config/mpu6050.yml @@ -0,0 +1,8 @@ +mpu6050: + enabled: false + path: modules.mpu6050.MPU6050 + config: + test_on_boot: true + dependencies: + unix: + - python3-smbus \ No newline at end of file diff --git a/config/neopixel.yml b/config/neopixel.yml index fcd3c094..79eced14 100644 --- a/config/neopixel.yml +++ b/config/neopixel.yml @@ -1,9 +1,9 @@ neopixel: - enabled: true + enabled: false path: 'modules.neopixel.neopx.NeoPx' config: pin: 12 # Only used for GPIO. GPIO2 and 3 are use for i2c, GPIO10 is used for SPI, GPIO12 is used for GPIO - protocol: 'I2C' # choose between GPIO, I2C and SPI + protocol: 'SPI' # choose between GPIO, I2C and SPI count: 12 positions: { 'status1': 0, @@ -35,7 +35,7 @@ neopixel: ] dependencies: python: - - pypubsub + - colour - adafruit-circuitpython-seesaw # i2c SUPPORT - adafruit-blinka # SPI SUPPORT - adafruit-circuitpython-neopixel-spi # SPI SUPPORT diff --git a/config/emotions.yml b/config/neopixel_emotions.yml similarity index 99% rename from config/emotions.yml rename to config/neopixel_emotions.yml index add92977..12a63aee 100644 --- a/config/emotions.yml +++ b/config/neopixel_emotions.yml @@ -1,4 +1,4 @@ -emotions: +neopixel_emotions: enabled: false path: modules.neopixel.emotion_analysis.EmotionAnalysis dependencies: @@ -6,7 +6,6 @@ emotions: - tensorflow - torch - transformers - - pypubsub config: colors: grief: diff --git a/config/piservo.yml b/config/piservo.yml index bc52a706..2ee4b7b3 100644 --- a/config/piservo.yml +++ b/config/piservo.yml @@ -1,5 +1,5 @@ piservo: - enabled: true + enabled: false path: modules.actuators.piservo.PiServo instances: - name: "ear" diff --git a/config/pitemperature.yml b/config/pitemperature.yml index 1df80313..13baa6bf 100644 --- a/config/pitemperature.yml +++ b/config/pitemperature.yml @@ -1,6 +1,3 @@ pitemperature: enabled: true - path: modules.pitemperature.PiTemperature - dependencies: - python: - - pypubsub \ No newline at end of file + path: modules.pitemperature.PiTemperature \ No newline at end of file diff --git a/config/rfid.yml b/config/rfid.yml deleted file mode 100644 index 35a56013..00000000 --- a/config/rfid.yml +++ /dev/null @@ -1,8 +0,0 @@ -module: - enabled: false - conf: - pin: None - cards: - "1234567890": "John Doe" - "0987654321": "Jane Doe" - "1234567891": "John Doe" diff --git a/config/rtlsdr.yml b/config/rtlsdr.yml index ff39b6a7..5612604e 100644 --- a/config/rtlsdr.yml +++ b/config/rtlsdr.yml @@ -5,14 +5,8 @@ rtl_sdr: udp_host: "127.0.0.1" udp_port: 8433 timeout: 70 - topics: - publish_data: "sdr/data" - subscribe_listen: "sdr/listen" - subscribe_start: "sdr/start" - subscribe_stop: "sdr/stop" dependencies: unix: - - "rtl-433" + - rtl-433 python: - - "pypubsub" - - "requests" \ No newline at end of file + - requests \ No newline at end of file diff --git a/config/serial.yml b/config/serial.yml index ad1b9bbc..376c9e0b 100644 --- a/config/serial.yml +++ b/config/serial.yml @@ -1,9 +1,6 @@ serial: - enabled: true + enabled: false path: "modules.network.arduinoserial.ArduinoSerial" config: port: '/dev/ttyAMA0' - baudrate: '115200' - dependencies: - pythion: - - pypubsub \ No newline at end of file + baudrate: '115200' \ No newline at end of file diff --git a/config/servos.yml b/config/servos.yml index a4de36ba..25f6cf0f 100644 --- a/config/servos.yml +++ b/config/servos.yml @@ -1,5 +1,5 @@ servos: - enabled: true + enabled: false path: "modules.actuators.servo.Servo" # Include class name here instances: - name: "leg_l_hip" @@ -44,5 +44,4 @@ servos: start: 50 dependencies: python: - - pypubsub - pigpio \ No newline at end of file diff --git a/config/speechinput.yml b/config/speechinput.yml index 4ce1bd94..b69459a4 100644 --- a/config/speechinput.yml +++ b/config/speechinput.yml @@ -2,4 +2,22 @@ speechinput: enabled: false path: modules.audio.speechinput.SpeechInput config: - device_name: lp \ No newline at end of file + device_name: "pulse" + start_on_boot: true + dependencies: + unix: + - flac + - python3-pyaudio + # - matplotlib + # - scipy + - ladspa-sdk + python: + - SpeechRecognition + # - adafruit-python-shell + # - libportaudio0 + # - libportaudio2 + # - libportaudiocpp0 + # - portaudio19-dev + additional: + - installers/install-mics.sh + - installers/install-speech-recognition.sh \ No newline at end of file diff --git a/config/telegram.yml b/config/telegram.yml index 0545907c..dd4bed11 100644 --- a/config/telegram.yml +++ b/config/telegram.yml @@ -1,14 +1,10 @@ telegram: - path: modules.network.telegrambot.TelegramBot enabled: false + path: modules.network.telegrambot.TelegramBot config: user_whitelist: [] - topics: - publish_received: "telegram/received" - subscribe_respond: "telegram/respond" dependencies: python: - - pypubsub - python-telegram-bot additional: - https://core.telegram.org/bots/tutorial#obtain-your-bot-token \ No newline at end of file diff --git a/config/tft_display.yml b/config/tft_display.yml new file mode 100644 index 00000000..25da6210 --- /dev/null +++ b/config/tft_display.yml @@ -0,0 +1,17 @@ +tft_display: + enabled: false + path: modules.display.tft_display.TFTDisplay + config: + bus: 0 # Use `ls /dev/spi*` to identify. (/dev/spidev0.0) is bus 0, device 0 + device: 0 # Use `ls /dev/spi*` to identify. (/dev/spidev0.0) is bus 0, device 0 + rst_pin: 27 + dc_pin: 25 + bl_pin: 18 # Not used if no backlight pin on device + test_on_boot: true + dependencies: + unix: + - python3-pip + - python3-pil + - python3-numpy + python: + - spidev \ No newline at end of file diff --git a/config/tracking.yml b/config/tracking.yml index 549be5b0..d9e14dc4 100644 --- a/config/tracking.yml +++ b/config/tracking.yml @@ -1,10 +1,7 @@ tracking: - enabled: true + enabled: false path: 'modules.vision.imx500.tracking.Tracking' config: active: True name: 'tracking' - filter: 'person' - dependencies: - python: - - pypubsub \ No newline at end of file + filter: 'person' \ No newline at end of file diff --git a/config/ttsmodule.yml b/config/ttsmodule.yml index b10ed7fc..6f3d8e0c 100644 --- a/config/ttsmodule.yml +++ b/config/ttsmodule.yml @@ -1,6 +1,6 @@ -tts: +ttsmodule: enabled: false - path: "modules.audio.tts.TTS" + path: "modules.audio.ttsmodule.TTSModule" config: service: "elevenlabs" # or pyttsx3 voice_id: "pMsXgVXv3BLzUgSXRplE" @@ -8,3 +8,9 @@ tts: python: - pyttsx3 - elevenlabs + unix: + - espeak + - ffmpeg + - libespeak1 + additional: + - installers/install-amp.sh \ No newline at end of file diff --git a/config/viam.yml b/config/viam.yml deleted file mode 100644 index 59a2bf81..00000000 --- a/config/viam.yml +++ /dev/null @@ -1,21 +0,0 @@ -viamclassifier: - enabled: false - path: 'modules.viam.viamclassifier.ViamClassifier' - config: - classifier_name: 'my-classifier' - camera_name: 'camera' - dependencies: - python: - - viam-sdk -viamobjectdetection: - enabled: false - path: 'modules.viam.viamobjects.ViamObjects' - config: - vision_client: 'objectDetector' - camera_name: 'camera' - timelapse_location: '/home/archie/modular-biped/data/viamobjects' - dependencies: - python: - - viam-sdk - additional: - - https://docs.viam.com/installation/viam-server-setup/ \ No newline at end of file diff --git a/config/vision.yml b/config/vision.yml index 2bb6cf8d..ead2fecf 100644 --- a/config/vision.yml +++ b/config/vision.yml @@ -1,5 +1,5 @@ vision: - enabled: true + enabled: false path: 'modules.vision.imx500.vision.Vision' config: preview: False diff --git a/docs/BaseModule.md b/docs/BaseModule.md new file mode 100644 index 00000000..e44d70b3 --- /dev/null +++ b/docs/BaseModule.md @@ -0,0 +1,145 @@ +# BaseModule Documentation + +## Overview + +The `BaseModule` class provides a foundational structure for all modules in the project. It includes essential functionalities such as messaging service integration, logging, and topic subscription. By extending this class, new modules can leverage these built-in features, ensuring consistency and reducing boilerplate code. + +## Benefits of Extending BaseModule + +1. **Unified Messaging Service**: The `BaseModule` class integrates with the `MessagingService`, allowing modules to publish and subscribe to messages using a unified interface. +2. **Consistent Logging**: The `log` method provides advanced logging capabilities, including class name, method name, and line number, ensuring consistent and informative log messages across all modules. +3. **Simplified Topic Subscription**: The `setup_messaging` method allows modules to define their own topic subscriptions, ensuring a standardized approach to message handling. +4. **Error Handling**: The class includes error handling for scenarios where the messaging service is not set, preventing runtime errors and ensuring robustness. + +## Usage + +### Initializing the BaseModule + +To create a new module that extends the `BaseModule` class, follow these steps: + +1. Create a new Python file for your module (e.g., `my_module.py`). +2. Import the `BaseModule` class. +3. Define your module class and inherit from `BaseModule`. +4. Implement the necessary methods and messaging setup. + +### Example: MyModule + +```python +# filepath: /home/dan/projects/modular-biped/modules/my_module.py +from modules.base_module import BaseModule + +class MyModule(BaseModule): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.value = kwargs.get('value', 0) + + if kwargs.get('test_on_boot'): + self.test() + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/1', self.loop) + + def loop(self): + self.publish('my_module/value', value=self.value) + + def test(self): + print(f"Initial value: {self.value}") +``` + +### Configuration for MyModule + +Create a configuration file for your module (e.g., `config/my_module.yml`): + +```yaml +my_module: + enabled: true # Set to true to enable the MyModule module + path: modules.my_module.MyModule # Path to the MyModule class + config: + value: 42 # Initial value + test_on_boot: true # Set to true to run a test on boot +``` + +### Using MyModule in main.py + +Reference the `MyModule` class in the `main.py`: + +```python +module = module_instances['my_module'] # Get the MyModule module instance +module.publish('my_module/value', value=100) # Publish a value +``` + +## Methods + +### `messaging_service` + +Getter and setter for the messaging service. Ensures that `setup_messaging` is called when the service is set. + +```python +@property +def messaging_service(self): + """Getter for messaging service.""" + return self._messaging_service + +@messaging_service.setter +def messaging_service(self, service): + """Setter for messaging service, ensures setup is called.""" + self._messaging_service = service + self.setup_messaging() +``` + +### `setup_messaging` + +Override this method in child classes to subscribe to topics. + +```python +def setup_messaging(self): + """Override this method in child classes to subscribe to topics.""" + pass # No default implementation, subclasses should define their own subscriptions +``` + +### `publish` + +Publish a message to a topic. + +```python +def publish(self, topic, *args, **kwargs): + if self.messaging_service is None: + raise ValueError("Messaging service not set.") + self.messaging_service.publish(topic, *args, **kwargs) +``` + +### `subscribe` + +Subscribe to a topic. + +```python +def subscribe(self, topic, callback, **kwargs): + if self.messaging_service is None: + raise ValueError("Messaging service not set.") + self.messaging_service.subscribe(topic, callback, **kwargs) +``` + +### `log` + +Advanced logging, includes class name, method name, and line number to message string. + +```python +def log(self, message, level='info'): + """ + Advanced logging, includes class name, method name, and line number to message string + """ + # get class name, method name + class_name = self.__class__.__name__ + method_name = inspect.stack()[1].function + + #get line number of calling class + frame = inspect.stack()[1] + + message = f"[{class_name}.{method_name}:{frame.lineno}] {str(message)}" + self.publish(f'log/{level}', message=message) +``` + +## Conclusion + +The `BaseModule` class provides a robust foundation for creating new modules in the project. By extending this class, developers can leverage built-in messaging, logging, and subscription functionalities, ensuring consistency and reducing boilerplate code. This approach simplifies module development and enhances the maintainability of the project. \ No newline at end of file diff --git a/docs/ChatGPT.md b/docs/ChatGPT.md new file mode 100644 index 00000000..8dbaadad --- /dev/null +++ b/docs/ChatGPT.md @@ -0,0 +1,73 @@ +# ChatGPT Module Documentation + +## Overview + +The `ChatGPT` module is designed to interface with OpenAI's GPT models to provide conversational capabilities. It allows the robot to respond to speech inputs with text or predefined animations. This documentation outlines the setup and usage of the `ChatGPT` class. + +## Configuration + +Before using the `ChatGPT` module, you need to configure it in the `config/chatgpt.yml` file. Below is an explanation of the configuration options: + +```yaml +chatgpt: + enabled: false # Set to true to enable the ChatGPT module + path: 'modules.chatgpt.ChatGPT' # Path to the ChatGPT class + config: + model: gpt-4o-mini # Model to use for the chat + persona: "You are a helpful assistant robot. You respond with short phrases where possible. + Alternatively, you can respond with the following commands instead of text if you feel they are appropriate: + - animate:head_nod + - animate:head_shake" + dependencies: + python: + - openai + additional: + - https://platform.openai.com/api-keys +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +You must also obtain an API key from OpenAI to use the GPT models. The API key should be added as an environment variable OPENAI_API_KEY: + +```bash +export OPENAI_API_KEY="your-api-key" +``` + +Read here for config steps : https://platform.openai.com/docs/quickstart + +## Usage + +### Initializing the ChatGPT Module + +When the module is enabled in the config YAML, the `ChatGPT` class is automatically imported and initialized. + +### Chat Completion + +The `completion` method sends the input text to the OpenAI API and processes the response. It can publish responses as text-to-speech (TTS) messages or trigger animations. + +### Example Usage + +You can test the `ChatGPT` module by running the following code: + +```python +self.publish('speech', text='Hello, can you hear me?') +``` + +When the module is enabled in the config YAML, the `ChatGPT` class is automatically imported and initialized. + +You can also reference the `ChatGPT` class directly in the main.py: + +```python +module = module_instances['chatgpt'] +module.completion('Can you hear me?') +``` + +## Customization + +The `ChatGPT` module can be customized to meet specific requirements by modifying the persona, model, and response handling logic. You can update the configuration file to change the persona and model used for the chat. + +## Conclusion + +The `ChatGPT` module provides a powerful interface for adding conversational capabilities to the robot using OpenAI's GPT models. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the `ChatGPT` module in your projects. \ No newline at end of file diff --git a/docs/I2CServo.md b/docs/I2CServo.md new file mode 100644 index 00000000..da3b4f60 --- /dev/null +++ b/docs/I2CServo.md @@ -0,0 +1,60 @@ +# I2CServo Module Documentation + +## Overview + +The `I2CServo` module is designed to interface with servo motors using the I2C protocol. It provides functionalities to initialize the servos, perform tests, and move the servos to specified angles. This documentation outlines the setup and usage of the `I2CServo` class. + +## Configuration + +Before using the `I2CServo` module, you need to configure it in the `config/i2c_servo.yml` file. Below is an explanation of the configuration options: + +```yaml +i2c_servo: + enabled: false # Set to true to enable the I2CServo module + path: modules.i2c_servo.I2CServo # Path to the I2CServo class + config: + test_on_boot: false # Set to true to run a test on boot + servo_count: 16 # Number of servos connected + dependencies: + unix: + - python3-smbus + python: + - adafruit-circuitpython-servokit +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the I2CServo + +When the module is enabled in the config YAML, the `I2CServo` class is automatically imported and initialized. + +You can also reference the `I2CServo` class directly in the `main.py`: + +```python +module = module_instances['i2c_servo'] # Get the I2CServo module instance +module.test() # Run a test on the servos +``` + +### Testing the Servos + +If `test_on_boot` is set to `true` in the configuration, the servos will automatically run a test upon initialization. You can also manually call the `test` method: + +```python +i2c_servo.test() +``` + +### Moving the Servos + +The `I2CServo` class provides a method to move the servos to specified angles. Here’s an example of how to move a servo: + +```python +i2c_servo.moveServo(index=0, angle=90) # Move servo at index 0 to 90 degrees +``` + +## Conclusion + +The `I2CServo` module provides a straightforward interface for working with servo motors using the I2C protocol in Python. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the servo motors in your projects. \ No newline at end of file diff --git a/docs/MPU6050.md b/docs/MPU6050.md new file mode 100644 index 00000000..eb481ef0 --- /dev/null +++ b/docs/MPU6050.md @@ -0,0 +1,86 @@ +# MPU6050 Module Documentation + +## Overview + +The `MPU6050` module is designed to interface with the MPU6050 sensor using the I2C protocol. It provides functionalities to initialize the sensor, read raw data from the accelerometer and gyroscope, and process this data. This documentation outlines the setup and usage of the `MPU6050` class. + +## Configuration + +Before using the `MPU6050` module, you need to configure it in the `config/mpu6050.yml` file. Below is an explanation of the configuration options: + +```yaml +mpu6050: + enabled: false # Set to true to enable the MPU6050 module + path: modules.mpu6050.MPU6050 # Path to the MPU6050 class + config: + test_on_boot: false # Set to true to run a test on boot + dependencies: + unix: + - python3-smbus + python: + - smbus +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the MPU6050 + +When the module is enabled in the config YAML, the `MPU6050` class is automatically imported and initialized. + +You can also reference the `MPU6050` class directly in the `main.py`: + +```python +module = module_instances['mpu6050'] # Get the MPU6050 module instance +module.read_data() # Start reading data from the sensor +``` + +### Reading Data + +The `MPU6050` class provides methods to read raw data from the accelerometer and gyroscope. Here’s an example of how to read data: + +```python +def read_data(self): + print("Reading Data of Gyroscope and Accelerometer") + while True: + try: + # Read Accelerometer raw value + acc_x = self.read_raw_data(ACCEL_XOUT_H) + acc_y = self.read_raw_data(ACCEL_YOUT_H) + acc_z = self.read_raw_data(ACCEL_ZOUT_H) + + # Read Gyroscope raw value + gyro_x = self.read_raw_data(GYRO_XOUT_H) + gyro_y = self.read_raw_data(GYRO_YOUT_H) + gyro_z = self.read_raw_data(GYRO_ZOUT_H) + except OSError: + continue + + # Full scale range +/- 250 degree/C as per sensitivity scale factor + Ax = acc_x / 16384.0 + Ay = acc_y / 16384.0 + Az = acc_z / 16384.0 + + Gx = gyro_x / 131.0 + Gy = gyro_y / 131.0 + Gz = gyro_z / 131.0 + + print(f"Gx={Gx:.2f}°/s\tGy={Gy:.2f}°/s\tGz={Gz:.2f}°/s\tAx={Ax:.2f} g\tAy={Ay:.2f} g\tAz={Az:.2f} g") + sleep(0.1) +``` + +### Handling Errors + +The `MPU6050` class includes error handling for I2C communication errors. If an `OSError` occurs, it will be caught and ignored, allowing the script to continue running: + +```python +except OSError: + continue +``` + +## Conclusion + +The `MPU6050` module provides a straightforward interface for working with the MPU6050 sensor in Python. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the MPU6050 sensor in your projects. \ No newline at end of file diff --git a/docs/MessagingService.md b/docs/MessagingService.md new file mode 100644 index 00000000..688a03b4 --- /dev/null +++ b/docs/MessagingService.md @@ -0,0 +1,119 @@ +# Messaging Service Documentation + +## Overview + +The `MessagingService` module provides an abstraction layer for different messaging protocols, including `pypubsub` and `MQTT`. It allows modules to publish and subscribe to messages using a unified interface. This documentation outlines the setup and usage of the `MessagingService` class and explains how to create a new module that utilizes the `BaseModule` class, which includes a messaging service instance. + +## Configuration + +Before using the `MessagingService` module, you need to configure it in the `config/messaging_service.yml` file. Below is an explanation of the configuration options: + +```yaml +messaging_service: + enabled: true # Set to true to enable the MessagingService module + path: modules.network.messaging_service.MessagingService # Path to the MessagingService class + config: + protocol: 'pubsub' # Protocol to use ('pubsub' or 'mqtt') + mqtt_host: 'localhost' # MQTT broker host (if using MQTT) + port: 1883 # MQTT broker port (if using MQTT) + dependencies: + python: + - paho-mqtt + - pypubsub +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the MessagingService + +When the module is enabled in the config YAML, the `MessagingService` class is automatically imported and initialized. + +You can also reference the `MessagingService` class directly in the `main.py`: + +```python +module = module_instances['messaging_service'] # Get the MessagingService module instance +module.publish('system/log', message="Hello, World!") # Publish a message +``` + +### Subscribing to Topics + +The `MessagingService` class provides a method to subscribe to topics. Here’s an example of how to subscribe to a topic: + +```python +def callback(message): + print(message) + +module.subscribe('system/log', callback) +``` + +### Publishing Messages + +The `MessagingService` class provides a method to publish messages to topics. Here’s an example of how to publish a message: + +```python +module.publish('system/log', message="Hello, World!") +``` + +## Creating a New Module + +To create a new module that utilizes the `BaseModule` class, follow these steps: + +1. Create a new Python file for your module (e.g., `my_module.py`). +2. Import the `BaseModule` class. +3. Define your module class and inherit from `BaseModule`. +4. Implement the necessary methods and messaging setup. + +### Example: MyModule + +```python +# filepath: /home/dan/projects/modular-biped/modules/my_module.py +from modules.base_module import BaseModule + +class MyModule(BaseModule): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.value = kwargs.get('value', 0) + + if kwargs.get('test_on_boot'): + self.test() + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/1', self.loop) + + def loop(self): + self.publish('my_module/value', value=self.value) + + def test(self): + print(f"Initial value: {self.value}") +``` + +### Configuration for MyModule + +Create a configuration file for your module (e.g., `config/my_module.yml`): + +```yaml +my_module: + enabled: true # Set to true to enable the MyModule module + path: modules.my_module.MyModule # Path to the MyModule class + config: + value: 42 # Initial value + test_on_boot: true # Set to true to run a test on boot +``` + +### Using MyModule in main.py + +Reference the `MyModule` class in the `main.py`: + +```python +module = module_instances['my_module'] # Get the MyModule module instance +module.publish('my_module/value', value=100) # Publish a value +``` + +## Conclusion + +The `MessagingService` module provides a unified interface for different messaging protocols, making it easy to publish and subscribe to messages in your application. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the messaging service in your projects. Additionally, you can create new modules that inherit from the `BaseModule` class and leverage the messaging service for communication. \ No newline at end of file diff --git a/docs/Neopixel.md b/docs/Neopixel.md new file mode 100644 index 00000000..a44213b6 --- /dev/null +++ b/docs/Neopixel.md @@ -0,0 +1,86 @@ +# NeoPx Module Documentation + +## Overview + +The `NeoPx` module is designed to control NeoPixel LEDs using different protocols such as GPIO, I2C, and SPI. This documentation outlines the setup and usage of the `NeoPx` class, including protocol compatibility with various Raspberry Pi versions. + +## Configuration + +Before using the `NeoPx` module, you need to configure it in the `config/neopx.yml` file. Below is an explanation of the configuration options: + +```yaml +neopx: + enabled: false # Set to true to enable the NeoPx module + path: modules.neopixel.neopx.NeoPx # Path to the NeoPx class + config: + pin: 12 # Only used for GPIO. GPIO2 and 3 are use for i2c, GPIO10 is used for SPI, GPIO12 is used for GPIO + protocol: 'SPI' # choose between GPIO, I2C and SPI (see below) + count: 12 +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Protocol Compatibility + +### GPIO + +- **Not supported on Raspberry Pi 5**. +- Suitable for older Raspberry Pi versions. + +### I2C + +- Supported on the 'buddy' release. +- Requires the `adafruit-circuitpython-seesaw` library. + +### SPI + +- Supported on the 'Cody' release. +- Requires the `neopixel_spi` library. + +## Usage + +### Initializing the NeoPx + +When the module is enabled in the config YAML, the `NeoPx` class is automatically imported and initialized. + +You can also reference the `NeoPx` class directly in the main.py: + +```python +module = module_instances['neopx'] # Get the NeoPx module instance +module.set(module.all, NeoPx.COLOR_RED) # Set all pixels to red +``` + +### Setting Pixel Colors + +The `NeoPx` class provides methods to set the color of individual or multiple pixels. Here’s an example of how to set the color of a pixel: + + + +```python +# Using messaging +self.publish('led', identifiers=[0], color='red') + +# Directly accessing the module (see above) +module.set(1, 'red') # Set pixel 1 to red +module.set([0, 2, 4], (0, 255, 0)) # Set pixels 0, 2, and 4 to green +``` + +### Flashlight Mode + +To turn on or off all pixels in flashlight mode: + +```python +# Using messaging +self.publish('led/flashlight', state=True) # Turn on flashlight mode +self.publish('led/flashlight', state=False) # Turn off flashlight mode + +# Directly accessing the module (see above) +module.flashlight(True) # Turn on flashlight mode +module.flashlight(False) # Turn off flashlight mode +``` + +## Conclusion + +The `NeoPx` module provides a flexible interface for controlling NeoPixel LEDs using different protocols. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize NeoPixels in your projects. diff --git a/docs/Personality.md b/docs/Personality.md new file mode 100644 index 00000000..fd98a986 --- /dev/null +++ b/docs/Personality.md @@ -0,0 +1,217 @@ +# Personality Module Documentation + +## Overview + +The `Personality` module is designed to add personality and interactive behaviors to the robot. It subscribes to various events such as motion detection, serial communication, and vision detections, and responds with actions like animations, LED changes, and sounds. This documentation outlines the current functionality and how it interacts with other modules. + +## Configuration + +Before using the `Personality` module, you need to configure it in the `config/personality.yml` file. Below is an explanation of the configuration options: + +```yaml +personality: + enabled: true # Set to true to enable the Personality module + path: modules.personality.Personality # Path to the Personality class + config: + min_interval: 20 # Minimum interval between actions in seconds + max_interval: 60 # Maximum interval between actions in seconds +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the Personality Module + +When the module is enabled in the config YAML, the `Personality` class is automatically imported and initialized. + +### Subscribing to Topics + +The `Personality` module subscribes to various topics to receive events and trigger actions. Here’s an example of how to subscribe to topics: + +```python +def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/1', self.loop) + self.subscribe('vision/detections', self.handle_vision_detections) + self.subscribe('motion', self.update_motion_time) + self.subscribe('serial', self.track_serial_idle) +``` + +### Loop Method + +The `loop` method is called periodically to handle ongoing reactions and trigger new actions based on the current state and timing intervals. + +```python +def loop(self): + # Handle ongoing object reaction + if self.object_reaction_end_time and datetime.now() >= self.object_reaction_end_time: + self.publish('led', identifiers=[ + 'right', 'top_right', 'top_left', 'left', + 'bottom_left', 'bottom_right' + ], color="off") + self.object_reaction_end_time = None + + # Update the middle eye LED based on conditions + self.update_middle_eye_led() + + self.random_neopixel_status() + + # Check if it's time for the next action + if datetime.now() >= self.next_action_time: + action = choice(self.actions) + action() + self.next_action_time = self.calculate_next_action_time() + + # If serial has been idle for more than 10 seconds, call random_animation() + if self.last_serial_time and datetime.now() - self.last_serial_time > timedelta(seconds=10): + self.random_animation() +``` + +### Actions + +The `Personality` module defines various actions that can be triggered, such as animations, sounds, and LED changes. + +#### Random Animation + +```python +def random_animation(self): + animations = [ + 'head_shake', + 'head_left', + 'head_right', + 'wake', + 'look_down', + 'look_up', + 'celebrate' + ] + animation = choice(animations) + self.log(f"Random animation triggered: {animation}") + self.publish('animate', action=animation) +``` + +#### Braillespeak + +```python +def braillespeak(self): + messages = ["Hi", "Hello", "Hai", "Hey"] + msg = choice(messages) + self.publish('speak', msg=msg) + self.log(f"Braillespeak triggered: {msg}") +``` + +#### Buzzer Tone + +```python +def buzzer_tone(self): + frequency = randint(300, 1000) # Random frequency between 300Hz and 1000Hz + length = round(randint(1, 5) / 10, 1) # Random length between 0.1s and 0.5s + self.publish('buzz', frequency=frequency, length=length) + self.log(f"Buzzer tone triggered: {frequency}Hz for {length}s") +``` + +#### Buzzer Song + +```python +def buzzer_song(self): + songs = ["happy birthday", "merry christmas"] + song = choice(songs) + self.publish('play', song=song) + self.log(f"Buzzer song triggered: {song}") +``` + +#### Random Neopixel Status + +```python +def random_neopixel_status(self): + if not self.last_status_time or datetime.now() - self.last_status_time > timedelta(seconds=3): + self.last_status_time = datetime.now() + color = choice(["red", "green", "blue", "white_dim", "purple", "yellow", "orange", "pink"]) + self.publish('led', identifiers=[0], color=color) + for i in range 4, 0, -1): + if i+1 < 5: + self.led_colors[i] = self.led_colors[i-1] + for i in range(1, 5): + self.publish('led', identifiers=[i], color=self.led_colors[i]) + self.led_colors[0] = color + self.log(message=f"Neopixel status triggered set to {color}", level='debug') +``` + +#### Random Neopixel Eye + +```python +def random_neopixel_eye(self): + positions = [ + 'right', 'top_right', 'top_left', 'left', + 'bottom_left', 'bottom_right' + ] + position = choice(positions) + color = choice(["white_dim"]) + self.publish('led', identifiers=positions, color=color) + self.log(f"Neopixel eye ring set to {color}") +``` + +#### Move Antenna + +```python +def move_antenna(self): + angle = randint(-40, 40) + self.publish('piservo/move', angle=angle) + self.log(f"Antenna moved to angle: {angle}") +``` + +### Handling Vision Detections + +The `handle_vision_detections` method processes detected objects and triggers reactions. + +```python +def handle_vision_detections(self, matches): + if matches and len(matches) > 0 and (self.object_reaction_end_time is None or datetime.now() >= self.object_reaction_end_time): + self.log(f"Vision detected objects: {matches}") + self.last_vision_time = datetime.now() + self.random_neopixel_eye() + self.object_reaction_end_time = datetime.now() + timedelta(seconds=3) +``` + +### Updating Motion Time + +The `update_motion_time` method updates the last motion time. + +```python +def update_motion_time(self): + self.last_motion_time = datetime.now() +``` + +### Updating Middle Eye LED + +The `update_middle_eye_led` method updates the middle eye LED based on the current state. + +```python +def update_middle_eye_led(self): + now = datetime.now() + if self.last_vision_time and now - self.last_vision_time <= timedelta(seconds=30): + self.publish('led', identifiers='middle', color='green') + elif now - self.last_motion_time > timedelta(seconds=30): + self.publish('led', identifiers='middle', color='red') + else: + self.publish('led', identifiers='middle', color='blue') +``` + +### Tracking Serial Idle + +The `track_serial_idle` method tracks the last serial communication time. + +```python +def track_serial_idle(self, type, identifier, message): + self.last_serial_time = datetime.now() +``` + +## Customization + +The `Personality` module can be modified to meet specific requirements for different implementations. You can add new actions, change the intervals, or modify the existing behaviors to suit your needs. + +## Conclusion + +The `Personality` module provides a comprehensive framework for adding personality and interactive behaviors to the robot. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and customize the `Personality` module in your projects. \ No newline at end of file diff --git a/docs/RTLSDR.md b/docs/RTLSDR.md new file mode 100644 index 00000000..e5d0cfd4 --- /dev/null +++ b/docs/RTLSDR.md @@ -0,0 +1,95 @@ +# RTLSDR Module Documentation + +## Overview + +The `RTLSDR` module is designed to interface with RTL Software Defined Radio (SDR) using the `rtl_433` tool. It provides functionalities to start and stop the `rtl_433` process, listen to its HTTP streaming API, and handle JSON events. This documentation outlines the setup and usage of the `RTLSDR` class. + +## Configuration + +Before using the `RTLSDR` module, you need to configure it in the `config/rtlsdr.yml` file. Below is an explanation of the configuration options: + +```yaml +rtlsdr: + enabled: false # Set to true to enable the RTLSDR module + path: modules.network.rtlsdr.RTLSDR # Path to the RTLSDR class + config: + udp_host: "127.0.0.1" # Host for the UDP stream + udp_port: 8433 # Port for the UDP stream + timeout: 70 # Timeout for the HTTP connection + dependencies: + unix: + - rtl-sdr + python: + - requests +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the RTLSDR + +When the module is enabled in the config YAML, the `RTLSDR` class is automatically imported and initialized. + +You can also reference the `RTLSDR` class directly in the `main.py`: + +```python +module = module_instances['rtlsdr'] # Get the RTLSDR module instance +module.start_rtl_433() # Start the rtl_433 process +module.listen_once() # Listen to one chunk of the rtl_433 stream +``` + +### Starting and Stopping rtl_433 + +The `RTLSDR` class provides methods to start and stop the `rtl_433` process. Here’s an example of how to start and stop the process: + +```python +module.start_rtl_433() # Start the rtl_433 process +module.stop_rtl_433() # Stop the rtl_433 process +``` + +### Listening to the Stream + +The `RTLSDR` class provides methods to listen to the `rtl_433` HTTP stream and handle JSON events. Here’s an example of how to listen to the stream: + +```python +module.listen_once() # Listen to one chunk of the rtl_433 stream +module.rtl_433_listen() # Listen to rtl_433 messages in a loop until stopped +``` + +### Handling Events + +The `RTLSDR` class includes a method to handle JSON events from the `rtl_433` stream. Here’s an example of how to handle events: + +```python +def handle_event(self, line): + """Process each JSON line from rtl_433.""" + try: + data = json.loads(line) + print(data) + self.publish('sdr/data', data=data) + + # Additional custom handling below + # Example: print battery and temperature information + label = data.get("model", "Unknown") + if "channel" in data: + label += ".CH" + str(data["channel"]) + elif "id" in data: + label += ".ID" + str(data["id"]) + + if data.get("battery_ok") == 0: + print(f"{label} Battery empty!") + if "temperature_C" in data: + print(f"{label} Temperature: {data['temperature_C']}°C") + if "humidity" in data: + print(f"{label} Humidity: {data['humidity']}%") + + except json.JSONDecodeError: + print("Failed to decode JSON line:", line) +``` + +## Conclusion + +The `RTLSDR` module provides a straightforward interface for working with RTL Software Defined Radio (SDR) using the `rtl_433` tool. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the RTLSDR in your projects. \ No newline at end of file diff --git a/docs/Sensor.md b/docs/Sensor.md new file mode 100644 index 00000000..9f56720b --- /dev/null +++ b/docs/Sensor.md @@ -0,0 +1,75 @@ +# Sensor Module Documentation + +## Overview + +The `Sensor` module is designed to interface with a motion sensor using the `gpiozero` library. It provides functionalities to initialize the sensor, read sensor data, and publish motion detection events. This documentation outlines the setup and usage of the `Sensor` class. + +## Configuration + +Before using the `Sensor` module, you need to configure it in the `config/sensor.yml` file. Below is an explanation of the configuration options: + +```yaml +sensor: + enabled: false # Set to true to enable the Sensor module + path: modules.sensor.Sensor # Path to the Sensor class + config: + pin: 4 # GPIO pin number connected to the sensor + test_on_boot: false # Set to true to run a test on boot + dependencies: + unix: + - python3-gpiozero + python: + - gpiozero +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the Sensor + +When the module is enabled in the config YAML, the `Sensor` class is automatically imported and initialized. + +You can also reference the `Sensor` class directly in the `main.py`: + +```python +module = module_instances['sensor'] # Get the Sensor module instance +module.read() # Read data from the sensor +``` + +### Reading Data + +The `Sensor` class provides methods to read data from the motion sensor. Here’s an example of how to read data: + +```python +def read(self): + self.value = self.sensor.motion_detected + return self.value +``` + +### Publishing Motion Events + +The `Sensor` class can publish motion detection events to the application via a pub/sub mechanism. Here’s an example of how to publish a motion event: + +```python +def loop(self): + if self.read(): + self.publish('motion') +``` + +### Testing the Sensor + +The `Sensor` class includes a test method to continuously read data from the sensor and print the results. Here’s an example of how to test the sensor: + +```python +def test(self): + while True: + print(self.read()) + sleep(1) +``` + +## Conclusion + +The `Sensor` module provides a straightforward interface for working with motion sensors in Python. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the motion sensor in your projects. \ No newline at end of file diff --git a/docs/TFTDisplay.md b/docs/TFTDisplay.md new file mode 100644 index 00000000..ab6c7ee7 --- /dev/null +++ b/docs/TFTDisplay.md @@ -0,0 +1,110 @@ +# TFTDisplay Module Documentation + +## Overview + +The `TFTDisplay` module is designed to interface with a TFT display using the SPI protocol. It provides functionalities to initialize the display, perform tests, and draw graphics on the screen. This documentation outlines the setup and usage of the `TFTDisplay` class. + +## Configuration + +Before using the `TFTDisplay` module, you need to configure it in the `config/tft_display.yml` file. Below is an explanation of the configuration options: + +```yaml +tft_display: + enabled: false # Set to true to enable the TFTDisplay module + path: modules.display.tft_display.TFTDisplay # Path to the TFTDisplay class + config: + bus: 0 # SPI bus number (0 for /dev/spidev0.0) + device: 0 # SPI device number (0 for /dev/spidev0.0) + rst_pin: 27 # GPIO pin for reset + dc_pin: 25 # GPIO pin for data/command + bl_pin: 18 # GPIO pin for backlight (not used if no backlight) + test_on_boot: true # Set to true to run a test on boot + dependencies: + unix: + - python3-pip + - python3-pil + - python3-numpy + python: + - spidev +``` + +### Dependencies + +After enabling the module, run ./install.sh to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +This module utilises the waveshare library. More information available here: https://www.waveshare.com/wiki/2.4inch_LCD_Module#Python + +## Usage + +### Display size + +There are multiple libraries available for working with TFT displays. The `TFTDisplay` module uses the `ST7735` library, which supports a resolution of 240x240 pixels. + +The import and initialization of the display must be adjusted for other devices. + +```python +from modules.display.lib import LCD_1inch28 +self.disp = LCD_1inch28.LCD_1inch28(spi=spi, spi_freq=10000000, rst=kwargs.get('rst_pin'), dc=kwargs.get('dc_pin') , bl=kwargs.get('bl_pin')) +``` + +This is not currently supported via the config YAML, but could be added in the future. + +### Initializing the TFTDisplay + +When the module is enabled in the config YAML, the `TFTDisplay` class is automatically imported and initialized. + +You can also reference the `TFTDisplay` class directly in the main.py: + +```python +module = module_instances['tft_display'] # Get the TFTDisplay module instance +module.test_display() # Run a test on the display +``` + +### Testing the Display + +If `test_on_boot` is set to `true` in the configuration, the display will automatically run a test upon initialization. You can also manually call the `test_display` method: + +```python +tft_display.test_display() +``` + +### Drawing on the Display + +The `TFTDisplay` class provides methods to draw shapes and images on the display. Here’s an example of how to draw an arc and lines: + +```python +from PIL import Image, ImageDraw + +# Create a blank image +image = Image.new("RGB", (tft_display.disp.width, tft_display.disp.height), "BLACK") +draw = ImageDraw.Draw(image) + +# Draw an arc +draw.arc((1, 1, 239, 239), 0, 360, fill=(0, 0, 255)) + +# Draw lines +draw.line([(120, 1), (120, 12)], fill=(128, 255, 128), width=4) + +# Display the image +tft_display.disp.ShowImage(image) +``` + +### Clearing the Display + +To clear the display, you can call the `clear` method: + +```python +tft_display.disp.clear() +``` + +### Setting the Backlight + +You can adjust the backlight brightness using the `bl_DutyCycle` method: + +```python +tft_display.disp.bl_DutyCycle(50) # Set backlight to 50% +``` + +## Conclusion + +The `TFTDisplay` module provides a straightforward interface for working with TFT displays in Python. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the TFT display in your projects. \ No newline at end of file diff --git a/docs/TelegramBot.md b/docs/TelegramBot.md new file mode 100644 index 00000000..b2601b9a --- /dev/null +++ b/docs/TelegramBot.md @@ -0,0 +1,111 @@ +# TelegramBot Module Documentation + +## Overview + +The `TelegramBot` module is designed to interface with Telegram using the `python-telegram-bot` library. It provides functionalities to initialize the bot, handle commands, and publish messages to the application via a pub/sub mechanism. This documentation outlines the setup and usage of the `TelegramBot` class. + +## Configuration + +Before using the `TelegramBot` module, you need to configure it in the `config/telegram_bot.yml` file. Below is an explanation of the configuration options: + +```yaml +telegram_bot: + enabled: false # Set to true to enable the TelegramBot module + path: modules.network.telegrambot.TelegramBot # Path to the TelegramBot class + config: + token: '' # Telegram bot token (can also be set as an environment variable) + user_whitelist: [] # List of user IDs allowed to interact with the bot + dependencies: + unix: + - python3-pip + python: + - python-telegram-bot +``` + +### Dependencies + +After enabling the module, run `./install.sh` to install the required dependencies. The dependencies are listed under `dependencies` in the configuration file. Check the output to ensure that the dependencies are installed correctly. + +## Usage + +### Initializing the TelegramBot + +When the module is enabled in the config YAML, the `TelegramBot` class is automatically imported and initialized. + +You can also reference the `TelegramBot` class directly in the `main.py`: + +```python +module = module_instances['telegram_bot'] # Get the TelegramBot module instance +module.publish('telegram/respond', user_id=123456789, response="Hello, World!") # Send a message to a user +``` + +### Setting Up the Bot + +To set up the bot, follow these steps: + +1. Search for BotFather in Telegram. +2. Start the BotFather. +3. Type `/newbot`. +4. Follow the instructions. +5. Copy the token (DON'T SHARE IT WITH ANYONE). +6. Create an environment variable called `TELEGRAM_BOT_TOKEN` and set it to the token (can also be set in the config YAML). +7. Run the script. + +### Handling Commands + +The `TelegramBot` class provides methods to handle commands and messages. Here’s an example of how to handle the `/start` and `/help` commands: + +```python +@staticmethod +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Send a message when the command /start is issued.""" + user = update.effective_user + await update.message.reply_html( + rf"Hi {user.mention_html()}!", + reply_markup=ForceReply(selective=True), + ) + +@staticmethod +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Send a message when the command /help is issued.""" + await update.message.reply_text("Help!") +``` + +### Publishing Messages + +The `TelegramBot` class can publish messages to the application via a pub/sub mechanism. Here’s an example of how to publish a message: + +```python +async def publish(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Publish the user's message to the application via pubsub.""" + user_id = update.effective_user.id + message = update.message.text + + # Save the update for response handling + self.update = update + + # Publish the message to other parts of the application + self.publish('telegram/received', user_id=user_id, message=message) + print(f"Published message from user {user_id}: {message} on topic telegram/received") +``` + +### Handling Responses + +The `TelegramBot` class can handle responses from the application and send them back to the user. Here’s an example of how to handle a response: + +```python +async def handle(self, user_id: int, response: str) -> None: + """Handle responses from the application and send them back to the user.""" + if (user_id not in self.user_whitelist): + print(f"User {user_id} not in whitelist, skipping response") + return + print(f"Handling response for user {user_id}: {response}") + + # Send the response back to the user on Telegram + if self.update and self.update.effective_user.id == user_id: + await self.update.message.reply_text(response) +``` + +## Conclusion + +The `TelegramBot` module provides a straightforward interface for working with Telegram bots in Python. By following the configuration and usage instructions outlined in this documentation, you can effectively integrate and utilize the Telegram bot in your projects. \ No newline at end of file diff --git a/install.sh b/install.sh index d2109663..fced60ed 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,8 @@ ACTIVE_MODULES=() # Install yaml package for Python myenv/bin/python3 -m pip install pyyaml + + # Helper function to parse dependencies from YAML files using Python parse_dependencies() { myenv/bin/python3 - </dev/null 2>&1 +} + +# Function to prompt user +prompt() { + read -p "$1 [y/N]: " response + case "$response" in + [yY][eE][sS]|[yY]) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Function to check if running on Raspberry Pi +is_raspberry_pi() { + if [ -f /proc/device-tree/model ]; then + grep -q "Raspberry Pi" /proc/device-tree/model + else + return 1 + fi +} + +# Function to check if driver is loaded +driver_loaded() { + lsmod | grep -q "$1" +} + +# Function to write a file +write_file() { + echo -e "$2" > "$1" +} + +# Function to append to asound.conf without overwriting existing settings +append_to_asound_conf() { + local CONFIG_CONTENT="$1" + local IDENTIFIER="$2" + + if grep -q "$IDENTIFIER" /etc/asound.conf 2>/dev/null; then + echo "$IDENTIFIER already exists in /etc/asound.conf. Skipping." + else + echo -e "$CONFIG_CONTENT" >> /etc/asound.conf + echo "$IDENTIFIER added to /etc/asound.conf." + fi +} + +# Check for root permissions +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit 1 +fi + +# Check for installer flag +if [ -f "$FLAG_FILE" ]; then + echo "Installer has already been run. Exiting." + exit 0 +fi + +# Check for prerequisite installer flags +if [ ! -f "i2s_speech_recognition_installed.flag" ]; then + echo "Error: i2s_speech_recognition_installed.flag not found in the current directory." + echo "Ensure that the I2S Speech Recognition setup is installed before running this script." + exit 1 +fi + +if [ ! -f "i2s_mic_installed.flag" ]; then + echo "Error: i2s_mic_installed.flag not found in the current directory." + echo "Ensure that the I2S Microphone setup is installed before running this script." + exit 1 +fi + +# Ensure running on Raspberry Pi +if ! is_raspberry_pi; then + echo "Non-Raspberry Pi board detected. Exiting." + exit 1 +fi + +# Welcome message +echo -e "\nThis script will install everything needed to use\n$PRODUCT_NAME.\n" +echo -e "--- Warning ---\n\nAlways be careful when running scripts and commands\ncopied from the internet. Ensure they are from a\ntrusted source.\n" +if ! prompt "Do you wish to continue?"; then + echo "Aborting..." + exit 0 +fi + +# Determine config file +if [ -f "$ALT_CONFIG" ]; then + CONFIG="$ALT_CONFIG" +fi + +if [ ! -f "$CONFIG" ]; then + echo "No Device Tree detected. Not supported." + exit 1 +fi + +# Add Device Tree Entry +# echo -e "\nAdding Device Tree Entry to $CONFIG" +# if grep -q "^dtoverlay=max98357a" "$CONFIG"; then +# echo "dtoverlay already active." +# else +# echo "dtoverlay=max98357a" >> "$CONFIG" +# REBOOT=true +# fi + +# Update blacklist if it exists +if [ -f "$BLACKLIST" ]; then + echo -e "\nCommenting out Blacklist entry in $BLACKLIST" + sed -i.bak \ + -e '/^blacklist[[:space:]]*snd_soc_max98357a/s/^/\#/g' \ + -e '/^blacklist[[:space:]]*snd_soc_max98357a_i2c/s/^/\#/g' "$BLACKLIST" +fi + +# Configure sound output +echo "Configuring sound output" + +# Backup existing asound.conf if not already backed up +if [ -f "/etc/asound.conf" ] && [ ! -f "/etc/asound.conf.amp.bak" ]; then + cp /etc/asound.conf /etc/asound.conf.bak + echo "Backup of /etc/asound.conf created at /etc/asound.conf.amp.bak" +fi + +# Append amplifier configuration +AMP_CONF=""" +# I2S Amplifier Configuration +pcm.speakerbonnet { + type hw card 0 +} + +pcm.dmixer { + type dmix + ipc_key 1024 + ipc_perm 0666 + slave { + pcm "speakerbonnet" + period_time 0 + period_size 1024 + buffer_size 8192 + rate 44100 + channels 2 + } +} + +ctl.dmixer { + type hw card 0 +} + +pcm.softvol { + type softvol + slave.pcm "dmixer" + control.name "PCM" + control.card 0 +} + +ctl.softvol { + type hw card 0 +} + +pcm.amplifier { + type plug + slave.pcm "softvol" +} +""" + +append_to_asound_conf "$AMP_CONF" "# I2S Amplifier Configuration" + +# Install aplay systemd service +echo "Installing aplay systemd unit" +write_file /etc/systemd/system/aplay.service "[Unit] +Description=Invoke aplay from /dev/zero at system start. + +[Service] +ExecStart=/usr/bin/aplay -D default -t raw -r 44100 -c 2 -f S16_LE /dev/zero + +[Install] +WantedBy=multi-user.target" + +systemctl daemon-reload +systemctl disable aplay + +if prompt "Activate '/dev/zero' playback in background? [RECOMMENDED]"; then + systemctl enable aplay + REBOOT=true +fi + +# Test driver and sound +if driver_loaded "max98357a"; then + echo -e "\nWe can now test your $PRODUCT_NAME" + echo "Set your speakers at a low volume if possible!" + if prompt "Do you wish to test your system now?"; then + echo "Testing... If you have issues please restart and try again. Use 'aplay -l' to check the card number (defaults here to 0)." + speaker-test -l5 -c2 -t wav -D plughw:0 + fi +fi + +# Final message +echo -e "\nAll done! Enjoy your new $PRODUCT_NAME!" +if [ "$REBOOT" = true ]; then + echo "A reboot is required to apply changes. Then run this script again." + if prompt "Reboot now?"; then + reboot + fi +fi + + + +# Create installer flag file +# touch "$FLAG_FILE" # not needed diff --git a/installers/install-mics.sh b/installers/install-mics.sh new file mode 100755 index 00000000..60c3f720 --- /dev/null +++ b/installers/install-mics.sh @@ -0,0 +1,156 @@ +#!/bin/bash + +INSTALL_FLAG="$(dirname "$0")/i2s_mic_installed.flag" + +# Function to check if the script is running as root +require_root() { + if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root. Exiting." + exit 1 + fi +} + +# Function to clear the terminal +clear_terminal() { + clear +} + +# Function to detect Raspberry Pi model +detect_pi_model() { + if ! grep -q "Raspberry Pi" /proc/device-tree/model; then + echo "Non-Raspberry Pi board detected. Exiting." + exit 1 + fi + + local model=$(cat /proc/device-tree/model) + echo "$model" +} + +# Function to prompt the user with a yes/no question +prompt_yes_no() { + local prompt="$1" + while true; do + read -p "$prompt [y/n]: " yn + case $yn in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no.";; + esac + done +} + +# Function to write to a file +write_text_file() { + local file="$1" + local content="$2" + echo "$content" > "$file" +} + +# Set up Python virtual environment if needed +setup_python_env() { + local venv_dir="$(dirname "$0")/myenv" + if [[ ! -d "$venv_dir" ]]; then + echo "Creating Python virtual environment..." + python3 -m venv "$venv_dir" + fi + echo "Activating Python virtual environment..." + source "$venv_dir/bin/activate" +} + +# Check if the script has already been run +if [[ -f $INSTALL_FLAG ]]; then + echo "I2S microphone support has already been installed. Exiting." + echo "Test with 'arecord -D plughw:2 -c2 -r 48000 -f S32_LE -t wav -V stereo -v file_stereo.wav' where plughw: is the card number shown with 'arecord -l'" + exit 0 +fi + +# Main script +require_root +clear_terminal + +cat << EOF +This script downloads and installs +I2S microphone support. +EOF + +CONFIG_FILE="/boot/firmware/config.txt" +LINE="dtoverlay=googlevoicehat-soundcard" + +# Check if the line already exists +if grep -Fxq "$LINE" "$CONFIG_FILE"; then + echo "The line '$LINE' already exists in $CONFIG_FILE." +else + # Append the line to the file + echo "$LINE" | sudo tee -a "$CONFIG_FILE" > /dev/null + echo "The line '$LINE' has been added to $CONFIG_FILE." +fi + +pi_model=$(detect_pi_model) +echo "$pi_model detected.\n" + +case "$pi_model" in + *"Raspberry Pi Zero"*) + pimodel_select=0 + ;; + *"Raspberry Pi 2 Model B"*|*"Raspberry Pi 3"*|*"Raspberry Pi Zero 2"*) + pimodel_select=1 + ;; + *"Raspberry Pi 4"*|*"Raspberry Pi Compute Module 4"*|*"Raspberry Pi 400"*|*"Raspberry Pi Compute Module 5"*|*"Raspberry Pi 5"*) + pimodel_select=2 + ;; + *) + echo "Unsupported Pi board detected. Exiting." + exit 1 + ;; +esac + +if prompt_yes_no "Auto load module at boot?"; then + auto_load=true +else + auto_load=false +fi + +cat << EOF +Installing... +EOF + +# Set up Python environment +setup_python_env + +# Install required packages +apt-get -y install git raspberrypi-kernel-headers + +# Clone the repository +# git clone https://github.com/adafruit/Raspberry-Pi-Installer-Scripts.git + +# Build and install the module +# cd Raspberry-Pi-Installer-Scripts/i2s_mic_module || exit +# make clean +# make +# make install + +# Set up auto load at boot if selected +if $auto_load; then + write_text_file /etc/modules-load.d/snd-i2smic-rpi.conf "snd-i2smic-rpi" + write_text_file /etc/modprobe.d/snd-i2smic-rpi.conf "options snd-i2smic-rpi rpi_platform_generation=$pimodel_select" +fi + +# Enable I2S overlay +if [[ -f /boot/firmware/config.txt ]]; then + sed -i -e 's/#dtparam=i2s/dtparam=i2s/g' /boot/firmware/config.txt +else + sed -i -e 's/#dtparam=i2s/dtparam=i2s/g' /boot/config.txt +fi + +# Create the install flag file to prevent reinstallation +touch $INSTALL_FLAG + +cat << EOF +DONE. + +Settings take effect on next boot. +EOF + +if prompt_yes_no "Reboot now?"; then + reboot +fi diff --git a/installers/install-speech-recognition.sh b/installers/install-speech-recognition.sh new file mode 100755 index 00000000..7aa9633b --- /dev/null +++ b/installers/install-speech-recognition.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# This script sets up speech recognition configuration for a Raspberry Pi. +# It creates and modifies the /etc/asound.conf file and sets default audio devices. + +# Check if the script has already been installed +if [ -f "i2s_speech_recognition_installed.flag" ]; then + echo "Speech recognition setup is already installed. Exiting." + exit 0 +fi + +# Check for the presence of the i2s_mic_installed.flag file +if [ ! -f "i2s_mic_installed.flag" ]; then + echo "Error: i2s_mic_installed.flag not found in the current directory. Please run 'install-mics.sh' first to set up the microphones." + exit 1 +fi + +# Function to detect the card index for the microphone +detect_microphone() { + echo "Detecting available audio devices..." + arecord -l + + echo "\nPlease enter the card index for your microphone from the list above:" + read -r mic_index + + echo "\nUsing card index: $mic_index" + echo + return $mic_index +} + +# Prompt for microphone index +detect_microphone +mic_index=$? + +# Backup the existing /etc/asound.conf file if it exists +if [ -f "/etc/asound.conf" ]; then + sudo cp /etc/asound.conf /etc/asound.conf.bak + echo "Backup of /etc/asound.conf created at /etc/asound.conf.bak" +fi + +# Create /etc/asound.conf with the required content +sudo bash -c "cat > /etc/asound.conf" < 1 and sys.argv[1] == 'manual' else Config.MODE_LIVE - def main(): print('Starting...') - - path = os.path.dirname(__file__) - log = LogWrapper(path=os.path.dirname(__file__)) - # Throw exception to safely exit script when terminated signal.signal(signal.SIGTERM, Config.exit) - + # Dynamically load and initialize modules loader = ModuleLoader(config_folder="config") module_instances = loader.load_modules() + + # Set messaging service for all modules + messaging_service = module_instances['MessagingService'].messaging_service + loader.set_messaging_service(module_instances, messaging_service) # Add your business logic here using module_instances as needed # Example: module_instances[0].some_method() @@ -37,39 +26,38 @@ def main(): # dict_keys(['ArduinoSerial', 'NeoPx', 'BrailleSpeak', 'Animate', 'Vision', 'PiTemperature', 'Servo_leg_l_hip', 'Servo_leg_l_knee', 'Servo_leg_l_ankle', 'Servo_leg_r_hip', 'Servo_leg_r_knee', 'Servo_leg_r_ankle', 'Servo_tilt', 'Servo_pan', 'Translator', 'Tracking_tracking', 'Sensor', 'Buzzer_buzzer']) # Use animate to nod head - # pub.sendMessage('animate', action='head_nod') + # messaging_service.publish('animate', action='head_nod') # sleep(1) - # pub.sendMessage('animate', action='head_shake') + # messaging_service.publish('animate', action='head_shake') # Enable translator in log wrapper # log.translator = module_instances['Translator'] # Set the translator for the log wrapper # Use braillespeak to say hi - # pub.sendMessage('speak', msg="Hi") + # messaging_service.publish('speak', msg="Hi") # Play happy birthday with buzzer - # pub.sendMessage('play', song="happy birthday") # Also available: 'merry christmas' - + # messaging_service.publish('play', song="happy birthday") # Also available: 'merry christmas' # Check temperature of Raspberry Pi - # pub.subscribe(self.handleTemp, 'temperature') # handleTemp should accept 'value' as a parameter + # messaging_service.subscribe('temperature', callback) # callback should accept 'value' as a parameter # Move pi servo - # pub.sendMessage('piservo:move', angle=30) - # pub.sendMessage('piservo:move', angle=-30) + # messaging_service.publish('piservo:move', angle=30) + # messaging_service.publish('piservo:move', angle=-30) # Move servo - # pub.sendMessage('servo::mv', percentage=50) # e.g. servo:pan:mv - # pub.sendMessage('servo::mvabs', percentage=50) # Absolute position. e.g. servo:pan:mvabs + # messaging_service.publish('servo::mv', percentage=50) # e.g. servo:pan:mv + # messaging_service.publish('servo::mvabs', percentage=50) # Absolute position. e.g. servo:pan:mvabs # Test emotion analysis - # pub.sendMessage('speech', text='I am so happy today!') + # messaging_service.publish('speech', text='I am so happy today!') # Test speech input - # pub.sendMessage('speech:listen') - + # messaging_service.publish('speech:listen') + # Start loops or other tasks - pub.sendMessage('log', msg="[Main] Loop started") + messaging_service.publish('log', message=f"[Main] Loop started using {messaging_service.protocol} protocol") second_loop = time() ten_second_loop = time() @@ -78,25 +66,32 @@ def main(): try: while loop: - pub.sendMessage('loop') + messaging_service.publish('system/loop') if time() - second_loop > 1: second_loop = time() - pub.sendMessage('loop:1') + messaging_service.publish('system/loop/1') if time() - ten_second_loop > 10: ten_second_loop = time() - pub.sendMessage('loop:10') + messaging_service.publish('system/loop/10') if time() - minute_loop > 60: minute_loop = time() - pub.sendMessage('loop:60') - # schedule.run_pending() + messaging_service.publish('system/loop/60') + + if messaging_service.protocol == 'mqtt': + sleep(0.01) # Needed to prevent MQTT broker from jamming when system/loop is included except Exception as ex: - logging.error(f"Exception: {ex}", exc_info=True) + # output exception details + print(ex) + messaging_service.publish('log', message="[Main] Exception occurred: " + str(ex)) + #output full details + import traceback + traceback.print_exc() loop = False finally: - pub.sendMessage("exit") - pub.sendMessage("log", msg="[Main] loop ended") + messaging_service.publish('system/exit') + messaging_service.publish('log', message="[Main] Loop ended") if __name__ == '__main__': main() diff --git a/module_loader.py b/module_loader.py index f5624158..2d21f5e8 100644 --- a/module_loader.py +++ b/module_loader.py @@ -44,6 +44,16 @@ def load_yaml_files(self): print(f"Error loading {file_path}: {e}") return loaded_modules + def set_messaging_service(self, module_instances, messaging_service): + """Set the messaging service for the modules.""" + # Iterate through the module instances, extract name and module + for name, module in module_instances.items(): + # get module name from object + # if module is not messaging_service: + if 'MessagingService' in name: + continue + module.messaging_service = messaging_service + def load_modules(self): """Dynamically load and instantiate the modules based on the config.""" instances = {} # Use a dictionary to store instances for easy access @@ -59,7 +69,10 @@ def load_modules(self): # Dynamically load the module spec = importlib.util.spec_from_file_location(module_name, f"{module_path}.py") mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) + try: + spec.loader.exec_module(mod) + except Exception as e: + print(f"Error loading module {module_name}: {e}") # Create instances of the module for instance_config in instances_config: @@ -69,7 +82,7 @@ def load_modules(self): # Store the instance in the dictionary instances[instance_name] = instance - pub.sendMessage('log', msg=f"[ModuleLoader] Loaded module: {module_name} instance: {instance_name}") + # print(f"[ModuleLoader] Loaded module: {module_name} instance: {instance_name}") print("All modules loaded") return instances # Return the dictionary of instances diff --git a/modules/actuators/piservo.py b/modules/actuators/piservo.py index 255c01e4..fce2818e 100644 --- a/modules/actuators/piservo.py +++ b/modules/actuators/piservo.py @@ -1,9 +1,9 @@ from gpiozero import AngularServo from modules.config import Config from time import sleep -from pubsub import pub +from modules.base_module import BaseModule -class PiServo: +class PiServo(BaseModule): def __init__(self, **kwargs): """ @@ -19,14 +19,13 @@ def __init__(self, **kwargs): - Argument: angle (int) - angle to move servo Example: - pub.sendMessage('piservo:move', angle=90) + self.publish('piservo:move', angle=90) """ self.pin = kwargs.get('pin') self.range = kwargs.get('range') self.start = kwargs.get('start', 0) self.servo = None - pub.subscribe(self.move, 'piservo:move') # print(self.range) # self.move(0) # sleep(2) @@ -36,6 +35,8 @@ def __init__(self, **kwargs): # sleep(2) self.move(self.start) + def setup_messaging(self): + self.subscribe('piservo:move', self.move) def move(self, angle): if self.servo is None: diff --git a/modules/actuators/servo.py b/modules/actuators/servo.py index 6acd03c3..6e056b3a 100644 --- a/modules/actuators/servo.py +++ b/modules/actuators/servo.py @@ -3,9 +3,9 @@ from modules.config import Config from modules.network.arduinoserial import ArduinoSerial from time import sleep -from pubsub import pub +from modules.base_module import BaseModule -class Servo: +class Servo(BaseModule): def __init__(self, **kwargs): """ @@ -32,8 +32,8 @@ def __init__(self, **kwargs): - Argument: percentage (int) - percentage to move servo Example: - pub.sendMessage('servo:pan:mvabs', percentage=90) - pub.sendMessage('servo:pan:mv', percentage=10) + self.publish('servo:pan:mvabs', percentage=90) + self.publish('servo:pan:mv', percentage=10) """ self.pin = kwargs.get('pin') self.identifier = kwargs.get('name') @@ -54,8 +54,9 @@ def __init__(self, **kwargs): self.move(self.start) - pub.subscribe(self.move, 'servo:' + self.identifier + ':mvabs') - pub.subscribe(self.move_relative, 'servo:' + self.identifier + ':mv') + def setup_messaging(self): + self.subscribe('servo:' + self.identifier + ':mvabs', self.move) + self.subscribe('servo:' + self.identifier + ':mv', self.move_relative) def __del__(self): pass #self.reset() @@ -76,7 +77,7 @@ def move_relative(self, percentage, safe=True): self.execute_move(this_move, True) self.pos = new else: - pub.sendMessage('log:error', '[Servo] Percentage %d out of range' % percentage) + self.publish('log/error', '[Servo] Percentage %d out of range' % percentage) raise ValueError('Percentage %d out of range' % percentage) else: self.execute_move([(percentage, 0)], True) @@ -91,7 +92,7 @@ def move(self, percentage, safe=True): self.execute_move([(percentage, 0)]) self.pos = new else: - pub.sendMessage('log:error', '[Servo] Percentage %d out of range' % percentage) + self.publish('log/error', '[Servo] Percentage %d out of range' % percentage) raise ValueError('Percentage %d out of range' % percentage) def execute_move(self, sequence, is_relative=False): @@ -111,7 +112,7 @@ def execute_move(self, sequence, is_relative=False): # return if self.power: - pub.sendMessage('power:use') + self.publish('power:use') if self.serial: # just move the pan servo for now. Remove after debugging # if self.index != 7 and self.index != 6 and self.index != 5 and self.index != 4 and self.index != 3 and self.index != 2: @@ -119,7 +120,7 @@ def execute_move(self, sequence, is_relative=False): type = ArduinoSerial.DEVICE_SERVO if is_relative: type = ArduinoSerial.DEVICE_SERVO_RELATIVE - pub.sendMessage('serial', type=type, identifier=self.index, message=s[0]) + self.publish('serial', type=type, identifier=self.index, message=s[0]) else: self.pi.set_servo_pulsewidth(self.pin, s[0]) if len(sequence) > 1: @@ -128,7 +129,7 @@ def execute_move(self, sequence, is_relative=False): else: pass #sleep(s[1]) if self.power and self.pos == self.start: - pub.sendMessage('power:release') + self.publish('power:release') def calculate_move(self, old, new, time=0.1, translate=False): if translate: diff --git a/modules/animate.py b/modules/animate.py index 954183bd..8f45f207 100644 --- a/modules/animate.py +++ b/modules/animate.py @@ -1,10 +1,10 @@ import json import os.path -from pubsub import pub from time import sleep from gpiozero import LED +from modules.base_module import BaseModule -class Animate: +class Animate(BaseModule): def __init__(self, **kwargs): """ Animation module to move servos in sequence @@ -16,10 +16,13 @@ def __init__(self, **kwargs): - Argument: action (string) - name of animation file Example: - pub.sendMessage('animate', action='head_nod') + self.publish('animate', action='head_nod') """ self.path = kwargs.get('path', os.path.dirname(os.path.realpath(__file__)) + '/../animations') + '/' - pub.subscribe(self.animate, "animate") + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('animate', self.animate) def animate(self, action): """ @@ -29,6 +32,8 @@ def animate(self, action): file = self.path + action + '.json' if not os.path.isfile(file): raise ValueError('Animation does not exist: ' + action) + + self.log(f"Animating: {action}") with open(file, 'r') as f: parsed = json.load(f) @@ -37,15 +42,15 @@ def animate(self, action): cmd = list(step.keys())[0] args = list(step.values()) if 'servo:' in cmd: - pub.sendMessage(cmd, percentage=args[0]) + self.publish(cmd, percentage=args[0]) elif 'sleep' == cmd: sleep(args[0]) elif 'animate' == cmd: - pub.sendMessage(cmd, action=args[0]) - elif 'led:' in cmd: - pub.sendMessage(cmd, color=args[0]) + self.publish(cmd, action=args[0]) + elif 'led/' in cmd: + self.publish(cmd, color=args[0]) elif 'speak' == cmd: - pub.sendMessage(cmd, message=args[0]) + self.publish(cmd, message=args[0]) elif 'pin' in cmd: led = LED(args[0]) if 'pin:high' == cmd: diff --git a/modules/archived/battery.py b/modules/archived/battery.py index 72a29df9..f5e83fa3 100644 --- a/modules/archived/battery.py +++ b/modules/archived/battery.py @@ -22,12 +22,12 @@ def loop(self): val = self.check() if val == 0: pub.sendMessage('led:full', color='red') - pub.sendMessage('log:error', msg="[Battery] Battery Read Error - Value: " + str(val)) + pub.sendMessage('log/error', msg="[Battery] Battery Read Error - Value: " + str(val)) return if self.low_voltage(val): pub.sendMessage('led:full', color='red') if not self.safe_voltage(val): - pub.sendMessage('log:critical', msg="[Battery] EMERGENCY SHUTDOWN! Value: " + str(val)) + pub.sendMessage('log/critical', msg="[Battery] EMERGENCY SHUTDOWN! Value: " + str(val)) pub.sendMessage('exit') sleep(5) subprocess.call(['shutdown', '-h'], shell=False) diff --git a/modules/archived/coral/tracking.py b/modules/archived/coral/tracking.py index 34bf078a..c3676fc7 100644 --- a/modules/archived/coral/tracking.py +++ b/modules/archived/coral/tracking.py @@ -62,10 +62,10 @@ def track_largest_match(self, matches, labels): if x_move: pub.sendMessage('servo:pan:mv', percentage=-x_move) - pub.sendMessage('log:info', msg="[Tracking] panning " + str(x_move) + "%") + pub.sendMessage('log/info', msg="[Tracking] panning " + str(x_move) + "%") if y_move: pub.sendMessage('servo:tilt:mv', percentage=-y_move) - pub.sendMessage('log:info', msg="[Tracking] tilting " + str(-y_move) + "%") + pub.sendMessage('log/info', msg="[Tracking] tilting " + str(-y_move) + "%") #stop_moving = Timer(abs(max(x_move,y_move))/5, self.debounce) # Wait for some time depending on amount of movement stop_moving = Timer(Tracking.DEBOUNCE_TIME, self.debounce) # Wait for 2 seconds until this behaviour is improved. Then time can be reduced. diff --git a/modules/archived/main_viam.py b/modules/archived/main_viam.py index 6c059537..a22b3eee 100755 --- a/modules/archived/main_viam.py +++ b/modules/archived/main_viam.py @@ -191,7 +191,7 @@ async def main(): # output stack trace print(ex.with_traceback()) print(message) - pub.sendMessage('log:error', msg=ex) + pub.sendMessage('log/error', msg=ex) loop = False sleep(5) quit() diff --git a/modules/archived/opencv/tracking.py b/modules/archived/opencv/tracking.py index dccb8150..8ce5a6ee 100644 --- a/modules/archived/opencv/tracking.py +++ b/modules/archived/opencv/tracking.py @@ -59,10 +59,10 @@ def track_largest_match(self): if x_move: pub.sendMessage('servo:pan:mv', percentage=x_move) - pub.sendMessage('log:info', msg="[Tracking] panning " + str(x_move) + "%") + pub.sendMessage('log/info', msg="[Tracking] panning " + str(x_move) + "%") if y_move: pub.sendMessage('servo:tilt:mv', percentage=-y_move) - pub.sendMessage('log:info', msg="[Tracking] tilting " + str(-y_move) + "% and moving neck " + str(y_move) + "%") + pub.sendMessage('log/info', msg="[Tracking] tilting " + str(-y_move) + "% and moving neck " + str(y_move) + "%") return True @staticmethod diff --git a/modules/audio/braillespeak.py b/modules/audio/braillespeak.py index 07f2284c..cbccb533 100644 --- a/modules/audio/braillespeak.py +++ b/modules/audio/braillespeak.py @@ -1,8 +1,9 @@ import time -from pubsub import pub #import pysine #@todo this breaks the microphone (https://trello.com/c/qNVW2I5O/44-audio-reactions) -class BrailleSpeak: +from modules.base_module import BaseModule + +class BrailleSpeak(BaseModule): """ Communicate with tones, letters converted to tone pairs Uses Buzzer module to play tones via pubsub @@ -12,11 +13,11 @@ class BrailleSpeak: Subscribes to 'speak' event Example: - pub.sendMessage('speak', msg="Hi") + self.publish('speak', msg="Hi") """ def __init__(self, **kwargs): - pub.subscribe(self.send, 'speak') + self.pin = kwargs.get('pin') self.speaker = False self.duration = kwargs.get('duration', 100 / 1000) # ms to seconds @@ -52,6 +53,9 @@ def __init__(self, **kwargs): [5, 7], [5, 3] ] + + def setup_messaging(self): + self.subscribe('speak', self.send) def exit(self): pass @@ -66,7 +70,7 @@ def handle_char(self, char): pass #pysine.sine(frequency=self.notes[n], duration=self.duration) else: - pub.sendMessage('buzz', frequency=self.notes[n], length=self.duration) + self.publish('buzz', frequency=self.notes[n], length=self.duration) time.sleep(self.duration / 2) def send(self, msg): diff --git a/modules/audio/buzzer.py b/modules/audio/buzzer.py index 71fc2789..d6e31941 100644 --- a/modules/audio/buzzer.py +++ b/modules/audio/buzzer.py @@ -2,13 +2,13 @@ from gpiozero.tones import Tone import time -from pubsub import pub - from modules.audio.melodies.deck_the_halls import MelodyDeckTheHalls from modules.audio.melodies.happy_birthday import MelodyHappyBirthday from modules.audio.melodies.notes import MelodyNotes -class Buzzer: +from modules.base_module import BaseModule + +class Buzzer(BaseModule): def __init__(self, **kwargs): """ Buzzer class @@ -19,15 +19,16 @@ def __init__(self, **kwargs): Subscribe to 'play' and 'buzz' events Example: - pub.sendMessage('play', song="happy birthday") # Also available: 'merry christmas' - pub.sendMessage('buzz', frequency=440, length=0.5) + self.publish('play', song="happy birthday") # Also available: 'merry christmas' + self.publish('buzz', frequency=440, length=0.5) """ self.pin = kwargs.get('pin') self.buzzer = TonalBuzzer(self.pin) - - pub.subscribe(self.play_song, 'play') - pub.subscribe(self.buzz, 'buzz') + + def setup_messaging(self): + self.subscribe('play', self.play_song) + self.subscribe('buzz', self.buzz) def buzz(self, frequency, length): """ diff --git a/modules/audio/speechinput.py b/modules/audio/speechinput.py index ecc4aefc..d72c496b 100644 --- a/modules/audio/speechinput.py +++ b/modules/audio/speechinput.py @@ -1,15 +1,15 @@ import speech_recognition as sr -from pubsub import pub from time import sleep from threading import Thread +from modules.base_module import BaseModule -class SpeechInput: +class SpeechInput(BaseModule): """ Use speech_recognition to detect and interpret audio """ def __init__(self, **kwargs): self.recognizer = sr.Recognizer() - self.recognizer.pause_threshold = 1 + self.recognizer.pause_threshold = 2 self.device_name = kwargs.get('device_name', 'lp') self.device = self.get_device_index(self.device_name) @@ -17,11 +17,17 @@ def __init__(self, **kwargs): self.mic = sr.Microphone(device_index=self.device, sample_rate=self.sample_rate) self.listening = False - - pub.subscribe(self.start, 'speech:listen') - pub.subscribe(self.stop, 'rest') - pub.subscribe(self.stop, 'sleep') - pub.subscribe(self.stop, 'exit') + + self.start_on_boot = kwargs.get('start_on_boot', False) + + def setup_messaging(self): + self.subscribe('speech:listen', self.start) + self.subscribe('rest', self.stop) + self.subscribe('sleep', self.stop) + self.subscribe('exit', self.stop) + self.log('Mapping mic to index ' + str(self.device)) + if self.start_on_boot: + self.start() def __del__(self): self.stop() @@ -33,9 +39,9 @@ def start(self): def get_device_index(self, device_name): for index, name in enumerate(sr.Microphone.list_microphone_names()): - # print("Microphone with name \"{1}\" found for `Microphone(device_index={0})`".format(index, name)) + print("Microphone with name \"{1}\" found for `Microphone(device_index={0})`".format(index, name)) if name == device_name: - pub.sendMessage('log', msg='[Speech] Mapping mic to index ' + str(index)) + print('Mapping mic to index ' + str(index)) return index def detect(self): @@ -43,30 +49,35 @@ def detect(self): Not background :return: """ - pub.sendMessage('log', msg='[Speech] Initialising Detection') + self.log('Initialising Detection') with self.mic as source: - self.recognizer.adjust_for_ambient_noise(source) - pub.sendMessage('log', msg='[Speech] Detecting...') + self.recognizer.adjust_for_ambient_noise(source, duration=2) + # self.recognizer.energy_threshold = 300 # Adjust based on your environment + self.log('Detecting...') while self.listening: try: - audio = self.recognizer.listen(source)#, timeout=10, phrase_time_limit=5) - # pub.sendMessage('led', identifiers='top5', color='white') - # pub.sendMessage('log', msg='[Speech] End Detection') - + audio = self.recognizer.listen(source, timeout=10, phrase_time_limit=15) val = self.recognizer.recognize_google(audio) - pub.sendMessage('log', msg='[Speech] I heard: ' + str(val)) - pub.sendMessage('speech', text=val.lower()) + + #save audio with filename as val substituting any non alphanumeric characters with underscores + filename = str(val).replace(' ', '_').replace('[^a-zA-Z0-9]', '_') + with open("speech_" + filename + ".wav", "wb") as f: + f.write(audio.get_wav_data()) + + self.log('I heard: ' + str(val)) + self.publish('speech', text=val.lower()) + self.publish('tts', msg='I heard ' + val.lower()) except sr.WaitTimeoutError as e: - pub.sendMessage('log:error', msg='[Speech] Timeout Error: ' + str(e)) + self.publish('log/error', message='[Speech] Timeout Error: ' + str(e)) except sr.UnknownValueError as e: pass - # pub.sendMessage('log:error', msg='[Speech] Detection Error: ' + str(e)) + # self.publish('log/error', msg='[Speech] Detection Error: ' + str(e)) # finally: - # pub.sendMessage('led', identifiers='top5', color='off') + # self.publish('led', identifiers='top5', color='off') def stop(self): self.listening = False - pub.sendMessage('log', msg='[Speech] Stopping') + self.log('Stopping') # allow script to be run directly if __name__ == '__main__': diff --git a/modules/audio/tts.py b/modules/audio/ttsmodule.py similarity index 61% rename from modules/audio/tts.py rename to modules/audio/ttsmodule.py index 0c6e0a9f..6d3c939a 100644 --- a/modules/audio/tts.py +++ b/modules/audio/ttsmodule.py @@ -1,11 +1,12 @@ -from pubsub import pub from time import sleep import pyttsx3 -from elevenlabs import ElevenLabs, VoiceSettings, play +import elevenlabs import os -class TTS: +from modules.base_module import BaseModule + +class TTSModule(BaseModule): def __init__(self, **kwargs): """ @@ -23,18 +24,23 @@ def __init__(self, **kwargs): - Argument: msg (string) - message to speak Example: - pub.sendMessage('tts', msg='Hello, World!') + self.publish('tts', msg='Hello, World!') """ self.translator = kwargs.get('translator', None) self.service = kwargs.get('service', 'pyttsx3') + # print(self.service) + self.voice_id = kwargs.get('voice_id', '') + # print(self.voice_id) if self.service == 'elevenlabs': - self.init_elevenlabs(kwargs.get('voice_id', '')) + self.init_elevenlabs(self.voice_id) else: self.init_pyttsx3() + def setup_messaging(self): # Set subscribers - pub.subscribe(self.speak, 'tts') + self.subscribe('tts', self.speak) def speak(self, msg): + # print('Attempting to speak with service: ' + self.service) if self.service == 'elevenlabs': self.speak_elevenlabs(msg) else: @@ -48,43 +54,49 @@ def init_pyttsx3(self): #for i in voices: engine.setProperty('voice', voices[10].id) print('voice' + voices[10].id) - #engine.say('Hello, World!') - #engine.runAndWait() self.engine = engine def speak_pyttsx3(self, msg): - pub.sendMessage('log', msg="[TTS] {}".format(msg)) + self.log("{}".format(msg)) if self.translator is not None: msg = self.translator.request(msg) - self.engine.say(msg) + + self.engine.say(f"{msg}. I have spoken.") # Apparently this is the only way to get pyttsx3 to output anything (by including actual text) + # self.engine.say("{}".format(msg)) # @todo: Test this + + # self.engine.say(msg) # This doesn't output anything self.engine.runAndWait() def init_elevenlabs(self, voice_id): - self.client = ElevenLabs( + self.ttsclient = elevenlabs.ElevenLabs( api_key=os.getenv('ELEVENLABS_KEY') or '' ) self.voice_id = voice_id def speak_elevenlabs(self, msg): # This uses ElevenLabs, create an API key and export in your .bashrc file using `export ELEVENLABS_KEY=` before use - output = self.client.text_to_speech.convert( + output = self.ttsclient.text_to_speech.convert( voice_id=self.voice_id, optimize_streaming_latency="0", output_format="mp3_22050_32", - text="msg", - voice_settings=VoiceSettings( + text=msg, + voice_settings=elevenlabs.VoiceSettings( stability=0.1, similarity_boost=0.3, style=0.2, ), ) - play(output) + elevenlabs.play(output) if __name__ == '__main__': - tts = TTS() - tts.speak('Test') + # test with `myenv/bin/python3 modules/audio/ttsmodule.py` + tts = TTSModule() + tts.speak("this is a test") # broken currently, thinks espeak-ng is not installed - tts2 = TTS(service='elevenlabs', voice_id='pMsXgVXv3BLzUgSXRplE') + tts2 = TTSModule(service='elevenlabs', voice_id='pMsXgVXv3BLzUgSXRplE') tts2.speak('Test') + + # import speechinput as speechinput + # speech = speechinput.SpeechInput(device_name='pulse', start_on_boot=True) diff --git a/modules/base_module.py b/modules/base_module.py new file mode 100644 index 00000000..5d6b2a9e --- /dev/null +++ b/modules/base_module.py @@ -0,0 +1,41 @@ +import inspect +class BaseModule: + + @property + def messaging_service(self): + """Getter for messaging service.""" + return self._messaging_service + + @messaging_service.setter + def messaging_service(self, service): + """Setter for messaging service, ensures setup is called.""" + self._messaging_service = service + self.setup_messaging() + + def setup_messaging(self): + """Override this method in child classes to subscribe to topics.""" + pass # No default implementation, subclasses should define their own subscriptions + + def publish(self, topic, *args, **kwargs): + if self.messaging_service is None: + raise ValueError("Messaging service not set.") + self.messaging_service.publish(topic, *args, **kwargs) + + def subscribe(self, topic, callback, **kwargs): + if self.messaging_service is None: + raise ValueError("Messaging service not set.") + self.messaging_service.subscribe(topic, callback, **kwargs) + + def log(self, message, level='info'): + """ + Advanced logging, includes class name, method name, and line number to message string + """ + # get class name, method name + class_name = self.__class__.__name__ + method_name = inspect.stack()[1].function + + #get line number of calling class + frame = inspect.stack()[1] + + message = f"[{class_name}.{method_name}:{frame.lineno}] {str(message)}" + self.publish(f'log/{level}', message=message) \ No newline at end of file diff --git a/modules/behaviours/boredom.py b/modules/behaviours/boredom.py deleted file mode 100644 index 03f4aafe..00000000 --- a/modules/behaviours/boredom.py +++ /dev/null @@ -1,26 +0,0 @@ -from random import randint, randrange -from pubsub import pub -from time import sleep - -class Boredom: - def __init__(self, state): - self.state = state # the personality instance - pub.subscribe(self.behave_minute, 'loop:60') - pub.subscribe(self.do_something, 'boredom:action') - - def behave_minute(self): - if not self.state.is_resting() and randrange(5) is 1: - self.do_something() - - - def do_something(self): - # Random action to simulate behaviour, then reset. WIP. - actions = ['sleep', 'look_up', 'look_down', 'head_shake', 'head_nod', 'head_left', 'speak'] - action = actions[randrange(len(actions)-1)] - pub.sendMessage('log', msg='[Personality] Boredom action: ' + str(action)) - if action is 'speak': - pub.sendMessage('speak', msg='hi') - else: - pub.sendMessage('animate', action=action) - sleep(randrange(3)) - pub.sendMessage('animate', action="wake") \ No newline at end of file diff --git a/modules/behaviours/dream.py b/modules/behaviours/dream.py deleted file mode 100644 index b4d82334..00000000 --- a/modules/behaviours/dream.py +++ /dev/null @@ -1,13 +0,0 @@ -from pubsub import pub -from modules.config import Config - -class Dream: - def __init__(self, state): - self.state = state # the personality instance - pub.subscribe(self.behave_nightly, 'loop:nightly') - - def behave_nightly(self): - # This will attempt to process anything in the 'matches/verified' directory, or return if nothing to process - if self.state.is_asleep() and Config.is_night(): - pub.sendMessage('log', msg="[Personality] Training model") - pub.sendMessage('vision:train') \ No newline at end of file diff --git a/modules/behaviours/faces.py b/modules/behaviours/faces.py deleted file mode 100644 index a6e7b564..00000000 --- a/modules/behaviours/faces.py +++ /dev/null @@ -1,34 +0,0 @@ -from random import randint, randrange -from pubsub import pub -from datetime import datetime, timedelta - -from modules.config import Config - -class Faces: - def __init__(self, state): - self.state = state # the personality instance - self.last_face = None - self.last_face_name = None - self.current_faces = [] - self.face_detected = None - pub.subscribe(self.face, 'vision:detect:face') - pub.subscribe(self.noface, 'vision:nomatch') - - def noface(self): - if self.face_detected: - pub.sendMessage('log:info', msg='[Personality] No face matches found') - self.face_detected = False - - def face(self, name): - if not self.face_detected: - pub.sendMessage('log:info', msg='[Personality] Face detected: ' + str(name)) - self.face_detected = True - self.last_face = datetime.now() - # self.state.set_state(Config.STATE_IDLE) - self.state.set_eye('green') - if name not in self.current_faces: - self.current_faces.append(name) - #pub.sendMessage('speak', msg='hi') - pub.sendMessage('tts', msg='hello there') - if name != 'unknown': - self.last_face_name = name \ No newline at end of file diff --git a/modules/behaviours/feel.py b/modules/behaviours/feel.py deleted file mode 100644 index 86d2ca72..00000000 --- a/modules/behaviours/feel.py +++ /dev/null @@ -1,135 +0,0 @@ -from pubsub import pub -from datetime import datetime, timedelta -from random import randint, randrange -from modules.config import Config - -class Feel: - INPUT_TYPE_INTERESTING = 0 - INPUT_TYPE_COMPANY = 1 - INPUT_TYPE_SCARY = 2 - INPUT_TYPE_FUN = 3 - INPUT_TYPE_STARTLING = 4 - INPUT_TYPE_MAX = 5 - - RANGE_MAX = 100 - RANGE_MIN = 0 - - BEHAVE_INTERVAL = 2 - OUTPUT_INTERVAL = 30 - - def __init__(self, state): - self.happiness = Feel.RANGE_MAX / 2 - self.contentment = Feel.RANGE_MAX / 2 - self.attention = Feel.RANGE_MAX / 2 - self.wakefulness = Feel.RANGE_MAX / 2 - - self.state = state # the personality instance - pub.subscribe(self.loop, 'loop:1') - pub.subscribe(self.feel, 'loop:10') - pub.subscribe(self.loop_minute, 'loop:60') - # pub.subscribe(self.face, 'vision:detect:face') # every loop if a face is detected - # pub.subscribe(self.motion, 'motion') # every second when detected - pub.subscribe(self.speech, 'speech') # Speech input detected - pub.subscribe(self.puppet, 'puppet') # Being puppeteered - - def loop(self): - - # Throttle face detection behaviour to every second, rather than every loop - if self.state.behaviours.faces.face_detected: - self.input(Feel.INPUT_TYPE_INTERESTING) - - # If someone is nearby - if self.state.behaviours.motion.is_motion(): - self.input(Feel.INPUT_TYPE_COMPANY) - - def feel(self): - # Get gradually bored and tired - self.attention = self.limit(self.attention - randint(5,10)) - self.happiness = self.limit(self.happiness - randint(5, 10)) - self.wakefulness = self.limit(self.wakefulness - randint(1, 2)) - self.contentment = self.limit(self.contentment - randint(5, 10)) - # print(f'[Feelings] {str(self.attention)} {str(self.happiness)} {str(self.wakefulness)} {str(self.contentment)}') - - def loop_minute(self): - # print(f"[Feelings] {str(self.attention)} {str(self.happiness)} {str(self.wakefulness)} {str(self.contentment)}") - pub.sendMessage('log', msg='[Feeling]' + str(self.get_feelings())) - pub.sendMessage('led', identifiers='status3', color=self.attention, gradient='bg') - pub.sendMessage('led', identifiers='status4', color=self.happiness, gradient='bg') - - def get_feelings(self): - feelings = [] - if self.attention > 90 and self.wakefulness > 90: - feelings.append('excited') - if self.happiness < 10: - feelings.append('sad') - if self.attention < 30: - feelings.append('bored') - if self.wakefulness < 20: - feelings.append('tired') - if self.wakefulness < 0: - feelings.append('asleep') - if self.contentment < 20: - feelings.append('restless') - if len(feelings) == 0: - feelings.append('ok') - return feelings - - def input(self, input_type): - # print('Feeling input: ' + str(input_type)) - if input_type == Feel.INPUT_TYPE_INTERESTING: - # Should make me more attentive and wake me up a little - self.attention = Feel.RANGE_MAX - self.happiness += 10 - self.wakefulness += 10 - self.contentment += 30 - elif input_type == Feel.INPUT_TYPE_COMPANY: - # Has to keep me awake a little, otherwise nothing wakes me up again! - self.happiness += 10 - self.wakefulness += 5 - self.contentment += 10 - elif input_type == Feel.INPUT_TYPE_SCARY: - # Should make me more attentive, but less content and happy - self.attention = Feel.RANGE_MAX - self.happiness -= 20 - self.wakefulness += 50 - self.contentment -= 30 - elif input_type == Feel.INPUT_TYPE_FUN: - # Should make me much happer and attentive, wake me up and make me feel more content - self.attention += 50 - self.happiness += 50 - self.wakefulness += 50 - self.contentment += 50 - elif input_type == Feel.INPUT_TYPE_STARTLING: - # Should make me more attentive and awake, but less content and happy - self.attention = Feel.RANGE_MAX - self.happiness -= 40 - self.wakefulness = Feel.RANGE_MAX - self.contentment -= 10 - elif input_type == Feel.INPUT_TYPE_MAX: - # Should make me more attentive and awake, but less content and happy - self.attention = Feel.RANGE_MAX - self.happiness = Feel.RANGE_MAX - self.wakefulness = Feel.RANGE_MAX - self.contentment = Feel.RANGE_MAX - # print(str(self.attention) + ' ' + str(self.happiness) + ' ' + str(self.wakefulness) + ' ' + str(self.contentment)) - - @staticmethod - def limit(val): - if val > Feel.RANGE_MAX: - val = Feel.RANGE_MAX - elif val < Feel.RANGE_MIN: - val = Feel.RANGE_MIN - return val - - def speech(self, text): - # It's fun to talk to someone - self.input(Feel.INPUT_TYPE_FUN) - - def puppet(self): - self.input(Feel.INPUT_TYPE_MAX) - - # def motion(self): - # self.input(Feel.INPUT_TYPE_COMPANY) - # - # def face(self, name): - # self.input(Feel.INPUT_TYPE_INTERESTING) \ No newline at end of file diff --git a/modules/behaviours/motion.py b/modules/behaviours/motion.py deleted file mode 100644 index 123b087b..00000000 --- a/modules/behaviours/motion.py +++ /dev/null @@ -1,21 +0,0 @@ -from random import randint, randrange -from pubsub import pub -from datetime import datetime, timedelta - -from modules.config import Config - -class Motion: - def __init__(self, state): - self.state = state # the personality instance - self.last_motion = datetime.now() - pub.subscribe(self.motion, 'motion') - - def motion(self): - self.last_motion = datetime.now() - # print(self.last_motion) - if not self.state.behaviours.faces.face_detected and self.state.lt(self.state.behaviours.faces.last_face, self.state.past(2)): - self.state.set_eye('blue') - pub.sendMessage('vision:start') - - def is_motion(self): - return not self.state.lt(self.state.behaviours.motion.last_motion, self.state.past(2)) \ No newline at end of file diff --git a/modules/behaviours/objects.py b/modules/behaviours/objects.py deleted file mode 100644 index fab94352..00000000 --- a/modules/behaviours/objects.py +++ /dev/null @@ -1,26 +0,0 @@ -from random import randint, randrange -from pubsub import pub -from datetime import datetime, timedelta - -from modules.config import Config - -class Objects: - def __init__(self, state): - self.state = state # the personality instance - self.last_detection = None - self.is_detected = None - pub.subscribe(self.object, 'vision:detect:object') - pub.subscribe(self.noobject, 'vision:nomatch') - - def noobject(self): - if self.is_detected: - pub.sendMessage('log:info', msg='[Personality] No object matches found') - self.is_detected = False - - def object(self, name): - if not self.is_detected: - pub.sendMessage('log:info', msg='[Personality] Object detected: ' + name) - self.is_detected = True - self.last_detection = datetime.now() - # self.state.set_state(Config.STATE_IDLE) - self.state.set_eye('purple') diff --git a/modules/behaviours/respond.py b/modules/behaviours/respond.py deleted file mode 100644 index 1bd234ae..00000000 --- a/modules/behaviours/respond.py +++ /dev/null @@ -1,48 +0,0 @@ -from pubsub import pub -from time import sleep, localtime -from modules.config import Config -from random import randrange - -class Respond: - - def __init__(self, state): - self.state = state # the personality instance - pub.subscribe(self.speech, 'speech') - pub.subscribe(self.tracking, 'tracking:match') - - def speech(self, text): - if self.state.is_resting(): - return - - action = None - if 'are you sure' in text: - action = 'head_nod' - if 'you like' in text: - actions = ['head_shake', 'head_nod', 'speak'] - action = actions[abs(hash(text.split('like ')[1])) % len(actions)-1] # choose from the number of actions by hashing the item, so the answer is always the same - # action = actions[randrange(len(actions) - 1)] - - if action: - pub.sendMessage('log', msg='[Personality] Respond action: ' + str(action)) - if action is 'speak': - pub.sendMessage('speak', msg=text) - else: - pub.sendMessage('animate', action=action) - - def tracking(self, largest, screen): - """ - Show the position of the largest match in the eye LEDs - """ - if largest is None: - return - - (x, y, w, h) = largest - if x + (w / 2) < (screen[0] / 2) - 60: - pub.sendMessage('led', identifiers=['left', 'middle'], color='off') - pub.sendMessage('led', identifiers='right', color='green') - elif x + (w / 2) > (screen[0] / 2) + 60: - pub.sendMessage('led', identifiers=['right', 'middle'], color='off') - pub.sendMessage('led', identifiers='left', color='green') - else: - pub.sendMessage('led', identifiers=['left', 'right'], color='off') - pub.sendMessage('led', identifiers='middle', color='green') \ No newline at end of file diff --git a/modules/behaviours/sentiment.py b/modules/behaviours/sentiment.py deleted file mode 100644 index e57be153..00000000 --- a/modules/behaviours/sentiment.py +++ /dev/null @@ -1,29 +0,0 @@ -from pubsub import pub -from time import sleep, localtime -from modules.config import Config -from random import randrange - -import nltk -from nltk.sentiment.vader import SentimentIntensityAnalyzer - -class Sentiment: - - def __init__(self, state): - - self.state = state # the personality instance - pub.subscribe(self.speech, 'speech') - # Do this the first time - nltk.download('vader_lexicon') - # initialize NLTK sentiment analyzer - self.analyzer = SentimentIntensityAnalyzer() - - def speech(self, text): - if self.state.is_resting(): - return - score = self.get_sentiment(text) - pub.sendMessage('sentiment', score=score) - - def get_sentiment(self, text): - scores = self.analyzer.polarity_scores(text) - pub.sendMessage('log', msg='[Sentiment] ' + str(scores)) - return scores['compound'] diff --git a/modules/behaviours/sleep.py b/modules/behaviours/sleep.py deleted file mode 100644 index b1ad94e2..00000000 --- a/modules/behaviours/sleep.py +++ /dev/null @@ -1,32 +0,0 @@ -from pubsub import pub -from time import sleep, localtime -from modules.config import Config -class Sleep: - SLEEP_TIMEOUT = 2 * 60 - REST_TIMEOUT = 2 * 60 - - def __init__(self, state): - self.state = state # the personality instance - pub.subscribe(self.loop, 'loop:1') - - def loop(self): - if self.state.is_asleep(): - sleep(5) - - # if sleeping and not tired, then wake (during the day) - if self.state.is_asleep() and not Config.is_night() and 'tired' not in self.state.behaviours.feel.get_feelings(): - self.state.set_state(Config.STATE_RESTING) - - # if not sleeping tired, sleep - elif not self.state.is_asleep() and 'tired' in self.state.behaviours.feel.get_feelings(): - self.state.set_state(Config.STATE_SLEEPING) - - # if not resting and bored, rest - elif not self.state.is_resting() and 'bored' in self.state.behaviours.feel.get_feelings(): - self.state.set_state(Config.STATE_RESTING) - - elif 'ok' in self.state.behaviours.feel.get_feelings(): - self.state.set_state(Config.STATE_IDLE) - - elif 'excited' in self.state.behaviours.feel.get_feelings(): - self.state.set_state(Config.STATE_ALERT) \ No newline at end of file diff --git a/modules/chatgpt.py b/modules/chatgpt.py index 930e496d..4ec621ec 100644 --- a/modules/chatgpt.py +++ b/modules/chatgpt.py @@ -1,11 +1,11 @@ -from pubsub import pub from time import sleep import os import re from openai import OpenAI +from modules.base_module import BaseModule -class ChatGPT: +class ChatGPT(BaseModule): def __init__(self, **kwargs): """ ChatGPT class @@ -16,18 +16,19 @@ def __init__(self, **kwargs): Requires API key environment variable OPENAI_API_KEY Read here for config steps : https://platform.openai.com/docs/quickstart - Install: pip install openai - Subscribes to 'speech' to chat - Argument: text (string) - message to chat Example: - pub.sendMessage('speech', text='Can you hear me?') + self.publish('speech', text='Can you hear me?') """ self.persona = kwargs.get('persona', 'You are a helpful assistant. You respond with short phrases where possible.') self.model = kwargs.get('model', 'gpt-4o-mini') self.client = OpenAI() - pub.subscribe(self.completion, 'speech') + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('speech', self.completion) def completion(self, text): """ @@ -35,15 +36,15 @@ def completion(self, text): :param text: message to chat Publishes 'log' with response - Publishes 'animate' with head nod or shake + Publishes 'animate' with available animations listed in config yaml Publishes 'tts' with response """ completion = self.client.chat.completions.create( - model="gpt-4o-mini", + model=self.model, messages=[ { "role": "system", - "content": "You are a helpful assistant. You respond with short phrases or yes / no as a strong preference." + "content": self.persona }, { "role": "user", @@ -52,18 +53,15 @@ def completion(self, text): ] ) - pub.sendMessage('log', msg='[ChatGPT] ' + completion.choices[0].message.content) - # print(completion.choices[0].message.content) - output = re.sub(r'[^\w\s]','',completion.choices[0].message.content).lower() - # print(output) - if output == 'yes': - # Nod head if answer is just 'yes' - pub.sendMessage('animate', action='head_nod') - elif output == 'no': - # Shake head if answer is just 'no' - pub.sendMessage('animate', action='head_shake') - pub.sendMessage('tts', msg=completion.choices[0].message.content) - return completion.choices[0].message.content + output = completion.choices[0].message.content + self.log(output) + # if output includes 'animate:', split on colon and sendMessage 'animate' with action + if 'animate:' in output: + action = output.split(':')[1] + self.publish('animate', action=action) + else: + self.publish('tts', msg=output) + return output if __name__ == '__main__': diff --git a/modules/display/lib/LCD_0inch96.py b/modules/display/lib/LCD_0inch96.py new file mode 100644 index 00000000..0f997cc6 --- /dev/null +++ b/modules/display/lib/LCD_0inch96.py @@ -0,0 +1,178 @@ + +import time +from . import lcdconfig + +class LCD_0inch96(lcdconfig.RaspberryPi): + + width = 160 + height = 80 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x11) + time.sleep(0.1) + self.command(0x21) + self.command(0x21) + + self.command(0xB1) + self.data(0x05) + self.data(0x3A) + self.data(0x3A) + + self.command(0xB2) + self.data(0x05) + self.data(0x3A) + self.data(0x3A) + + self.command(0xB3) + self.data(0x05) + self.data(0x3A) + self.data(0x3A) + self.data(0x05) + self.data(0x3A) + self.data(0x3A) + + self.command(0xB4) + self.data(0x03) + + self.command(0xC0) + self.data(0x62) + self.data(0x02) + self.data(0x04) + + self.command(0xC1) + self.data(0xC0) + + self.command(0xC2) + self.data(0x0D) + self.data(0x00) + + self.command(0xC3) + self.data(0x8D) + self.data(0x6A) + + self.command(0xC4) + self.data(0x8D) + self.data(0xEE) + + self.command(0xC5) + self.data(0x0E) + + self.command(0xE0) + self.data(0x10) + self.data(0x0E) + self.data(0x02) + self.data(0x03) + self.data(0x0E) + self.data(0x07) + self.data(0x02) + self.data(0x07) + self.data(0x0A) + self.data(0x12) + self.data(0x27) + self.data(0x37) + self.data(0x00) + self.data(0x0D) + self.data(0x0E) + self.data(0x10) + + self.command(0xE1) + self.data(0x10) + self.data(0x0E) + self.data(0x03) + self.data(0x03) + self.data(0x0F) + self.data(0x06) + self.data(0x02) + self.data(0x08) + self.data(0x0A) + self.data(0x13) + self.data(0x26) + self.data(0x36) + self.data(0x00) + self.data(0x0D) + self.data(0x0E) + self.data(0x10) + + self.command(0x3A) + self.data(0x05) + + self.command(0x36) + self.data(0xA8) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + Xstart=Xstart+1 + Xend=Xend+1 + Ystart=Ystart+26 + Yend=Yend+26 + self.command(0x2A) + self.data(0x00) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(0x00) #Set the horizontal end to the high octet + self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(0x00) + self.data((Ystart & 0xff)) + self.data(0x00) + self.data((Yend - 1) & 0xff ) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + if imwidth != self.height or imheight != self.width: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.height,self.width)) + else: + img = self.np.asarray(Image) + pix = self.np.zeros((self.width,self.hight,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8), self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + else: + img = self.np.asarray(Image) + pix = self.np.zeros((self.height,self.width,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8), self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + + for i in range(0,len(pix),4096):#The length is 160*160*2 Actually 160*80*2 So take 2 + self.spi_writebyte(pix[i:i+4096]) + + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height ) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) diff --git a/modules/display/lib/LCD_1inch14.py b/modules/display/lib/LCD_1inch14.py new file mode 100644 index 00000000..c99b8621 --- /dev/null +++ b/modules/display/lib/LCD_1inch14.py @@ -0,0 +1,152 @@ + +import time +from . import lcdconfig + +class LCD_1inch14(lcdconfig.RaspberryPi): + + width = 240 + height = 135 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x70) #self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x19) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x12) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0xD0) + self.data(0x04) + self.data(0x0D) + self.data(0x11) + self.data(0x13) + self.data(0x2B) + self.data(0x3F) + self.data(0x54) + self.data(0x4C) + self.data(0x18) + self.data(0x0D) + self.data(0x0B) + self.data(0x1F) + self.data(0x23) + + self.command(0xE1) + self.data(0xD0) + self.data(0x04) + self.data(0x0C) + self.data(0x11) + self.data(0x13) + self.data(0x2C) + self.data(0x3F) + self.data(0x44) + self.data(0x51) + self.data(0x2F) + self.data(0x1F) + self.data(0x1F) + self.data(0x20) + self.data(0x23) + + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data((Xstart+40)>>8& 0xff) #Set the horizontal starting point to the high octet + self.data((Xstart+40) & 0xff) #Set the horizontal starting point to the low octet + self.data((Xend-1+40)>>8& 0xff) #Set the horizontal end to the high octet + self.data((Xend-1+40) & 0xff) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data((Ystart+53)>>8& 0xff) + self.data((Ystart+53) & 0xff) + self.data((Yend-1+53)>>8& 0xff) + self.data((Yend-1+53) & 0xff) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.height,self.width,2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + diff --git a/modules/display/lib/LCD_1inch28.py b/modules/display/lib/LCD_1inch28.py new file mode 100644 index 00000000..447a3976 --- /dev/null +++ b/modules/display/lib/LCD_1inch28.py @@ -0,0 +1,308 @@ + +import time +from . import lcdconfig + +class LCD_1inch28(lcdconfig.RaspberryPi): + + width = 240 + height = 240 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0xEF) + self.command(0xEB) + self.data(0x14) + + self.command(0xFE) + self.command(0xEF) + + self.command(0xEB) + self.data(0x14) + + self.command(0x84) + self.data(0x40) + + self.command(0x85) + self.data(0xFF) + + self.command(0x86) + self.data(0xFF) + + self.command(0x87) + self.data(0xFF) + + self.command(0x88) + self.data(0x0A) + + self.command(0x89) + self.data(0x21) + + self.command(0x8A) + self.data(0x00) + + self.command(0x8B) + self.data(0x80) + + self.command(0x8C) + self.data(0x01) + + self.command(0x8D) + self.data(0x01) + + self.command(0x8E) + self.data(0xFF) + + self.command(0x8F) + self.data(0xFF) + + + self.command(0xB6) + self.data(0x00) + self.data(0x20) + + self.command(0x36) + self.data(0x08) + + self.command(0x3A) + self.data(0x05) + + + self.command(0x90) + self.data(0x08) + self.data(0x08) + self.data(0x08) + self.data(0x08) + + self.command(0xBD) + self.data(0x06) + + self.command(0xBC) + self.data(0x00) + + self.command(0xFF) + self.data(0x60) + self.data(0x01) + self.data(0x04) + + self.command(0xC3) + self.data(0x13) + self.command(0xC4) + self.data(0x13) + + self.command(0xC9) + self.data(0x22) + + self.command(0xBE) + self.data(0x11) + + self.command(0xE1) + self.data(0x10) + self.data(0x0E) + + self.command(0xDF) + self.data(0x21) + self.data(0x0c) + self.data(0x02) + + self.command(0xF0) + self.data(0x45) + self.data(0x09) + self.data(0x08) + self.data(0x08) + self.data(0x26) + self.data(0x2A) + + self.command(0xF1) + self.data(0x43) + self.data(0x70) + self.data(0x72) + self.data(0x36) + self.data(0x37) + self.data(0x6F) + + + self.command(0xF2) + self.data(0x45) + self.data(0x09) + self.data(0x08) + self.data(0x08) + self.data(0x26) + self.data(0x2A) + + self.command(0xF3) + self.data(0x43) + self.data(0x70) + self.data(0x72) + self.data(0x36) + self.data(0x37) + self.data(0x6F) + + self.command(0xED) + self.data(0x1B) + self.data(0x0B) + + self.command(0xAE) + self.data(0x77) + + self.command(0xCD) + self.data(0x63) + + + self.command(0x70) + self.data(0x07) + self.data(0x07) + self.data(0x04) + self.data(0x0E) + self.data(0x0F) + self.data(0x09) + self.data(0x07) + self.data(0x08) + self.data(0x03) + + self.command(0xE8) + self.data(0x34) + + self.command(0x62) + self.data(0x18) + self.data(0x0D) + self.data(0x71) + self.data(0xED) + self.data(0x70) + self.data(0x70) + self.data(0x18) + self.data(0x0F) + self.data(0x71) + self.data(0xEF) + self.data(0x70) + self.data(0x70) + + self.command(0x63) + self.data(0x18) + self.data(0x11) + self.data(0x71) + self.data(0xF1) + self.data(0x70) + self.data(0x70) + self.data(0x18) + self.data(0x13) + self.data(0x71) + self.data(0xF3) + self.data(0x70) + self.data(0x70) + + self.command(0x64) + self.data(0x28) + self.data(0x29) + self.data(0xF1) + self.data(0x01) + self.data(0xF1) + self.data(0x00) + self.data(0x07) + + self.command(0x66) + self.data(0x3C) + self.data(0x00) + self.data(0xCD) + self.data(0x67) + self.data(0x45) + self.data(0x45) + self.data(0x10) + self.data(0x00) + self.data(0x00) + self.data(0x00) + + self.command(0x67) + self.data(0x00) + self.data(0x3C) + self.data(0x00) + self.data(0x00) + self.data(0x00) + self.data(0x01) + self.data(0x54) + self.data(0x10) + self.data(0x32) + self.data(0x98) + + self.command(0x74) + self.data(0x10) + self.data(0x85) + self.data(0x80) + self.data(0x00) + self.data(0x00) + self.data(0x4E) + self.data(0x00) + + self.command(0x98) + self.data(0x3e) + self.data(0x07) + + self.command(0x35) + self.command(0x21) + + self.command(0x11) + time.sleep(0.12) + self.command(0x29) + time.sleep(0.02) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data(0x00) #Set the horizontal starting point to the high octet + self.data(Xstart) #Set the horizontal starting point to the low octet + self.data(0x00) #Set the horizontal end to the high octet + self.data(Xend - 1) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(0x00) + self.data(Ystart) + self.data(0x00) + self.data(Yend - 1) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.width,self.height,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + diff --git a/modules/display/lib/LCD_1inch3.py b/modules/display/lib/LCD_1inch3.py new file mode 100644 index 00000000..54c1deec --- /dev/null +++ b/modules/display/lib/LCD_1inch3.py @@ -0,0 +1,147 @@ + +import time +from . import lcdconfig + +class LCD_1inch3(lcdconfig.RaspberryPi): + + width = 240 + height = 240 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x70) #self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x19) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x12) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0xD0) + self.data(0x04) + self.data(0x0D) + self.data(0x11) + self.data(0x13) + self.data(0x2B) + self.data(0x3F) + self.data(0x54) + self.data(0x4C) + self.data(0x18) + self.data(0x0D) + self.data(0x0B) + self.data(0x1F) + self.data(0x23) + + self.command(0xE1) + self.data(0xD0) + self.data(0x04) + self.data(0x0C) + self.data(0x11) + self.data(0x13) + self.data(0x2C) + self.data(0x3F) + self.data(0x44) + self.data(0x51) + self.data(0x2F) + self.data(0x1F) + self.data(0x1F) + self.data(0x20) + self.data(0x23) + + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data(0x00) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(0x00) #Set the horizontal end to the high octet + self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(0x00) + self.data((Ystart & 0xff)) + self.data(0x00) + self.data((Yend - 1) & 0xff ) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.width,self.height,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + diff --git a/modules/display/lib/LCD_1inch47.py b/modules/display/lib/LCD_1inch47.py new file mode 100644 index 00000000..104614e1 --- /dev/null +++ b/modules/display/lib/LCD_1inch47.py @@ -0,0 +1,153 @@ + +import time +from . import lcdconfig + +class LCD_1inch47(lcdconfig.RaspberryPi): + + width = 172 + height = 320 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x00) #self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x35) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x13) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0xF0) + self.data(0xF0) + self.data(0x00) + self.data(0x04) + self.data(0x04) + self.data(0x04) + self.data(0x05) + self.data(0x29) + self.data(0x33) + self.data(0x3E) + self.data(0x38) + self.data(0x12) + self.data(0x12) + self.data(0x28) + self.data(0x30) + + self.command(0xE1) + self.data(0xF0) + self.data(0x07) + self.data(0x0A) + self.data(0x0D) + self.data(0x0B) + self.data(0x07) + self.data(0x28) + self.data(0x33) + self.data(0x3E) + self.data(0x36) + self.data(0x14) + self.data(0x14) + self.data(0x29) + self.data(0x32) + + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data((Xstart)>>8& 0xff) #Set the horizontal starting point to the high octet + self.data((Xstart+34) & 0xff) #Set the horizontal starting point to the low octet + self.data((Xend-1+34)>>8& 0xff) #Set the horizontal end to the high octet + self.data((Xend-1+34) & 0xff) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data((Ystart)>>8& 0xff) + self.data((Ystart) & 0xff) + self.data((Yend-1)>>8& 0xff) + self.data((Yend-1) & 0xff) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.height,self.width,2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + diff --git a/modules/display/lib/LCD_1inch54.py b/modules/display/lib/LCD_1inch54.py new file mode 100644 index 00000000..7da13ffb --- /dev/null +++ b/modules/display/lib/LCD_1inch54.py @@ -0,0 +1,149 @@ + +import time +from . import lcdconfig + +class LCD_1inch54(lcdconfig.RaspberryPi): + + width = 240 + height = 240 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x70) #self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x19) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x12) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0xD0) + self.data(0x04) + self.data(0x0D) + self.data(0x11) + self.data(0x13) + self.data(0x2B) + self.data(0x3F) + self.data(0x54) + self.data(0x4C) + self.data(0x18) + self.data(0x0D) + self.data(0x0B) + self.data(0x1F) + self.data(0x23) + + self.command(0xE1) + self.data(0xD0) + self.data(0x04) + self.data(0x0C) + self.data(0x11) + self.data(0x13) + self.data(0x2C) + self.data(0x3F) + self.data(0x44) + self.data(0x51) + self.data(0x2F) + self.data(0x1F) + self.data(0x1F) + self.data(0x20) + self.data(0x23) + + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data(0x00) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(0x00) #Set the horizontal end to the high octet + self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(0x00) + self.data((Ystart & 0xff)) + self.data(0x00) + self.data((Yend - 1) & 0xff ) + + self.command(0x2C) + + def ShowImage(self,Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.width,self.height,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + diff --git a/modules/display/lib/LCD_1inch69.py b/modules/display/lib/LCD_1inch69.py new file mode 100644 index 00000000..57f23159 --- /dev/null +++ b/modules/display/lib/LCD_1inch69.py @@ -0,0 +1,191 @@ + +import time +from . import lcdconfig + +class LCD_1inch69(lcdconfig.RaspberryPi): + width = 240 + height = 280 + + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0xB2) + self.data(0x0B) + self.data(0x0B) + self.data(0x00) + self.data(0x33) + self.data(0x35) + + self.command(0xB7) + self.data(0x11) + + self.command(0xBB) + self.data(0x35) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x0D) + + self.command(0xC4) + self.data(0x20) # VDV, 0x20: 0V + + self.command(0xC6) + self.data(0x13) # 0x13: 60Hz + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xD6) + self.data(0xA1) + + self.command(0xE0) + self.data(0xF0) + self.data(0x06) + self.data(0x0B) + self.data(0x0A) + self.data(0x09) + self.data(0x26) + self.data(0x29) + self.data(0x33) + self.data(0x41) + self.data(0x18) + self.data(0x16) + self.data(0x15) + self.data(0x29) + self.data(0x2D) + + self.command(0xE1) + self.data(0xF0) + self.data(0x04) + self.data(0x08) + self.data(0x08) + self.data(0x07) + self.data(0x03) + self.data(0x28) + self.data(0x32) + self.data(0x40) + self.data(0x3B) + self.data(0x19) + self.data(0x18) + self.data(0x2A) + self.data(0x2E) + + self.command(0xE4) + self.data(0x25) + self.data(0x00) + self.data(0x00) + + self.command(0x21) + + self.command(0x11) + + time.sleep(0.1) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend, horizontal = 0): + if horizontal: + #set the X coordinates + self.command(0x2A) + self.data(Xstart+20>>8) #Set the horizontal starting point to the high octet + self.data(Xstart+20 & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend+20-1>>8) #Set the horizontal end to the high octet + self.data((Xend+20-1) & 0xff) #Set the horizontal end to the low octet + #set the Y coordinates + self.command(0x2B) + self.data(Ystart>>8) + self.data((Ystart & 0xff)) + self.data(Yend-1>>8) + self.data((Yend-1) & 0xff) + self.command(0x2C) + else: + #set the X coordinates + self.command(0x2A) + self.data(Xstart>>8) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend-1>>8) #Set the horizontal end to the high octet + self.data((Xend-1) & 0xff) #Set the horizontal end to the low octet + #set the Y coordinates + self.command(0x2B) + self.data(Ystart+20>>8) + self.data((Ystart+20 & 0xff)) + self.data(Yend+20-1>>8) + self.data((Yend+20-1) & 0xff) + self.command(0x2C) + + + def ShowImage(self, Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth == self.height and imheight == self.width: + print("Landscape screen") + img = self.np.asarray(Image) + pix = self.np.zeros((self.width, self.height,2), dtype = self.np.uint8) + #RGB888 >> RGB565 + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x70) + self.SetWindows(0, 0, self.height,self.width, 1) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + else : + print("Portrait screen") + img = self.np.asarray(Image) + pix = self.np.zeros((imheight,imwidth , 2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x00) + self.SetWindows(0, 0, self.width, self.height, 0) + self.digital_write(self.DC_PIN,True) + for i in range(0, len(pix), 4096): + self.spi_writebyte(pix[i: i+4096]) + + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff] * (self.width*self.height*2) + self.SetWindows(0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0, len(_buffer), 4096): + self.spi_writebyte(_buffer[i: i+4096]) + diff --git a/modules/display/lib/LCD_1inch8.py b/modules/display/lib/LCD_1inch8.py new file mode 100644 index 00000000..09a3ccc9 --- /dev/null +++ b/modules/display/lib/LCD_1inch8.py @@ -0,0 +1,261 @@ + +import time +from . import lcdconfig + +LCD_X = 2 +LCD_Y = 1 +LCD_X_MAXPIXEL = 128 #LCD width maximum memory +LCD_Y_MAXPIXEL = 160 #LCD height maximum memory + +#scanning method +L2R_U2D = 1 +L2R_D2U = 2 +R2L_U2D = 3 +R2L_D2U = 4 +U2D_L2R = 5 +U2D_R2L = 6 +D2U_L2R = 7 +D2U_R2L = 8 +SCAN_DIR_DFT = U2D_R2L + +LCD_WIDTH = 160 +LCD_HEIGHT = 128 + +class LCD_1inch8(lcdconfig.RaspberryPi): + LCD_Dis_Column = LCD_WIDTH + LCD_Dis_Page = LCD_HEIGHT + LCD_Scan_Dir = SCAN_DIR_DFT + LCD_X_Adjust = LCD_X + LCD_Y_Adjust = LCD_Y + width = LCD_WIDTH + height = LCD_HEIGHT + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + def SetGramScanWay(self, Scan_dir): + #Get the screen scan direction + self.LCD_Scan_Dir = Scan_dir + + #Get GRAM and LCD width and height + if (Scan_dir == L2R_U2D) or (Scan_dir == L2R_D2U) or (Scan_dir == R2L_U2D) or (Scan_dir == R2L_D2U) : + self.LCD_Dis_Column = LCD_HEIGHT + self.LCD_Dis_Page = LCD_WIDTH + self.LCD_X_Adjust = LCD_X + self.LCD_Y_Adjust = LCD_Y + if Scan_dir == L2R_U2D: + MemoryAccessReg_Data = 0X00 | 0x00 + elif Scan_dir == L2R_D2U: + MemoryAccessReg_Data = 0X00 | 0x80 + elif Scan_dir == R2L_U2D: + MemoryAccessReg_Data = 0x40 | 0x00 + else: #R2L_D2U: + MemoryAccessReg_Data = 0x40 | 0x80 + else: + self.LCD_Dis_Column = LCD_WIDTH + self.LCD_Dis_Page = LCD_HEIGHT + self.LCD_X_Adjust = LCD_Y + self.LCD_Y_Adjust = LCD_X + if Scan_dir == U2D_L2R: + MemoryAccessReg_Data = 0X00 | 0x00 | 0x20 + elif Scan_dir == U2D_R2L: + MemoryAccessReg_Data = 0X00 | 0x40 | 0x20 + elif Scan_dir == D2U_L2R: + MemoryAccessReg_Data = 0x80 | 0x00 | 0x20 + else: #R2L_D2U + MemoryAccessReg_Data = 0x40 | 0x80 | 0x20 + + # Set the read / write scan direction of the frame memory + self.command(0x36) #MX, MY, RGB mode + self.data( MemoryAccessReg_Data & 0xf7) #RGB color filter panel + def Init_reg(self): + """Initialize dispaly""" + self.command(0xB1) + self.data(0x01) + self.data(0x2C) + self.data(0x2D) + + self.command(0xB2) + self.data(0x01) + self.data(0x2C) + self.data(0x2D) + + self.command(0xB3) + self.data(0x01) + self.data(0x2C) + self.data(0x2D) + self.data(0x01) + self.data(0x2C) + self.data(0x2D) + + #Column inversion + self.command(0xB4) + self.data(0x07) + + #ST7735R Power Sequence + self.command(0xC0) + self.data(0xA2) + self.data(0x02) + self.data(0x84) + self.command(0xC1) + self.data(0xC5) + + self.command(0xC2) + self.data(0x0A) + self.data(0x00) + + self.command(0xC3) + self.data(0x8A) + self.data(0x2A) + self.command(0xC4) + self.data(0x8A) + self.data(0xEE) + + self.command(0xC5)#VCOM + self.data(0x0E) + + #ST7735R Gamma Sequence + self.command(0xe0) + self.data(0x0f) + self.data(0x1a) + self.data(0x0f) + self.data(0x18) + self.data(0x2f) + self.data(0x28) + self.data(0x20) + self.data(0x22) + self.data(0x1f) + self.data(0x1b) + self.data(0x23) + self.data(0x37) + self.data(0x00) + self.data(0x07) + self.data(0x02) + self.data(0x10) + + self.command(0xe1) + self.data(0x0f) + self.data(0x1b) + self.data(0x0f) + self.data(0x17) + self.data(0x33) + self.data(0x2c) + self.data(0x29) + self.data(0x2e) + self.data(0x30) + self.data(0x30) + self.data(0x39) + self.data(0x3f) + self.data(0x00) + self.data(0x07) + self.data(0x03) + self.data(0x10) + + #Enable test command + self.command(0xF0) + self.data(0x01) + + #Disable ram power save mode + self.command(0xF6) + self.data(0x00) + + #65k mode + self.command(0x3A) + self.data(0x05) + + def Init(self,Lcd_ScanDir=U2D_R2L): + self.module_init() + self.reset() + + #Set the initialization register + self.Init_reg() + + #Set the display scan and color transfer modes + self.SetGramScanWay( Lcd_ScanDir ) + self.delay_ms(200) + + #sleep out + self.command(0x11) + self.delay_ms(120) + + #Turn on the LCD display + self.command(0x29) + + self.clear() + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command ( 0x2A ) + self.data ( 0x00 ) #Set the horizontal starting point to the high octet + self.data ( (Xstart & 0xff) + self.LCD_X_Adjust) #Set the horizontal starting point to the low octet + self.data ( 0x00 ) #Set the horizontal end to the high octet + self.data ( (( Xend - 1 ) & 0xff) + self.LCD_X_Adjust) #Set the horizontal end to the low octet + + #set the Y coordinates + self.command ( 0x2B ) + self.data ( 0x00 ) + self.data ( (Ystart & 0xff) + self.LCD_Y_Adjust) + self.data ( 0x00 ) + self.data ( ( (Yend - 1) & 0xff )+ self.LCD_Y_Adjust) + + self.command(0x2C) + + def clear(self, color=0XFFFF): + _buffer = [color]*(self.LCD_Dis_Column * self.LCD_Dis_Page * 2) + if (self.LCD_Scan_Dir == L2R_U2D) or (self.LCD_Scan_Dir == L2R_D2U) or (self.LCD_Scan_Dir == R2L_U2D) or (self.LCD_Scan_Dir == R2L_D2U) : + # self.LCD_SetArealColor(0,0, LCD_X_MAXPIXEL , LCD_Y_MAXPIXEL , Color = color)#white + self.SetWindows( 0 , 0 , LCD_X_MAXPIXEL , LCD_Y_MAXPIXEL ) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + else: + # self.LCD_SetArealColor(0,0, LCD_Y_MAXPIXEL , LCD_X_MAXPIXEL , Color = color)#white + self.SetWindows( 0 , 0 , LCD_Y_MAXPIXEL , LCD_X_MAXPIXEL ) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + + def ShowImage(self,Image): + if (Image == None): + return + + imwidth, imheight = Image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + img = self.np.asarray(Image) + pix = self.np.zeros((self.height,self.width,2), dtype = self.np.uint8) + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0),self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + ''' + self.SetWindows ( Xstart, Ystart, self.LCD_Dis_Column , self.LCD_Dis_Page ) + self.digital_write(self.DC_PIN,self.GPIO.HIGH) + # Pixels = Image.load() + img = np.asarray(Image) + pix = np.zeros((Image.height,Image.width, 2), dtype = np.uint8) + pix[...,[0]] = np.add(np.bitwise_and(img[...,[0]],0xF8),np.right_shift(img[...,[1]],5)) + pix[...,[1]] = np.add(np.bitwise_and(np.left_shift(img[...,[1]],3),0xE0), np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + self.digital_write(self.DC_PIN,self.GPIO.HIGH) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + ''' diff --git a/modules/display/lib/LCD_1inch9.py b/modules/display/lib/LCD_1inch9.py new file mode 100644 index 00000000..fe6449b9 --- /dev/null +++ b/modules/display/lib/LCD_1inch9.py @@ -0,0 +1,178 @@ + +import time +from . import lcdconfig + +class LCD_1inch9(lcdconfig.RaspberryPi): + width = 170 + height = 320 + + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x00) + + self.command(0x3A) + self.data(0x55) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x13) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x0B) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0x00) + self.data(0x03) + self.data(0x07) + self.data(0x08) + self.data(0x07) + self.data(0x15) + self.data(0x2A) + self.data(0x44) + self.data(0x42) + self.data(0x0A) + self.data(0x17) + self.data(0x18) + self.data(0x25) + self.data(0x27) + + self.command(0xE1) + self.data(0x00) + self.data(0x03) + self.data(0x08) + self.data(0x07) + self.data(0x07) + self.data(0x23) + self.data(0x2A) + self.data(0x43) + self.data(0x42) + self.data(0x09) + self.data(0x18) + self.data(0x17) + self.data(0x25) + self.data(0x27) + + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + def SetWindows(self, Xstart, Ystart, Xend, Yend, horizontal = 0): + if horizontal: + #set the X coordinates + self.command(0x2A) + self.data(Xstart>>8) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend-1>>8) #Set the horizontal end to the high octet + self.data((Xend-1) & 0xff) #Set the horizontal end to the low octet + #set the Y coordinates + self.command(0x2B) + self.data(Ystart+35>>8) + self.data((Ystart+35 & 0xff)) + self.data(Yend+35-1>>8) + self.data((Yend+35-1 ) & 0xff ) + self.command(0x2C) + else: + #set the X coordinates + self.command(0x2A) + self.data(Xstart+35>>8) #Set the horizontal starting point to the high octet + self.data(Xstart+35 & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend+35-1>>8) #Set the horizontal end to the high octet + self.data((Xend+35 - 1) & 0xff) #Set the horizontal end to the low octet + #set the Y coordinates + self.command(0x2B) + self.data(Ystart>>8) + self.data((Ystart & 0xff)) + self.data(Yend -1>>8) + self.data((Yend - 1) & 0xff ) + self.command(0x2C) + + def ShowImage(self, Image): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth == self.height and imheight == self.width: + img = self.np.asarray(Image) + pix = self.np.zeros((self.width, self.height,2), dtype = self.np.uint8) + #RGB888 >> RGB565 + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x70) + self.SetWindows(0, 0, self.height,self.width, 1) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + else : + img = self.np.asarray(Image) + pix = self.np.zeros((imheight,imwidth , 2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x00) + self.SetWindows(0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0, len(pix), 4096): + self.spi_writebyte(pix[i: i+4096]) + + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff] * (self.width*self.height*2) + self.SetWindows(0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0, len(_buffer), 4096): + self.spi_writebyte(_buffer[i: i+4096]) + diff --git a/modules/display/lib/LCD_2inch.py b/modules/display/lib/LCD_2inch.py new file mode 100644 index 00000000..c22638b3 --- /dev/null +++ b/modules/display/lib/LCD_2inch.py @@ -0,0 +1,179 @@ + +import time +from . import lcdconfig + +class LCD_2inch(lcdconfig.RaspberryPi): + + width = 240 + height = 320 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x36) + self.data(0x00) + + self.command(0x3A) + self.data(0x05) + + self.command(0x21) + + self.command(0x2A) + self.data(0x00) + self.data(0x00) + self.data(0x01) + self.data(0x3F) + + self.command(0x2B) + self.data(0x00) + self.data(0x00) + self.data(0x00) + self.data(0xEF) + + self.command(0xB2) + self.data(0x0C) + self.data(0x0C) + self.data(0x00) + self.data(0x33) + self.data(0x33) + + self.command(0xB7) + self.data(0x35) + + self.command(0xBB) + self.data(0x1F) + + self.command(0xC0) + self.data(0x2C) + + self.command(0xC2) + self.data(0x01) + + self.command(0xC3) + self.data(0x12) + + self.command(0xC4) + self.data(0x20) + + self.command(0xC6) + self.data(0x0F) + + self.command(0xD0) + self.data(0xA4) + self.data(0xA1) + + self.command(0xE0) + self.data(0xD0) + self.data(0x08) + self.data(0x11) + self.data(0x08) + self.data(0x0C) + self.data(0x15) + self.data(0x39) + self.data(0x33) + self.data(0x50) + self.data(0x36) + self.data(0x13) + self.data(0x14) + self.data(0x29) + self.data(0x2D) + + self.command(0xE1) + self.data(0xD0) + self.data(0x08) + self.data(0x10) + self.data(0x08) + self.data(0x06) + self.data(0x06) + self.data(0x39) + self.data(0x44) + self.data(0x51) + self.data(0x0B) + self.data(0x16) + self.data(0x14) + self.data(0x2F) + self.data(0x31) + self.command(0x21) + + self.command(0x11) + + self.command(0x29) + + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data(Xstart>>8) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend>>8) #Set the horizontal end to the high octet + self.data((Xend - 1) & 0xff)#Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(Ystart>>8) + self.data((Ystart & 0xff)) + self.data(Yend>>8) + self.data((Yend - 1) & 0xff ) + + self.command(0x2C) + + def ShowImage(self,Image,Xstart=0,Ystart=0): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth == self.height and imheight == self.width: + img = self.np.asarray(Image) + pix = self.np.zeros((self.width, self.height,2), dtype = self.np.uint8) + #RGB888 >> RGB565 + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x70) + self.SetWindows ( 0, 0, self.height,self.width) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + else : + img = self.np.asarray(Image) + pix = self.np.zeros((imheight,imwidth , 2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x00) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + self.SetWindows ( 0, 0, self.height, self.width) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + diff --git a/modules/display/lib/LCD_2inch4.py b/modules/display/lib/LCD_2inch4.py new file mode 100644 index 00000000..88c6c24a --- /dev/null +++ b/modules/display/lib/LCD_2inch4.py @@ -0,0 +1,187 @@ + +import time +from . import lcdconfig +import numbers + +class LCD_2inch4(lcdconfig.RaspberryPi): + + width = 240 + height = 320 + def command(self, cmd): + self.digital_write(self.DC_PIN, False) + self.spi_writebyte([cmd]) + + def data(self, val): + self.digital_write(self.DC_PIN, True) + self.spi_writebyte([val]) + def reset(self): + """Reset the display""" + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + self.digital_write(self.RST_PIN,False) + time.sleep(0.01) + self.digital_write(self.RST_PIN,True) + time.sleep(0.01) + + def Init(self): + """Initialize dispaly""" + self.module_init() + self.reset() + + self.command(0x11)#'''Sleep out''' + + self.command(0xCF)# + self.data(0x00)# + self.data(0xC1)# + self.data(0X30)# + self.command(0xED)# + self.data(0x64)# + self.data(0x03)# + self.data(0X12)# + self.data(0X81)# + self.command(0xE8)# + self.data(0x85)# + self.data(0x00)# + self.data(0x79)# + self.command(0xCB)# + self.data(0x39)# + self.data(0x2C)# + self.data(0x00)# + self.data(0x34)# + self.data(0x02)# + self.command(0xF7)# + self.data(0x20)# + self.command(0xEA)# + self.data(0x00)# + self.data(0x00)# + self.command(0xC0)#'''Power control''' + self.data(0x1D)#'''VRH[5:0]''' + self.command(0xC1)#'''Power control''' + self.data(0x12)#'''SAP[2:0]#BT[3:0]''' + self.command(0xC5)#'''VCM control''' + self.data(0x33)# + self.data(0x3F)# + self.command(0xC7)#'''VCM control''' + self.data(0x92)# + self.command(0x3A)#'''Memory Access Control''' + self.data(0x55)# + self.command(0x36)#'''Memory Access Control''' + self.data(0x08)# + self.command(0xB1)# + self.data(0x00)# + self.data(0x12)# + self.command(0xB6)#'''Display Function Control''' + self.data(0x0A)# + self.data(0xA2)# + + self.command(0x44)# + self.data(0x02)# + + self.command(0xF2)#'''3Gamma Function Disable''' + self.data(0x00)# + self.command(0x26)#'''Gamma curve selected''' + self.data(0x01)# + self.command(0xE0)#'''Set Gamma''' + self.data(0x0F)# + self.data(0x22)# + self.data(0x1C)# + self.data(0x1B)# + self.data(0x08)# + self.data(0x0F)# + self.data(0x48)# + self.data(0xB8)# + self.data(0x34)# + self.data(0x05)# + self.data(0x0C)# + self.data(0x09)# + self.data(0x0F)# + self.data(0x07)# + self.data(0x00)# + self.command(0XE1)#'''Set Gamma''' + self.data(0x00)# + self.data(0x23)# + self.data(0x24)# + self.data(0x07)# + self.data(0x10)# + self.data(0x07)# + self.data(0x38)# + self.data(0x47)# + self.data(0x4B)# + self.data(0x0A)# + self.data(0x13)# + self.data(0x06)# + self.data(0x30)# + self.data(0x38)# + self.data(0x0F)# + self.command(0x29)#'''Display on''' + + + def SetWindows(self, Xstart, Ystart, Xend, Yend): + #set the X coordinates + self.command(0x2A) + self.data(Xstart>>8) #Set the horizontal starting point to the high octet + self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet + self.data(Xend>>8) #Set the horizontal end to the high octet + self.data((Xend - 1) & 0xff)#Set the horizontal end to the low octet + + #set the Y coordinates + self.command(0x2B) + self.data(Ystart>>8) + self.data((Ystart & 0xff)) + self.data(Yend>>8) + self.data((Yend - 1) & 0xff ) + + self.command(0x2C) + + def ShowImage(self,Image,Xstart=0,Ystart=0): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + imwidth, imheight = Image.size + if imwidth == self.height and imheight == self.width: + img = self.np.asarray(Image) + pix = self.np.zeros((imheight,imwidth , 2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + + self.command(0x36) + self.data(0x78) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + else : + img = self.np.asarray(Image) + pix = self.np.zeros((imheight,imwidth , 2), dtype = self.np.uint8) + + pix[...,[0]] = self.np.add(self.np.bitwise_and(img[...,[0]],0xF8),self.np.right_shift(img[...,[1]],5)) + pix[...,[1]] = self.np.add(self.np.bitwise_and(self.np.left_shift(img[...,[1]],3),0xE0), self.np.right_shift(img[...,[2]],3)) + + pix = pix.flatten().tolist() + self.command(0x36) + self.data(0x08) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(pix),4096): + self.spi_writebyte(pix[i:i+4096]) + + def clear(self): + """Clear contents of image buffer""" + _buffer = [0xff]*(self.width * self.height * 2) + time.sleep(0.02) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) + + def clear_color(self,color): + """Clear contents of image buffer""" + _buffer = [color>>8, color & 0xff]*(self.width * self.height) + time.sleep(0.02) + self.SetWindows ( 0, 0, self.width, self.height) + self.digital_write(self.DC_PIN,True) + for i in range(0,len(_buffer),4096): + self.spi_writebyte(_buffer[i:i+4096]) diff --git a/modules/display/lib/__init__.py b/modules/display/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/display/lib/lcdconfig.py b/modules/display/lib/lcdconfig.py new file mode 100644 index 00000000..ddac9a37 --- /dev/null +++ b/modules/display/lib/lcdconfig.py @@ -0,0 +1,116 @@ +# /***************************************************************************** +# * | File : epdconfig.py +# * | Author : Waveshare team +# * | Function : Hardware underlying interface +# * | Info : +# *---------------- +# * | This version: V1.0 +# * | Date : 2019-06-21 +# * | Info : +# ****************************************************************************** +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documnetation 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 +# furished 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 OR 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. +# + +import os +import sys +import time +import spidev +import logging +import numpy as np +from gpiozero import * + +class RaspberryPi: + def __init__(self,spi=spidev.SpiDev(0,0),spi_freq=40000000,rst = 27,dc = 25,bl = 18,bl_freq=1000,i2c=None,i2c_freq=100000): + self.np=np + self.INPUT = False + self.OUTPUT = True + + self.SPEED =spi_freq + self.BL_freq=bl_freq + + self.RST_PIN= self.gpio_mode(rst,self.OUTPUT) + self.DC_PIN = self.gpio_mode(dc,self.OUTPUT) + self.BL_PIN = self.gpio_pwm(bl) + self.bl_DutyCycle(0) + + #Initialize SPI + self.SPI = spi + if self.SPI!=None : + self.SPI.max_speed_hz = spi_freq + self.SPI.mode = 0b00 + + def gpio_mode(self,Pin,Mode,pull_up = None,active_state = True): + if Mode: + return DigitalOutputDevice(Pin,active_high = True,initial_value =False) + else: + return DigitalInputDevice(Pin,pull_up=pull_up,active_state=active_state) + + def digital_write(self, Pin, value): + if value: + Pin.on() + else: + Pin.off() + + def digital_read(self, Pin): + return Pin.value + + def delay_ms(self, delaytime): + time.sleep(delaytime / 1000.0) + + def gpio_pwm(self,Pin): + return PWMOutputDevice(Pin,frequency = self.BL_freq) + + def spi_writebyte(self, data): + if self.SPI!=None : + self.SPI.writebytes(data) + + def bl_DutyCycle(self, duty): + self.BL_PIN.value = duty / 100 + + def bl_Frequency(self,freq):# Hz + self.BL_PIN.frequency = freq + + def module_init(self): + if self.SPI!=None : + self.SPI.max_speed_hz = self.SPEED + self.SPI.mode = 0b00 + return 0 + + def module_exit(self): + logging.debug("spi end") + if self.SPI!=None : + self.SPI.close() + + logging.debug("gpio cleanup...") + self.digital_write(self.RST_PIN, 1) + self.digital_write(self.DC_PIN, 0) + self.BL_PIN.close() + time.sleep(0.001) + + + +''' +if os.path.exists('/sys/bus/platform/drivers/gpiomem-bcm2835'): + implementation = RaspberryPi() + +for func in [x for x in dir(implementation) if not x.startswith('_')]: + setattr(sys.modules[__name__], func, getattr(implementation, func)) +''' + +### END OF FILE ### diff --git a/modules/display/tft_display.py b/modules/display/tft_display.py new file mode 100644 index 00000000..eb88dd97 --- /dev/null +++ b/modules/display/tft_display.py @@ -0,0 +1,55 @@ +#!/usr/bin/python +# -*- coding: UTF-8 -*- +#import chardet +import os +import sys +import time +import logging +import spidev as SPI +sys.path.append("..") +from modules.display.lib import LCD_1inch28 +from PIL import Image,ImageDraw,ImageFont + +# @todo: Do we need the library and images? +class TFTDisplay: + def __init__(self, **kwargs): + try: + # Open SPI bus + spi = SPI.SpiDev(kwargs.get('bus'), kwargs.get('device')) + spi.max_speed_hz = 10000000 + + # Display with hardware SPI: + ''' Warning!!! Don't create multiple display objects!!! ''' + self.disp = LCD_1inch28.LCD_1inch28(spi=spi, spi_freq=10000000, rst=kwargs.get('rst_pin'), dc=kwargs.get('dc_pin') , bl=kwargs.get('bl_pin')) + if kwargs.get('test_on_boot'): + self.test_display() + + except Exception as e: + logging.error(f"Failed to initialize TFT display: {e}") + + def test_display(self): + disp = self.disp + # Initialize library. + disp.Init() + # Clear display. + disp.clear() + # Set the backlight to 100 + disp.bl_DutyCycle(50) + print("TFT display initialized") + # Create blank image for drawing. + image1 = Image.new("RGB", (disp.width, disp.height), "BLACK") + draw = ImageDraw.Draw(image1) + + draw.arc((1, 1, 239, 239), 0, 360, fill=(0, 0, 255)) + draw.arc((2, 2, 238, 238), 0, 360, fill=(0, 0, 255)) + draw.arc((3, 3, 237, 237), 0, 360, fill=(0, 0, 255)) + + draw.line([(120, 1), (120, 12)], fill=(128, 255, 128), width=4) + draw.line([(120, 227), (120, 239)], fill=(128, 255, 128), width=4) + draw.line([(1, 120), (12, 120)], fill=(128, 255, 128), width=4) + draw.line([(227, 120), (239, 120)], fill=(128, 255, 128), width=4) + + # Display the image + disp.ShowImage(image1) + print("TFT display test completed") + \ No newline at end of file diff --git a/modules/i2c_servo.py b/modules/i2c_servo.py new file mode 100644 index 00000000..ff133ca3 --- /dev/null +++ b/modules/i2c_servo.py @@ -0,0 +1,30 @@ +import os +from time import sleep +from modules.base_module import BaseModule + +from adafruit_servokit import ServoKit + +# @todo: Merge with main servo module +class I2CServo(BaseModule): + def __init__(self, **kwargs): + # Scan with `sudo i2cdetect -y 1` + self.servos = ServoKit(channels=16) + self.count = kwargs.get('servo_count') + # https://learn.adafruit.com/adafruit-16-channel-servo-driver-with-raspberry-pi/using-the-adafruit-library + for i in range(self.count): + self.moveServo(i, 90) + if kwargs.get('test_on_boot'): + self.test() + + def moveServo(self, index, angle): + self.servos.servo[index].angle = angle + + def test(self): + for i in range(self.count): + print("Testing servo {}".format(i)) + self.moveServo(i, 0) + sleep(1) + self.moveServo(i, 180) + sleep(1) + self.moveServo(i, 90) + sleep(1) \ No newline at end of file diff --git a/modules/logwrapper.py b/modules/logwrapper.py index 57119ed0..a0268607 100644 --- a/modules/logwrapper.py +++ b/modules/logwrapper.py @@ -1,11 +1,8 @@ -import logging -from pubsub import pub -import os -# from viam.logging import getLogger -# LOGGER = getLogger(__name__) -# LOGGER.debug('INIT MAKERFORGE LOGGER') +import logging, json +import os, datetime +from modules.base_module import BaseModule -class LogWrapper: +class LogWrapper(BaseModule): levels = ['notset', 'debug', 'info', 'warning', 'error', 'critical'] def __init__(self, **kwargs): @@ -17,52 +14,60 @@ def __init__(self, **kwargs): Subscribes to 'log' to log messages - Argument: type (string) - log level - - Argument: msg (string) - message to log + - Argument: message (string) - message to log - Example: - pub.sendMessage('log', type='info', msg='This is an info message') - pub.sendMessage('log:debug', msg='This is a debug message') - pub.sendMessage('log:info', msg='This is an info message') - pub.sendMessage('log:error', msg='This is an error message') - pub.sendMessage('log:critical', msg='This is a critical message') - pub.sendMessage('log:warning', msg='This is a warning message') + Examples (require module to extend BaseModule): + self.log('My message to log') + self.publish('log', 'My message to log') + self.publish('log', type='info', message='This is an info message') + self.publish('log/debug', 'This is a debug message') + self.publish('log/info', 'This is an info message') + self.publish('log/error', 'This is an error message') + self.publish('log/critical', 'This is a critical message') + self.publish('log/warning', 'This is a warning message') """ - self.path = kwargs.get('path', '/') - self.filename = kwargs.get('filename', 'app.log') + self.path = kwargs.get('path', os.path.dirname(os.path.dirname(__file__))) + self.filename = kwargs.get('filename', kwargs.get('filename','app.log')) self.file = self.path + '/' + self.filename + self.log_level = kwargs.get('log_level', 'debug') # level of logs to output to file + self.cli_level = kwargs.get('cli_level', 'debug') # level of logs to output to console + print(f"[Creating log at {self.file}]") + self.print = kwargs.get('print', False) + + logging.basicConfig(filename=self.file, + level=LogWrapper.levels.index(self.log_level)*10, format='%(levelname)s: %(asctime)s %(message)s', + datefmt='%Y/%m/%d %I:%M:%S %p') self.translator = kwargs.get('translator', None) - pub.subscribe(self.log, 'log', type='info') - pub.subscribe(self.log, 'log:debug', type='debug') - pub.subscribe(self.log, 'log:info', type='info') - pub.subscribe(self.log, 'log:error', type='error') - pub.subscribe(self.log, 'log:critical', type='critical') - pub.subscribe(self.log, 'log:warning', type='warning') + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('log', self.log) + self.subscribe('log/debug', self.log, type='debug') + self.subscribe('log/info', self.log, type='info') + self.subscribe('log/error', self.log, type='error') + self.subscribe('log/critical', self.log, type='critical') + self.subscribe('log/warning', self.log, type='warning') def __del__(self): if os.path.isfile(self.file): os.rename(self.file, self.file + '.previous') + print(f"[Log file stored at {self.file}.previous]") + + def log(self, message): + self.log('info', message) - def log(self, type, msg): - #msg = '[LOGGING] ' + msg - # Translate type string to log level (0 - 50) - logging.log(LogWrapper.levels.index(type)*10, msg) - # if type == 'error' or type == 'warning': + def log(self, message, type='info'): + # if message is a json object as a string + if isinstance(message, str) and message.startswith('{'): + message = json.loads(message)['message'] + if self.translator is not None: - msg = self.translator.request(msg) - #print('LogWrapper: ' + type + ' - ' + str(msg)) - # self.log_viam(type, msg) - - # def log_viam(self, type, msg): - # if type == 'debug': - # LOGGER.debug(msg) - # elif type == 'info': - # LOGGER.info(msg) - # elif type == 'warning': - # LOGGER.warn(msg) - # elif type == 'error': - # LOGGER.error(msg) - # elif type == 'critical': - # LOGGER.critical(msg) \ No newline at end of file + message = self.translator.request(message) + + logging.log(LogWrapper.levels.index(type)*10, message) # Filter on log level is handled by logging module + + if LogWrapper.levels.index(self.cli_level) <= LogWrapper.levels.index(type): + print('log/' + type + ': ' + str(message)) + \ No newline at end of file diff --git a/modules/mpu6050.py b/modules/mpu6050.py new file mode 100644 index 00000000..10325432 --- /dev/null +++ b/modules/mpu6050.py @@ -0,0 +1,98 @@ +from pubsub import pub +''' + Read Gyro and Accelerometer by Interfacing Raspberry Pi with MPU6050 using Python + http://www.electronicwings.com +''' +import smbus #import SMBus module of I2C +from time import sleep #import +from modules.base_module import BaseModule + +#some MPU6050 Registers and their Address +PWR_MGMT_1 = 0x6B +SMPLRT_DIV = 0x19 +CONFIG = 0x1A +GYRO_CONFIG = 0x1B +INT_ENABLE = 0x38 +ACCEL_XOUT_H = 0x3B +ACCEL_YOUT_H = 0x3D +ACCEL_ZOUT_H = 0x3F +GYRO_XOUT_H = 0x43 +GYRO_YOUT_H = 0x45 +GYRO_ZOUT_H = 0x47 + +class MPU6050(BaseModule): + def __init__(self, **kwargs): + self.bus = smbus.SMBus(1) # or bus = smbus.SMBus(0) for older version boards + sleep(2) + self.Device_Address = 0x68 # MPU6050 device address + + #write to sample rate register + self.bus.write_byte_data(self.Device_Address, SMPLRT_DIV, 7) + + #Write to power management register + self.bus.write_byte_data(self.Device_Address, PWR_MGMT_1, 1) + + #Write to Configuration register + self.bus.write_byte_data(self.Device_Address, CONFIG, 0) + + #Write to Gyro configuration register + self.bus.write_byte_data(self.Device_Address, GYRO_CONFIG, 24) + + #Write to interrupt enable register + self.bus.write_byte_data(self.Device_Address, INT_ENABLE, 1) + + if kwargs.get('test_on_boot'): + self.read_data() + + def read_raw_data(self, addr): + #Accelero and Gyro value are 16-bit + # catch and ignore OSError: [Errno 121] Remote I/O error + high = self.bus.read_byte_data(self.Device_Address, addr) + low = self.bus.read_byte_data(self.Device_Address, addr+1) + + #concatenate higher and lower value + value = ((high << 8) | low) + + #to get signed value from mpu6050 + if(value > 32768): + value = value - 65536 + return value + + def read_data(self): + # print (" Reading Data of Gyroscope and Accelerometer") + while True: + try: + #Read Accelerometer raw value + acc_x = self.read_raw_data(ACCEL_XOUT_H) + acc_y = self.read_raw_data(ACCEL_YOUT_H) + acc_z = self.read_raw_data(ACCEL_ZOUT_H) + + #Read Gyroscope raw value + gyro_x = self.read_raw_data(GYRO_XOUT_H) + gyro_y = self.read_raw_data(GYRO_YOUT_H) + gyro_z = self.read_raw_data(GYRO_ZOUT_H) + except OSError: + #print('OSError: [Errno 121] Remote I/O error. Continuing...') + continue + #Full scale range +/- 250 degree/C as per sensitivity scale factor + Ax = acc_x/16384.0 + Ay = acc_y/16384.0 + Az = acc_z/16384.0 + + Gx = gyro_x/131.0 + Gy = gyro_y/131.0 + Gz = gyro_z/131.0 + + + print ("Gx=%.2f" %Gx, u'\u00b0'+ "/s", "\tGy=%.2f" %Gy, u'\u00b0'+ "/s", "\tGz=%.2f" %Gz, u'\u00b0'+ "/s", "\tAx=%.2f g" %Ax, "\tAy=%.2f g" %Ay, "\tAz=%.2f g" %Az) + sleep(.1) + # return data as map + return { + 'Gx': Gx, + 'Gy': Gy, + 'Gz': Gz, + 'Ax': Ax, + 'Ay': Ay, + 'Az': Az + } + \ No newline at end of file diff --git a/modules/neopixel/emotion_analysis.py b/modules/neopixel/emotion_analysis.py index b28b7ead..94d64e3c 100644 --- a/modules/neopixel/emotion_analysis.py +++ b/modules/neopixel/emotion_analysis.py @@ -1,9 +1,9 @@ import random from transformers import pipeline from itertools import combinations -from pubsub import pub +from modules.base_module import BaseModule -class EmotionAnalysis: +class EmotionAnalysis(BaseModule): def __init__(self, **kwargs): """ Emotion analysis module @@ -15,7 +15,7 @@ def __init__(self, **kwargs): - Argument: text (string) - text to analyze Example: - pub.sendMessage('speech', text='I am so happy today!') + self.publish('speech', text='I am so happy today!') """ # Load color sets from YAML file via Config class self.color_sets = kwargs.get('colors') @@ -54,8 +54,9 @@ def __init__(self, **kwargs): 'anger': 'anger', 'amusement': 'amusement', } - - pub.subscribe(self.analyze_text, 'speech') + + def setup_messaging(self): + self.subscribe('speech', self.analyze_text) def get_different_colors(self, color_dict, num_colors): colors = list(color_dict.values()) @@ -107,4 +108,4 @@ def analyze_text(self, text): # Send colors to NeoPixel LEDs for i, color in enumerate(rgb_colors): - pub.sendMessage('led', identifiers=i, color=color) + self.publish('led', identifiers=i, color=color) diff --git a/modules/neopixel/neopx.py b/modules/neopixel/neopx.py index 7c441cef..1d6748c0 100644 --- a/modules/neopixel/neopx.py +++ b/modules/neopixel/neopx.py @@ -1,16 +1,19 @@ -from pubsub import pub from time import sleep from colour import Color import board import threading +from modules.base_module import BaseModule -class NeoPx: +class NeoPx(BaseModule): COLOR_OFF = (0, 0, 0) COLOR_RED = (100, 0, 0) COLOR_GREEN = (0, 100, 0) COLOR_BLUE = (0, 0, 100) COLOR_PURPLE = (100, 0, 100) + COLOR_YELLOW = (100, 100, 0) + COLOR_ORANGE = (100, 50, 0) + COLOR_PINK = (100, 0, 50) COLOR_WHITE = (100, 100, 100) COLOR_WHITE_FULL = (255, 255, 255) COLOR_WHITE_DIM = (50, 50, 50) @@ -26,7 +29,10 @@ class NeoPx: 'white': COLOR_WHITE, 'white_full': COLOR_WHITE_FULL, 'off': COLOR_OFF, - 'white_dim': COLOR_WHITE_DIM + 'white_dim': COLOR_WHITE_DIM, + 'yellow': COLOR_YELLOW, + 'orange': COLOR_ORANGE, + 'pink': COLOR_PINK } def __init__(self, **kwargs): @@ -44,21 +50,21 @@ def __init__(self, **kwargs): - Argument: identifiers (int or list) - pixel number (starting from 0) - Argument: color (string or tuple) - string map of COLOR_MAP or tuple (R, G, B) - Subscribes to 'led:full' to set all pixels to one color + Subscribes to 'led/full' to set all pixels to one color - Argument: color (string or tuple) - string map of COLOR_MAP or tuple (R, G, B) - Subscribes to 'led:eye' to set eye color + Subscribes to 'led/eye' to set eye color - Argument: color (string or tuple) - string map of COLOR_MAP or tuple (R, G, B) - Subscribes to 'led:ring' to set ring color + Subscribes to 'led/ring' to set ring color - Argument: color (string or tuple) - string map of COLOR_MAP or tuple (R, G, B) - Subscribes to 'led:off' to turn off all pixels + Subscribes to 'led/off' to turn off all pixels - Subscribes to 'led:flashlight' to turn on/off all pixels + Subscribes to 'led/flashlight' to turn on/off all pixels - Argument: on (bool) - turn on or off - Subscribes to 'led:party' to start party mode + Subscribes to 'led/party' to start party mode Subscribes to 'exit' to clean up @@ -66,15 +72,15 @@ def __init__(self, **kwargs): - Argument: msg (string) - speech command Example: - pub.sendMessage('led', identifiers=1, color='red') - pub.sendMessage('led:full', color='red') - pub.sendMessage('led:eye', color='red') - pub.sendMessage('led:ring', color='red') - pub.sendMessage('led:off') - pub.sendMessage('led:flashlight', on=True) - pub.sendMessage('led:party') - pub.sendMessage('exit') - pub.sendMessage('speech', msg='light on') + self.publish('led', identifiers=1, color='red') + self.publish('led/full', color='red') + self.publish('led/eye', color='red') + self.publish('led/ring', color='red') + self.publish('led/off') + self.publish('led/flashlight', on=True) + self.publish('led/party') + self.publish('exit') + self.publish('speech', msg='light on') """ # Initialise self.count = kwargs.get('count') @@ -107,29 +113,29 @@ def __init__(self, **kwargs): spi = board.SPI() self.pixels = neopixel.NeoPixel_SPI(spi, self.count, brightness=0.1, auto_write=False, pixel_order=neopixel.GRB) - DELAY = 3 - print("All neopixels OFF") - self.pixels.fill((0,0,0)) - self.pixels.show() - sleep(DELAY) - - print("First neopixel red, last neopixel blue") - self.pixels[0] = (10,0,0) - self.pixels[self.count - 1] = (0,0,10) - self.pixels.show() - sleep(DELAY) - - print("All " + str(self.count) + " neopixels green") - self.pixels.fill((0,10,0)) - self.pixels.show() - sleep(DELAY) - - print("All neopixels OFF") - self.pixels.fill((0,0,0)) - self.pixels.show() - sleep(DELAY) - - print("End of test") + # DELAY = 3 + # print("All neopixels OFF") + # self.pixels.fill((0,0,0)) + # self.pixels.show() + # sleep(DELAY) + + # print("First neopixel red, last neopixel blue") + # self.pixels[0] = (10,0,0) + # self.pixels[self.count - 1] = (0,0,10) + # self.pixels.show() + # sleep(DELAY) + + # print("All " + str(self.count) + " neopixels green") + # self.pixels.fill((0,10,0)) + # self.pixels.show() + # sleep(DELAY) + + # print("All neopixels OFF") + # self.pixels.fill((0,0,0)) + # self.pixels.show() + # sleep(DELAY) + + # print("End of test") else: # GPIO import neopixel self.pixels = neopixel.NeoPixel(kwargs.get('pin'), self.count) @@ -137,17 +143,17 @@ def __init__(self, **kwargs): self.set(self.all, NeoPx.COLOR_OFF) sleep(0.1) self.set(self.positions['middle'], NeoPx.COLOR_BLUE) - + def setup_messaging(self): # Set subscribers - pub.subscribe(self.set, 'led') - pub.subscribe(self.full, 'led:full') - pub.subscribe(self.eye, 'led:eye') - pub.subscribe(self.ring, 'led:ring') - pub.subscribe(self.off, 'led:off') - pub.subscribe(self.eye, 'led:flashlight') - pub.subscribe(self.party, 'led:party') - pub.subscribe(self.exit, 'exit') - pub.subscribe(self.speech, 'speech') + self.subscribe('led', self.set) + self.subscribe('led/full', self.full) + self.subscribe('led/eye', self.eye) + self.subscribe('led/ring', self.ring) + self.subscribe('led/off', self.off) + self.subscribe('led/flashlight', self.flashlight) + self.subscribe('led/party', self.party) + self.subscribe('exit', self.exit) + self.subscribe('speech', self.speech) def exit(self): """ @@ -205,13 +211,13 @@ def set(self, identifiers, color, gradient=False): # print(str(i) + str(color)) try: if i >= self.count: - pub.sendMessage('log', msg='[LED] Error in set pixels: index out of range') + self.log('Error in set pixels: index out of range') print('Error in set pixels: index out of range') i = self.count-1 self.pixels[i] = self.apply_brightness_modifier(i, color) except Exception as e: print(e) - pub.sendMessage('log', msg='[LED] Error in set pixels: ' + str(e)) + self.log('Error in set pixels: ' + str(e)) pass self.pixels.show() @@ -234,7 +240,7 @@ def flashlight(self, on): def off(self): if self.thread: - pub.sendMessage('log', msg='[LED] Animation stopping') + self.log('Animation stopping') self.animation = False self.thread.animation = False self.thread.join() @@ -253,9 +259,9 @@ def eye(self, color): index = self.positions['middle'] if (self.count < index): index = self.count - 1 - pub.sendMessage('log', msg='[LED] Error in set pixels: index out of range, changing to last pixel') + self.log('Error in set pixels: index out of range, changing to last pixel') if self.pixels[index] != color: - pub.sendMessage('log', msg='[LED] Setting eye colour: ' + color) + self.log('Setting eye colour: ' + color) self.set(index, NeoPx.COLOR_MAP[color]) def party(self): @@ -279,10 +285,10 @@ def animate(self, identifiers, color, animation): :return: """ if self.animation: - pub.sendMessage('log', msg='[LED] Animation already started. Command ignored') + self.log('Animation already started. Command ignored') return - pub.sendMessage('log', msg='[LED] Animation starting: ' + animation) + self.log('Animation starting: ' + animation) animations = { 'spinner': self.spinner, diff --git a/modules/network/arduinoserial.py b/modules/network/arduinoserial.py index b7459cc0..d716fa99 100644 --- a/modules/network/arduinoserial.py +++ b/modules/network/arduinoserial.py @@ -2,9 +2,10 @@ import time from modules.network.robust_serial.robust_serial import write_order, Order, write_i8, write_i16, read_i8, read_i16, read_i32, read_order from modules.network.robust_serial.utils import open_serial_port -from pubsub import pub -class ArduinoSerial: +from modules.base_module import BaseModule + +class ArduinoSerial(BaseModule): """ Communicate with Arduino over Serial """ @@ -20,7 +21,9 @@ def __init__(self, **kwargs): self.baudrate = kwargs.get('baudrate', 115200) self.serial_file = ArduinoSerial.initialise(self.port, self.baudrate) self.file = None - pub.subscribe(self.send, 'serial') + + def setup_messaging(self): + self.subscribe('serial', self.send) @staticmethod def initialise(port, baudrate): @@ -35,7 +38,7 @@ def initialise(port, baudrate): # # Initialize communication with Arduino # while not is_connected and attempts > 0: # attempts = attempts -1 - # pub.sendMessage('log', msg="[ArduinoSerial] Waiting for arduino...") + # self.log("Waiting for arduino...") # write_order(serial_file, Order.HELLO) # bytes_array = bytearray(serial_file.read(1)) # if not bytes_array: @@ -45,9 +48,9 @@ def initialise(port, baudrate): # if byte in [Order.HELLO.value, Order.ALREADY_CONNECTED.value]: # is_connected = True # if is_connected: - # pub.sendMessage('log', msg="[ArduinoSerial] Connected to Arduino") + # self.log("Connected to Arduino") # else: - # pub.sendMessage('log', msg="[ArduinoSerial] NOT CONNECTED") + # self.log("NOT CONNECTED") # serial_file = None return serial_file @@ -69,30 +72,26 @@ def send(self, type, identifier, message): """ # If serial_file is None, call initialise(), if still fails then exit if self.serial_file is None: - pub.sendMessage('led', identifiers='status5', color='red') - pub.sendMessage('log', msg="[ArduinoSerial] Attempting to recover connection...") + self.log("Attempting to recover connection...") self.serial_file = ArduinoSerial.initialise() if self.serial_file is None: return - pub.sendMessage('led', identifiers='status5', color='blue') - # print('[ArduinoSerial] ' + str(ArduinoSerial.type_map[type]) + ' id: ' + str(identifier) + ' val: ' + str(message)) - - pub.sendMessage('log', msg='[ArduinoSerial] ' + str(ArduinoSerial.type_map[type]) + ' id: ' + str(identifier) + ' val: ' + str(message)) + self.log(str(ArduinoSerial.type_map[type]) + ' id: ' + str(identifier) + ' val: ' + str(message)) if type == ArduinoSerial.DEVICE_SERVO or type == 'servo': write_order(self.serial_file, Order.SERVO) write_i8(self.serial_file, identifier) write_i16(self.serial_file, int(message)) - pub.sendMessage('log', msg="[ArduinoSerial] Servo(relative) " + str(identifier) + " " + str(message)) + self.log("Servo(relative) " + str(identifier) + " " + str(message)) # print('[ArduinoSerial] Moved value from Arduino: ' + str(self.read16())) - pub.sendMessage('log', msg='[ArduinoSerial] Moved value from Arduino: ' + str(self.read16())) + self.log('Moved value from Arduino: ' + str(self.read16())) if type == ArduinoSerial.DEVICE_SERVO_RELATIVE or type == 'servo_relative': write_order(self.serial_file, Order.SERVO_RELATIVE) write_i8(self.serial_file, identifier) write_i16(self.serial_file, int(message)) - pub.sendMessage('log', msg="[ArduinoSerial] Servo(relative) " + str(identifier) + " " + str(message)) + self.log("Servo(relative) " + str(identifier) + " " + str(message)) # print('[ArduinoSerial] Moved value from Arduino: ' + str(self.read16())) - pub.sendMessage('log', msg='[ArduinoSerial] Moved value from Arduino: ' + str(self.read16())) + self.log('Moved value from Arduino: ' + str(self.read16())) elif type == ArduinoSerial.DEVICE_LED or type == 'led': write_order(self.serial_file, Order.LED) if isinstance(identifier, list) or isinstance(identifier, range): @@ -116,9 +115,6 @@ def send(self, type, identifier, message): write_i8(self.serial_file, message) elif type == ArduinoSerial.DEVICE_PIN_READ or type == 'pin_read': - pub.sendMessage('led', identifiers='status5', color='green') write_order(self.serial_file, Order.READ) write_i8(self.serial_file, identifier) - pub.sendMessage('led', identifiers='status5', color='off') return read_i16(self.serial_file) - pub.sendMessage('led', identifiers='status5', color='off') diff --git a/modules/network/messaging_service.py b/modules/network/messaging_service.py new file mode 100644 index 00000000..43b7295e --- /dev/null +++ b/modules/network/messaging_service.py @@ -0,0 +1,88 @@ +import json +import paho.mqtt.client as mqtt +from pubsub import pub + +class MessagingService: + def __init__(self, **kwargs): + self.protocol = kwargs.get('protocol', 'pubsub') + self.messaging_service = None + if self.protocol == 'mqtt': + self.host = kwargs.get('mqtt_host', 'localhost') + self.port = kwargs.get('port', 1883) + self.messaging_service = MQTTMessagingService(broker=self.host, port=self.port) + elif self.protocol == 'pubsub': + self.messaging_service = PubSubMessagingService() + else: + raise ValueError(f"Invalid protocol: {self.protocol}") + + """Base class for messaging services""" + def subscribe(self, topic, callback, **kwargs): + raise NotImplementedError + + def publish(self, topic, message=None): + raise NotImplementedError + + +class PubSubMessagingService(MessagingService): + def __init__(self): + self.protocol = 'pubsub' + print("[PubSubMessagingService] Initialized") + + """pypubsub-based messaging implementation""" + def subscribe(self, topic, callback, **kwargs): + """ + Subscribe to a topic. + + :param topic: Topic string (e.g., 'system/log'). + :param callback: Callback function. + :param kwargs: Optional keyword arguments. + + Example: + ``` + def callback(message): + print(message) + + messaging_service.subscribe('system/log', callback) + + ``` + """ + print(f"[PubSubMessagingService] Subscribing to {topic} to call {callback.__self__.__class__.__name__}.{callback.__name__}") + pub.subscribe(callback, topic, **kwargs) + + def publish(self, topic, *args, **kwargs): + """ + Publish a message to a topic. + + - If only one argument is provided, send it as-is. + - If multiple arguments or keyword arguments are provided, send as JSON. + + :param topic: Topic string (e.g., 'system/log'). + :param args: Optional positional arguments. + :param kwargs: Optional keyword arguments. + """ + + if len(args) == 1 and not kwargs: + pub.sendMessage(topic, message=args[0]) + else: + # pass kwargs to pubsub + pub.sendMessage(topic, **kwargs) + +class MQTTMessagingService(MessagingService): + """MQTT-based messaging implementation""" + def __init__(self, broker="localhost", port=1883): + self.protocol = 'mqtt' + print(f"[MQTTMessagingService] Connecting to {broker}:{port}") + self.client = mqtt.Client() + # self.client.on_message = self._on_message + self.client.connect(broker, port, 60) + self.subscriptions = {} + self.client.loop_start() + + def __del__(self): + self.client.loop_stop() + + def subscribe(self, topic, callback, **kwargs): + raise NotImplementedError + + def publish(self, topic, message=None): + raise NotImplementedError diff --git a/modules/network/rtlsdr.py b/modules/network/rtlsdr.py index ccf32b7b..5ae7f554 100644 --- a/modules/network/rtlsdr.py +++ b/modules/network/rtlsdr.py @@ -4,9 +4,9 @@ import json import subprocess from time import sleep -from pubsub import pub +from modules.base_module import BaseModule -class RTLSDR: +class RTLSDR(BaseModule): def __init__(self, **kwargs): """ RTL Software Defined Radio (SDR) class. @@ -14,21 +14,16 @@ def __init__(self, **kwargs): Service must be started using `rtl_433 -F http` before listening. This is handled with the start and stop methods. - - Subscribes (topics defined in config): - - listen: Listen to all messages and publish. - - Publishes (topics defined in config): - - data: Publish the data to the topic defined in config. """ self.udp_host = kwargs.get('udp_host', "127.0.0.1") self.udp_port = kwargs.get('udp_port', 8433) self.timeout = kwargs.get('timeout', 70) - self.topics = kwargs.get('topics') self.rtl_process = None # Handle for the rtl_433 process - pub.subscribe(self.start_rtl_433, self.topics['subscribe_start']) - pub.subscribe(self.listen_once, self.topics['subscribe_listen']) - pub.subscribe(self.stop_rtl_433, self.topics['subscribe_stop']) + + def setup_messaging(self): + self.subscribe('sdr/start', self.start_rtl_433) + self.subscribe('sdr/listen', self.listen_once) + self.subscribe('sdr/stop', self.stop_rtl_433) def start_rtl_433(self): """Starts the rtl_433 process with HTTP (line) streaming enabled.""" @@ -75,7 +70,7 @@ def handle_event(self, line): try: data = json.loads(line) print(data) - pub.sendMessage(self.topics['publish_data'], data=data) + self.publish('sdr/data', data=data) # Additional custom handling below # Example: print battery and temperature information @@ -117,12 +112,7 @@ def rtl_433_listen(self): if __name__ == "__main__": try: - sdr = RTLSDR(topics={ - 'subscribe_listen': 'sdr/listen', - 'publish_data': 'sdr/data', - 'subscribe_start': 'sdr/start', - 'subscribe_stop': 'sdr/stop' - }) + sdr = RTLSDR() sdr.rtl_433_listen() except KeyboardInterrupt: print('\nExiting.') diff --git a/modules/network/telegrambot.py b/modules/network/telegrambot.py index f8056cc6..2ac4352d 100644 --- a/modules/network/telegrambot.py +++ b/modules/network/telegrambot.py @@ -3,12 +3,12 @@ import logging import os import asyncio -from pubsub import pub +from modules.base_module import BaseModule from telegram import ForceReply, Update from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters -class TelegramBot: +class TelegramBot(BaseModule): def __init__(self, **kwargs): """ Telegram Bot @@ -35,10 +35,6 @@ def __init__(self, **kwargs): self.token = os.getenv('TELEGRAM_BOT_TOKEN', kwargs.get('token', None)) print(self.token) # Topics for pubsub communication - self.topics = kwargs.get('topics', { - 'publish_received': 'telegram/received', - 'subscribe_respond': 'telegram/respond' - }) self.update = None self.user_whitelist = kwargs.get('user_whitelist', []) @@ -52,13 +48,14 @@ def __init__(self, **kwargs): # Set up message handlers application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.publish)) - # Subscribe to the response topic with async wrapper - pub.subscribe(self.async_handle_wrapper, self.topics['subscribe_respond']) - print("Starting Telegram bot...") # Run the bot until the user presses Ctrl-C application.run_polling(allowed_updates=Update.ALL_TYPES) + + def setup_messaging(self): + """Set up messaging for the module.""" + self.subscribe('telegram/respond', self.async_handle_wrapper) @staticmethod async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -83,8 +80,8 @@ async def publish(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> N self.update = update # Publish the message to other parts of the application - pub.sendMessage(self.topics['publish_received'], user_id=user_id, message=message) - print(f"Published message from user {user_id}: {message} on topic {self.topics['publish_received']}") + self.publish('telegram/received', user_id=user_id, message=message) + print(f"Published message from user {user_id}: {message} on topic telegram/received") async def handle(self, user_id: int, response: str) -> None: """Handle responses from the application and send them back to the user.""" @@ -111,16 +108,18 @@ def async_handle_wrapper(self, user_id: int, response: str) -> None: logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) + + bot = TelegramBot() + bot.user_whitelist = [] # Define the simulate_echo function, which will be triggered on 'publish_received' def simulate_echo(user_id, message): print(f"Simulating echo for user {user_id} with message: {message}") # This would echo back as a response to the handle method - pub.sendMessage('telegram/respond', user_id=user_id, response=message) + bot.publish('telegram/respond', user_id=user_id, response=message) # Subscribe the simulate_echo function to the publish_received topic - pub.subscribe(simulate_echo, 'telegram/received') + bot.subscribe('telegram/received', simulate_echo) print("Subscribed to 'telegram/received' topic") - bot = TelegramBot() - bot.user_whitelist = [] \ No newline at end of file + \ No newline at end of file diff --git a/modules/personality.py b/modules/personality.py index b1374c13..0a423cc3 100644 --- a/modules/personality.py +++ b/modules/personality.py @@ -1,125 +1,161 @@ -from random import randint -from time import sleep, localtime -from pubsub import pub +from random import choice, randint from datetime import datetime, timedelta - +from modules.base_module import BaseModule from modules.config import Config -from modules.behaviours.dream import Dream -from modules.behaviours.faces import Faces -from modules.behaviours.motion import Motion -from modules.behaviours.boredom import Boredom -from modules.behaviours.feel import Feel -from modules.behaviours.sleep import Sleep -from modules.behaviours.respond import Respond -from modules.behaviours.objects import Objects -from modules.behaviours.sentiment import Sentiment - -from types import SimpleNamespace - -""" -This class dictates the behaviour of the robot, subscribing to various input events (face matches or motion) -and triggering animations as a result of those behaviours (or lack of) - -It also stores the current 'state of mind' of the robot, so that we can simulate boredom and other emotions based -on the above stimulus. - -To update the personality status from another module, publish to the 'behaviour' topic one of the defined INPUT_TYPE constants: -from pubsub import pub -pub.sendMessage('behaviour', type=Personality.INPUT_TYPE_FUN) -""" - -class Personality: - +class Personality(BaseModule): def __init__(self, **kwargs): - self.mode = kwargs.get('mode', Config.MODE_LIVE) - self.state = Config.STATE_SLEEPING self.eye = 'blue' + self.object_reaction_end_time = None - pub.subscribe(self.loop, 'loop:1') - pub.subscribe(self.process_sentiment, 'sentiment') + # Configurable interval range + self.min_interval = kwargs.get('min_interval', 20) # Default minimum 20 seconds + self.max_interval = kwargs.get('max_interval', 60) # Default maximum 60 seconds + + self.last_motion_time = datetime.now() + self.last_vision_time = None + self.next_action_time = self.calculate_next_action_time() + self.last_status_time = None + self.last_serial_time = None - behaviours = { 'boredom': Boredom(self), - 'dream': Dream(self), - 'faces': Faces(self), - 'motion': Motion(self), - 'sleep': Sleep(self), - 'feel': Feel(self), - 'objects': Objects(self), - 'respond': Respond(self), - 'sentiment': Sentiment(self)} - - self.behaviours = SimpleNamespace(**behaviours) + # Initialize status LED colors (default to 'off') + self.led_colors = ['off'] * 5 + + # Define possible actions + self.actions = [ + self.braillespeak, + self.move_antenna, + self.move_antenna, + self.move_antenna, + self.move_antenna, + ] + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/1', self.loop) + self.subscribe('vision/detections', self.handle_vision_detections) + self.subscribe('motion', self.update_motion_time) + self.subscribe('serial', self.track_serial_idle) def loop(self): - # pub.sendMessage('speech', text="Hello, I am happy") # for testing sentiment responses - if not self.is_asleep() and not self.behaviours.faces.face_detected and not self.behaviours.motion.is_motion() and not self.behaviours.objects.is_detected: - self.set_eye('red') - - if self.state == Config.STATE_ALERT and self.lt(self.behaviours.faces.last_face, self.past(2*60)) and self.lt(self.behaviours.objects.last_detection, self.past(2*60)): - # reset to idle position after 2 minutes inactivity - pub.sendMessage('animate', action="wake") - self.set_state(Config.STATE_IDLE) - - def process_sentiment(self, score): - pub.sendMessage('log', msg="[Personality] Sentiment: " + str(score)) - if score > 0: - pub.sendMessage('piservo:move', angle=0) - else: - pub.sendMessage('piservo:move', angle=40) + # Handle ongoing object reaction + if self.object_reaction_end_time and datetime.now() >= self.object_reaction_end_time: + self.publish('led', identifiers=[ + 'right', 'top_right', 'top_left', 'left', + 'bottom_left', 'bottom_right' + ], color="off") + self.object_reaction_end_time = None + + # Update the middle eye LED based on conditions + self.update_middle_eye_led() + + self.random_neopixel_status() + + # Check if it's time for the next action + if datetime.now() >= self.next_action_time: + action = choice(self.actions) + action() + self.next_action_time = self.calculate_next_action_time() - def set_eye(self, color): - if self.eye == color: - return - # pub.sendMessage('led', identifiers=['left', 'right'], color='off') - pub.sendMessage('led:eye', color=color) - self.eye = color - - def set_state(self, state): - if self.state == state: - return - - pub.sendMessage('log', msg="[Personality] State: " + str(state)) - if state == Config.STATE_SLEEPING: - pub.sendMessage("sleep") - pub.sendMessage("animate", action="sleep") - pub.sendMessage("animate", action="sit") - pub.sendMessage("led:off") - pub.sendMessage("led", identifiers=['status1'], color="off") - pub.sendMessage('piservo:move', angle=0) - elif state == Config.STATE_RESTING: - pub.sendMessage('rest') - pub.sendMessage("animate", action="sit") - pub.sendMessage("animate", action="sleep") - self.set_eye('blue') - pub.sendMessage("led", identifiers=['status1'], color="red") - pub.sendMessage('piservo:move', angle=-40) - elif state == Config.STATE_IDLE: - if self.state == Config.STATE_RESTING or self.state == Config.STATE_SLEEPING: - pub.sendMessage('wake') - pub.sendMessage('animate', action="wake") - pub.sendMessage('animate', action="sit") - pub.sendMessage("led", identifiers=['status1'], color="green") - pub.sendMessage('piservo:move', angle=-20) - self.set_eye('blue') - elif state == Config.STATE_ALERT: - if self.state == Config.STATE_RESTING or self.state == Config.STATE_SLEEPING: - pub.sendMessage('wake') - pub.sendMessage('animate', action="wake") - # pub.sendMessage('animate', action="stand") - pub.sendMessage('piservo:move', angle=0) - pub.sendMessage("led", identifiers=['status1'], color="blue") - self.state = state - - def is_asleep(self): - return self.state == Config.STATE_SLEEPING - - def is_resting(self): - return self.state == Config.STATE_SLEEPING or self.state == Config.STATE_RESTING - - def lt(self, date, compare): - return date is None or date < compare - - def past(self, seconds): - return datetime.now() - timedelta(seconds=seconds) \ No newline at end of file + # If serial has been idle for more than 10 seconds, call random_animation() + if self.last_serial_time and datetime.now() - self.last_serial_time > timedelta(seconds=10): + self.random_animation() + + def random_animation(self): + animations = [ + 'head_shake', + 'head_left', + 'head_right', + 'wake', + 'look_down', + 'look_up', + 'celebrate' + ] + animation = choice(animations) + self.log(f"Random animation triggered: {animation}") + self.publish('animate', action=animation) + + # Calculate the next action time + def calculate_next_action_time(self): + interval = randint(self.min_interval, self.max_interval) # Use configurable interval range + return datetime.now() + timedelta(seconds=interval) + + # Braillespeak: Outputs short messages as tones + def braillespeak(self): + messages = ["Hi", "Hello", "Hai", "Hey"] + msg = choice(messages) + self.publish('speak', msg=msg) + self.log(f"Braillespeak triggered: {msg}") + + # Buzzer: Outputs a specific tone + def buzzer_tone(self): + frequency = randint(300, 1000) # Random frequency between 300Hz and 1000Hz + length = round(randint(1, 5) / 10, 1) # Random length between 0.1s and 0.5s + self.publish('buzz', frequency=frequency, length=length) + self.log(f"Buzzer tone triggered: {frequency}Hz for {length}s") + + # Buzzer: Plays one of two predefined tunes + def buzzer_song(self): + songs = ["happy birthday", "merry christmas"] + song = choice(songs) + self.publish('play', song=song) + self.log(f"Buzzer song triggered: {song}") + + # Neopixels: Toggles random status LEDs + def random_neopixel_status(self): + if not self.last_status_time or datetime.now() - self.last_status_time > timedelta(seconds=3): + self.last_status_time = datetime.now() + color = choice(["red", "green", "blue", "white_dim", "purple", "yellow", "orange", "pink"]) + self.publish('led', identifiers=[0], color=color) + for i in range(4, 0, -1): + if i+1 < 5: + self.led_colors[i] = self.led_colors[i-1] + for i in range(1, 5): + self.publish('led', identifiers=[i], color=self.led_colors[i]) + self.led_colors[0] = color + self.log(message=f"Neopixel status triggered set to {color}", level='debug') + + # Neopixels: Toggles random eye LEDs + def random_neopixel_eye(self): + positions = [ + 'right', 'top_right', 'top_left', 'left', + 'bottom_left', 'bottom_right' + ] + position = choice(positions) + color = choice(["white_dim"]) + self.publish('led', identifiers=positions, color=color) + self.log(f"Neopixel eye ring set to {color}") + + # Antenna: Moves to a random angle between -40 and 40 degrees + def move_antenna(self): + angle = randint(-40, 40) + self.publish('piservo/move', angle=angle) + self.log(f"Antenna moved to angle: {angle}") + + # Vision: Handles detected objects + def handle_vision_detections(self, matches): + # if there are matches and the object reaction end time is in the past + if matches and len(matches) > 0 and (self.object_reaction_end_time is None or datetime.now() >= self.object_reaction_end_time): + self.log(f"Vision detected objects: {matches}") + self.last_vision_time = datetime.now() + # Trigger temporary reaction for detected objects + self.random_neopixel_eye() + self.object_reaction_end_time = datetime.now() + timedelta(seconds=3) + + # Motion: Updates the last motion time + def update_motion_time(self): + self.last_motion_time = datetime.now() + + # Updates the middle eye LED based on the current state + def update_middle_eye_led(self): + now = datetime.now() + if self.last_vision_time and now - self.last_vision_time <= timedelta(seconds=30): + self.publish('led', identifiers='middle', color='green') + elif now - self.last_motion_time > timedelta(seconds=30): + self.publish('led', identifiers='middle', color='red') + else: + self.publish('led', identifiers='middle', color='blue') + + def track_serial_idle(self, type, identifier, message): + self.last_serial_time = datetime.now() \ No newline at end of file diff --git a/modules/pitemperature.py b/modules/pitemperature.py index 7e7a1eac..8d88353d 100644 --- a/modules/pitemperature.py +++ b/modules/pitemperature.py @@ -1,14 +1,15 @@ import os -from pubsub import pub +from modules.base_module import BaseModule -class PiTemperature: +class PiTemperature(BaseModule): WARNING_TEMP = 80 THROTTLED_TEMP = 85 AVG_TEMP = 50 MIN_TEMP = 15 - - def __init__(self): - pub.subscribe(self.monitor, 'loop:60') + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/60', self.monitor) @staticmethod def read(): @@ -17,14 +18,16 @@ def read(): def monitor(self): val = self.read() - pub.sendMessage('log', msg='[TEMP] ' + val) - pub.sendMessage('temperature', value=val) - val = float(val) - if val >= PiTemperature.THROTTLED_TEMP: - pub.sendMessage('led', identifiers='status2', color='red') # WARNING + #get degrees celsius symbol + outputval = val + u"\u00b0" + "C" + self.publish('system/temperature', val) + float_val = float(val) + if float_val > PiTemperature.THROTTLED_TEMP: + self.log(f'Temperature is critical: {outputval}', 'critical') + elif float_val > PiTemperature.WARNING_TEMP: + self.log(f'Temperature is high: {outputval}', 'warning') else: - pub.sendMessage('led', identifiers='status2', color=self.map_range(round(val)), gradient='br') # right - + self.log(f'Temperature: {outputval}', 'debug') def map_range(self, value): # Cap range for LED if value > PiTemperature.WARNING_TEMP: diff --git a/modules/sensor.py b/modules/sensor.py index 855171b0..79320a0f 100644 --- a/modules/sensor.py +++ b/modules/sensor.py @@ -1,7 +1,8 @@ from gpiozero import MotionSensor -from pubsub import pub +from time import sleep +from modules.base_module import BaseModule -class Sensor: +class Sensor(BaseModule): def __init__(self, **kwargs): """ Sensor class @@ -13,21 +14,32 @@ def __init__(self, **kwargs): Publishes 'motion' when motion detected Example: - pub.subscribe(handleMotion, 'motion') + self.subscribe('motion', callback) """ self.pin = kwargs.get('pin') self.value = None self.sensor = MotionSensor(self.pin) - pub.subscribe(self.loop, 'loop:1') + + if kwargs.get('test_on_boot'): + self.test() + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop/1', self.loop) def loop(self): if self.read(): - pub.sendMessage('motion') + self.publish('motion') def read(self): self.value = self.sensor.motion_detected return self.value + + def test(self): + while True: + print(self.read()) + sleep(1) # def watch(self, edge, callback): # """ diff --git a/modules/viam/viamclassifier.py b/modules/viam/viamclassifier.py deleted file mode 100644 index c8736ab8..00000000 --- a/modules/viam/viamclassifier.py +++ /dev/null @@ -1,98 +0,0 @@ -from pubsub import pub - -import asyncio -import os - -from viam.robot.client import RobotClient -from viam.rpc.dial import Credentials, DialOptions -from viam.services.vision import VisionClient -from viam.components.camera import Camera -from datetime import datetime, timedelta -from viam.logging import getLogger -LOGGER = getLogger(__name__) - -class ViamClassifier: - def __init__(self, **kwargs): - """ - Viam Classifier - Allows classification of objects using the VIAM Vision service - - Install the VIAM SDK before using. - pip install viam-sdk - - Requires secrets to be set in the environment variables: - - ROBOT_API_KEY - - ROBOT_API_KEY_ID - - ROBOT_ADDRESS - See wiki for more information. - - Subscribes to: - - vision:start - - exit - - Publishes: - - log - - vision:detections - - vision:nomatch - - :param robot: RobotClient instance (set after creation) - Example: - classifier = ViamClassifier() - classifier.robot = robot - classifier.enable() - results = classifier.detect() - """ - self.enabled = False - pub.subscribe(self.enable, 'vision:start') - pub.subscribe(self.exit, 'exit') - self._robot = None - self.classifier_name = kwargs.get('classifier_name', 'my-classifier') - self.camera_name = kwargs.get('camera_name', 'camera') - self.last_capture = datetime.now() - timedelta(seconds=60) - self.disable_timeout = None # Used to disable vision after 5 seconds of no detections - - @property - def robot(self): - return self._robot - - @robot.setter - def robot(self, value): - self._robot = value - self.classifier = VisionClient.from_robot(self._robot, self.classifier_name) - self.camera = Camera.from_robot(robot=self._robot, name=self.camera_name) - - def enable(self): - self.disable_timeout = datetime.now() + timedelta(seconds=5) - self.enabled = True - - def disable(self): - self.enabled = False - - def exit(self): - pass - - async def detect(self): - if not self.enabled: - return - if self.last_capture + timedelta(seconds=1) > datetime.now(): - return - - img = await self.camera.get_image() - detections = await self.classifier.get_classifications(img, 1) - - found = False - date_time = datetime.now().strftime("%m%d%Y%H%M%S%f") - # print(detections) - pub.sendMessage('vision:detections', matches=detections) - for d in detections: - if d.confidence > 0.6: - pub.sendMessage('log', msg='[Gesture] Detected: ' + d.class_name) - self.disable_timeout = datetime.now() + timedelta(seconds=5) - self.last_capture = datetime.now() - found = True - if found: - return - pub.sendMessage('log', msg='[Gesture] None') - if self.disable_timeout < datetime.now(): - pub.sendMessage('vision:nomatch') - self.disable() \ No newline at end of file diff --git a/modules/viam/viamobjects.py b/modules/viam/viamobjects.py deleted file mode 100644 index b180ec23..00000000 --- a/modules/viam/viamobjects.py +++ /dev/null @@ -1,106 +0,0 @@ -from pubsub import pub - -import asyncio -import os - -from viam.robot.client import RobotClient -from viam.rpc.dial import Credentials, DialOptions -from viam.services.vision import VisionClient -from viam.components.camera import Camera -from datetime import datetime, timedelta -from viam.logging import getLogger -LOGGER = getLogger(__name__) - -class ViamObjects: - def __init__(self, **kwargs): - """ - Viam Objects - Allows detection of objects using the VIAM Vision service - - Install the VIAM SDK before using. - pip install viam-sdk - - Subscribes to: - - vision:start - - exit - - Publishes: - - log - - vision:detections - - vision:nomatch - - :param robot: RobotClient instance (set after creation) - Example: - objects = ViamObjects() - objects.robot = robot - objects.enable() - results = objects.detect() - """ - self.enabled = False - pub.subscribe(self.enable, 'vision:start') - pub.subscribe(self.exit, 'exit') - self._robot = None - self.vision_client = kwargs.get('vision_client', 'objectDetector') - self.camera_name = kwargs.get('camera_name', 'camera') - self.timelapse_location = kwargs.get('timelapse_location') - - self.last_capture = datetime.now() - timedelta(seconds=60) - self.disable_timeout = None # Used to disable vision after 5 seconds of no detections - - # Robot setter and getter - @property - def robot(self): - return self._robot - - @robot.setter - def robot(self, value): - self._robot = value - # make sure that your detector name in the app matches "myPeopleDetector" - self.detector = VisionClient.from_robot(self.robot, self.vision_client) - # make sure that your camera name in the app matches "my-camera" - self.camera = Camera.from_robot(robot=self.robot, name=self.camera_name) - - def enable(self): - # print("ENABLING VISION") - self.disable_timeout = datetime.now() + timedelta(seconds=5) - self.enabled = True - - def disable(self): - # print("DISABLING VISION") - self.enabled = False - - def exit(self): - pass - - async def detect(self): - if not self.enabled: - return - if self.last_capture + timedelta(seconds=60) > datetime.now(): - return - img = await self.camera.get_image() - detections = await self.detector.get_detections(img) - - found = False - date_time = datetime.now().strftime("%m%d%Y%H%M%S%f") - pub.sendMessage('vision:detections', matches=detections) - for d in detections: - if d.confidence > 0.6: - pub.sendMessage('log', msg='Object detected: ' + d.class_name) - self.disable_timeout = datetime.now() + timedelta(seconds=5) - self.last_capture = datetime.now() - pos = str(d.x_min).zfill(3) + str(d.y_min).zfill(3) + str(d.x_max).zfill(3) + str(d.y_max).zfill(3) - # print(d.class_name + pos) - - found = True - if self.timelapse_location is None: - directory = self.timelapse_location - if not os.path.exists(directory): - os.makedirs(directory) - img.save(directory + '/' + date_time + pos + '.png') - - if found: - return - else: - if self.disable_timeout < datetime.now(): - pub.sendMessage('vision:nomatch') - self.disable() \ No newline at end of file diff --git a/modules/vision/imx500/calibration.py b/modules/vision/imx500/calibration.py index 8b6888a6..2834f93b 100644 --- a/modules/vision/imx500/calibration.py +++ b/modules/vision/imx500/calibration.py @@ -1,11 +1,10 @@ -from pubsub import pub from time import sleep from .tracking import Tracking - +from modules.base_module import BaseModule """ To enable, disable tracking.active, import into main.py and pass the vision and tracking instances from ModuleLoader. """ -class Calibration: +class Calibration(BaseModule): def __init__(self, vision, tracking): self.vision = vision self.tracking = tracking @@ -35,9 +34,9 @@ def calibrate_servo_movement(self, axis=0, label=None, match=None, servo_move_pc # Move the servos a preset percentage if axis == 0: - pub.sendMessage('servo:pan:mv', percentage=servo_move_pct) + self.publish('servo:pan:mv', percentage=servo_move_pct) else: - pub.sendMessage('servo:tilt:mv', percentage=servo_move_pct) + self.publish('servo:tilt:mv', percentage=servo_move_pct) # Wait for the servo movement to complete (e.g., debounce time) sleep(5) # This can be adjusted as per servo speed diff --git a/modules/vision/imx500/tracking.py b/modules/vision/imx500/tracking.py index f886f2f4..e956ba79 100644 --- a/modules/vision/imx500/tracking.py +++ b/modules/vision/imx500/tracking.py @@ -1,9 +1,9 @@ import asyncio -from pubsub import pub from threading import Thread from time import sleep +from modules.base_module import BaseModule -class Tracking: +class Tracking(BaseModule): TRACKING_THRESHOLD = (50, 50) VIDEO_SIZE = (640, 480) # Pixel dimensions of the image VIDEO_CENTER = (VIDEO_SIZE[0] / 2, VIDEO_SIZE[1] / 2) @@ -25,25 +25,22 @@ def __init__(self, **kwargs): Subscribes to 'exit' to set tracking state to inactive Example: - pub.sendMessage('vision:detections', matches=matches) - pub.sendMessage('vision:stable') - pub.sendMessage('rest') - pub.sendMessage('wake') - pub.sendMessage('sleep') - pub.sendMessage('exit') + self.publish('vision:detections', matches=matches) + self.publish('vision:stable') + self.publish('rest') + self.publish('wake') + self.publish('sleep') + self.publish('exit') """ self.active = kwargs.get('active', True) self.moving = False self.camera = kwargs.get('camera', None) self.filter = kwargs.get('filter', 'person') - # Subscribe to vision and servo-related topics - pub.subscribe(self.handle, 'vision:detections') - pub.subscribe(self.unfreeze, 'vision:stable') - pub.subscribe(self.set_state, 'rest', active=True) - pub.subscribe(self.set_state, 'wake', active=True) - pub.subscribe(self.set_state, 'sleep', active=False) - pub.subscribe(self.set_state, 'exit', active=False) + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('vision:detections', self.handle) + self.subscribe('vision:stable', self.unfreeze, ) def set_state(self, active): """Set the tracking state (active/inactive).""" @@ -104,9 +101,9 @@ def track_match(self, match): print(f"Moving Y: {y_move} ({match['distance_y']})") if x_move: - pub.sendMessage('servo:pan:mv', percentage=x_move) + self.publish('servo:pan:mv', percentage=x_move) if y_move: - pub.sendMessage('servo:tilt:mv', percentage=y_move) + self.publish('servo:tilt:mv', percentage=y_move) # move_time = max(abs(max(x_move, y_move)) / 50, 1) # min 1 second # print(f"Move time: {move_time}") diff --git a/modules/vision/imx500/vision.py b/modules/vision/imx500/vision.py index 735f1edf..2c1e8e88 100644 --- a/modules/vision/imx500/vision.py +++ b/modules/vision/imx500/vision.py @@ -11,10 +11,10 @@ postprocess_nanodet_detection) from libcamera import Transform -from pubsub import pub +from modules.base_module import BaseModule -class Detection: +class Detection(BaseModule): def __init__(self, imx500, picam2, selfref, coords, category, conf, metadata): """Create a Detection object, recording the bounding box, category and confidence.""" self.category = category @@ -145,8 +145,10 @@ def __init__(self, **kwargs): self.previous_frame = None self.stable_frame_count = 0 self.moving = False - - pub.subscribe(self.scan, 'loop') + + def setup_messaging(self): + """Subscribe to necessary topics.""" + self.subscribe('system/loop', self.scan) def scan(self): self.last_results = self.parse_detections(self.picam2.capture_metadata()) @@ -154,7 +156,7 @@ def scan(self): for i in self.last_results: this_capture = [obj.json_out() for obj in self.last_results] - pub.sendMessage('vision:detections', matches=this_capture) + self.publish('vision:detections', matches=this_capture) return this_capture def parse_detections(self, metadata: dict): @@ -168,7 +170,7 @@ def parse_detections(self, metadata: dict): return self.last_detections elif self.moving == True: self.moving = False - pub.sendMessage('vision:stable') + self.publish('vision:stable') bbox_normalization = self.intrinsics.bbox_normalization threshold = self.args.threshold diff --git a/startup.sh b/startup.sh index 1f909088..aaf87235 100755 --- a/startup.sh +++ b/startup.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Force release of audio devices, fixes "Device or resource busy" error on amplifier +sudo fuser -k /dev/snd/* + # Set the base directory to the location of this script BASE_DIR="$(dirname "$(realpath "$0")")" @@ -8,7 +11,10 @@ sudo pkill -f "$BASE_DIR/main.py" # Start necessary services # sudo modprobe bcm2835-v4l2 # Enable camera (if needed) -sudo pigpiod # Start the GPIO daemon +sudo pigpiod # Start the GPIO daemon. # Run main.py using the virtual environment's Python interpreter "$BASE_DIR/myenv/bin/python3" "$BASE_DIR/main.py" + +# start mosquitto for mqtt broker +# sudo systemctl start mosquitto \ No newline at end of file diff --git a/tests/test_base_module.py b/tests/test_base_module.py new file mode 100644 index 00000000..fad38079 --- /dev/null +++ b/tests/test_base_module.py @@ -0,0 +1,46 @@ +import unittest +from unittest import mock +from modules.base_module import BaseModule + +class TestBaseModule(unittest.TestCase): + + def setUp(self): + self.module = BaseModule() + self.mock_messaging_service = mock.Mock() + self.module.messaging_service = self.mock_messaging_service + + def test_messaging_service_getter_setter(self): + self.module.messaging_service = self.mock_messaging_service + self.assertEqual(self.module.messaging_service, self.mock_messaging_service) + + def test_publish_no_messaging_service(self): + self.module.messaging_service = None + with self.assertRaises(ValueError) as context: + self.module.publish('test/topic') + self.assertEqual(str(context.exception), "Messaging service not set.") + + def test_publish_with_messaging_service(self): + self.module.publish('test/topic', arg1='value1') + self.mock_messaging_service.publish.assert_called_with('test/topic', arg1='value1') + + def test_subscribe_no_messaging_service(self): + self.module.messaging_service = None + with self.assertRaises(ValueError) as context: + self.module.subscribe('test/topic', lambda x: x) + self.assertEqual(str(context.exception), "Messaging service not set.") + + def test_subscribe_with_messaging_service(self): + callback = lambda x: x + self.module.subscribe('test/topic', callback, arg1='value1') + self.mock_messaging_service.subscribe.assert_called_with('test/topic', callback, arg1='value1') + + def test_log(self): + with mock.patch.object(self.module, 'publish') as mock_publish: + self.module.log('Test message', level='debug') + mock_publish.assert_called() + args, kwargs = mock_publish.call_args + self.assertTrue('log/debug' in args) + self.assertTrue('[BaseModule.test_log' in kwargs['message']) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_logwrapper.py b/tests/test_logwrapper.py new file mode 100644 index 00000000..2b1a7114 --- /dev/null +++ b/tests/test_logwrapper.py @@ -0,0 +1,65 @@ +import unittest +from unittest import mock +import os +import logging +from modules.logwrapper import LogWrapper + +class TestLogWrapper(unittest.TestCase): + + @mock.patch('modules.logwrapper.logging') + def test_init(self, mock_logging): + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug') + self.assertEqual(log_wrapper.path, '/tmp') + self.assertEqual(log_wrapper.filename, 'test.log') + self.assertEqual(log_wrapper.file, '/tmp/test.log') + self.assertEqual(log_wrapper.log_level, 'info') + self.assertEqual(log_wrapper.cli_level, 'debug') + mock_logging.basicConfig.assert_called_with( + filename='/tmp/test.log', + level=logging.INFO, + format='%(levelname)s: %(asctime)s %(message)s', + datefmt='%Y/%m/%d %I:%M:%S %p' + ) + + @mock.patch('modules.logwrapper.logging') + def test_log(self, mock_logging): + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug') + log_wrapper.log('Test message', type='info') + mock_logging.log.assert_called_with(logging.INFO, 'Test message') + + @mock.patch('modules.logwrapper.logging') + def test_log_json_message(self, mock_logging): + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug') + log_wrapper.log('{"message": "Test JSON message"}', type='info') + mock_logging.log.assert_called_with(logging.INFO, 'Test JSON message') + + @mock.patch('modules.logwrapper.logging') + def test_log_with_translator(self, mock_logging): + mock_translator = mock.Mock() + mock_translator.request.return_value = 'Translated message' + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug', translator=mock_translator) + log_wrapper.log('Test message', type='info') + mock_translator.request.assert_called_with('Test message') + mock_logging.log.assert_called_with(logging.INFO, 'Translated message') + + @mock.patch('modules.logwrapper.logging') + def test_setup_messaging(self, mock_logging): + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug') + with mock.patch.object(log_wrapper, 'subscribe') as mock_subscribe: + log_wrapper.setup_messaging() + mock_subscribe.assert_any_call('log', log_wrapper.log) + mock_subscribe.assert_any_call('log/debug', log_wrapper.log, type='debug') + mock_subscribe.assert_any_call('log/info', log_wrapper.log, type='info') + mock_subscribe.assert_any_call('log/error', log_wrapper.log, type='error') + mock_subscribe.assert_any_call('log/critical', log_wrapper.log, type='critical') + mock_subscribe.assert_any_call('log/warning', log_wrapper.log, type='warning') + + @mock.patch('modules.logwrapper.os') + def test_del(self, mock_os): + log_wrapper = LogWrapper(path='/tmp', filename='test.log', log_level='info', cli_level='debug') + log_wrapper.__del__() + mock_os.path.isfile.assert_called_with('/tmp/test.log') + mock_os.rename.assert_called_with('/tmp/test.log', '/tmp/test.log.previous') + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_pitemperature.py b/tests/test_pitemperature.py new file mode 100644 index 00000000..55577c5e --- /dev/null +++ b/tests/test_pitemperature.py @@ -0,0 +1,44 @@ +import unittest +from unittest import mock +from modules.pitemperature import PiTemperature + +class TestPiTemperature(unittest.TestCase): + + @mock.patch('modules.pitemperature.os.popen') + def test_monitor_critical_temp(self, mock_popen): + mock_popen.return_value.readline.return_value = "temp=86'C" + temp_module = PiTemperature() + temp_module.messaging_service = mock.MagicMock() + + with mock.patch.object(temp_module, 'publish') as mock_publish, \ + mock.patch.object(temp_module, 'log') as mock_log: + temp_module.monitor() + mock_publish.assert_called_with('system/temperature', '86') + mock_log.assert_called_with('Temperature is critical: 86°C', 'critical') + + @mock.patch('modules.pitemperature.os.popen') + def test_monitor_high_temp(self, mock_popen): + mock_popen.return_value.readline.return_value = "temp=81'C" + temp_module = PiTemperature() + temp_module.messaging_service = mock.MagicMock() + + with mock.patch.object(temp_module, 'publish') as mock_publish, \ + mock.patch.object(temp_module, 'log') as mock_log: + temp_module.monitor() + mock_publish.assert_called_with('system/temperature', '81') + mock_log.assert_called_with('Temperature is high: 81°C', 'warning') + + @mock.patch('modules.pitemperature.os.popen') + def test_monitor_normal_temp(self, mock_popen): + mock_popen.return_value.readline.return_value = "temp=50'C" + temp_module = PiTemperature() + temp_module.messaging_service = mock.MagicMock() + + with mock.patch.object(temp_module, 'publish') as mock_publish, \ + mock.patch.object(temp_module, 'log') as mock_log: + temp_module.monitor() + mock_publish.assert_called_with('system/temperature', '50') + mock_log.assert_called_with('Temperature: 50°C', 'debug') + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 00000000..673d8778 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,58 @@ +import unittest +from unittest import mock + +# Mock the entire gpiozero module before Sensor gets imported +mock_gpiozero = mock.MagicMock() +mock_gpiozero.MotionSensor = mock.MagicMock() +with mock.patch.dict('sys.modules', {'gpiozero': mock_gpiozero}): + from modules.sensor import Sensor + +class TestSensor(unittest.TestCase): + + def test_init(self): + # Test with pin and without test_on_boot + sensor = Sensor(pin=17) + self.assertEqual(sensor.pin, 17) + self.assertEqual(sensor.sensor, mock_gpiozero.MotionSensor.return_value) + mock_gpiozero.MotionSensor.assert_called_with(17) + + # Test with pin and test_on_boot + with mock.patch.object(Sensor, 'test', return_value=None) as mock_test: + sensor = Sensor(pin=18, test_on_boot=True) + self.assertEqual(sensor.pin, 18) + self.assertEqual(sensor.sensor, mock_gpiozero.MotionSensor.return_value) + mock_gpiozero.MotionSensor.assert_called_with(18) + mock_test.assert_called_once() + + def test_read(self): + mock_sensor_instance = mock_gpiozero.MotionSensor.return_value + mock_sensor_instance.motion_detected = True + + sensor = Sensor(pin=17) + self.assertTrue(sensor.read()) + self.assertTrue(sensor.value) + + mock_sensor_instance.motion_detected = False + self.assertFalse(sensor.read()) + self.assertFalse(sensor.value) + + def test_loop(self): + sensor = Sensor(pin=17) + with mock.patch.object(sensor, 'read', return_value=True): + with mock.patch.object(sensor, 'publish') as mock_publish: + sensor.loop() + mock_publish.assert_called_with('motion') + + with mock.patch.object(sensor, 'read', return_value=False): + with mock.patch.object(sensor, 'publish') as mock_publish: + sensor.loop() + mock_publish.assert_not_called() + + def test_setup_messaging(self): + sensor = Sensor(pin=17) + with mock.patch.object(sensor, 'subscribe') as mock_subscribe: + sensor.setup_messaging() + mock_subscribe.assert_called_with('system/loop/1', sensor.loop) + +if __name__ == '__main__': + unittest.main()