├── .gitignore ├── LICENSE ├── README.md ├── adafruit_drv2605.py ├── axp2101.py ├── examples ├── scroller.py └── touch.py ├── ft6x06.py ├── haptic_motor.py ├── main.py ├── st7789_base.py ├── st7789_ext.py └── sx1262.py /.gitignore: -------------------------------------------------------------------------------- 1 | misc/* 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining 2 | a copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be 10 | included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 13 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 15 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 16 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 18 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MicroPython programming of Lilygo T-WATCH S3. 2 | 3 | This repositry contains the minimal stuff needed to program the T-WATCH 4 | S3 in MicroPython. For now there is code to: 5 | 6 | 1. Setup the AX2101 power manager chip in order to give current to the different subsystems, enable the TFT display backlight, setup charging, and so forth. 7 | 2. Configure SPI correctly in order to use a MicroPython ST7789 display driver that was already [available here](https://github.com/devbis/st7789py_mpy). 8 | 9 | DISCLAIMER: I wrote this code based on the available information and the 10 | C implementation of Lilygo. However this code is not well tested and may 11 | ruin your device. Use it at your own risk. 12 | 13 | ## Installing MicroPython 14 | 15 | **WARNING:** after installing MicroPython you will no longer be able to flash the device with `esptools` if you don't press the *boot* button inside the device, accessible under the battery, [as explained here](https://github.com/Xinyuan-LilyGO/TTGO_TWatch_Library/issues/223#issuecomment-1913183156). 16 | 17 | I just used the generic MicroPython release for the S3 device. 18 | The file name is `ESP32_GENERIC_S3-20231005-v1.21.0.bin`. 19 | 20 | ``` 21 | esptool.py --chip esp32s3 --port /dev/tty.usbmodem* erase_flash 22 | esptool.py --chip esp32s3 --port /dev/tty.usbmodem* write_flash -z 0 ESP32_GENERIC_S3-20231005-v1.21.0.bin 23 | ``` 24 | 25 | ## Transferring the example files on the device 26 | 27 | 28 | mpremote cp *.py : 29 | 30 | Then enter the device in REPL mode: 31 | 32 | mpremote repl 33 | 34 | Hit Ctrl+D to reset the device and you should see a demo usign the 35 | TFT screen. The screen should change color every 2 seconds, a few 36 | random pixels are written. 37 | 38 | # ST7789v Display 39 | 40 | Right now we use [a driver](https://github.com/antirez/ST77xx-pure-MP) I wrote myself for a different project. The driver is conceived to use little memory, but it has an optional framebuffer target (240x240x2 bytes of memory used) that is much faster and is used in the example code here. 41 | 42 | Please note that the MicroPython SoftSPI implementation is *very* slow. 43 | It is important to use the hardware SPI of the ESP32-S3. In order to 44 | setup the SPI for the display, use code like this: 45 | 46 | ``` 47 | # Our display does not have a MISO pin, but the MicroPython 48 | # SPI implementation does not allow to avoid specifying one, so 49 | # we use just a not used pin in the device. 50 | 51 | # Power on the display backlight. 52 | bl = Pin(45,Pin.OUT) 53 | bl.on() 54 | 55 | # Our display does not have a MISO pin, but the MicroPython 56 | # SPI implementation does not allow to avoid specifying one, so 57 | # we use just a not used pin in the device. 58 | display = st7789_ext.ST7789( 59 | SPI(1, baudrate=40000000, phase=0, polarity=1, sck=18, mosi=13, miso=37), 60 | 240, 240, 61 | reset=False, 62 | dc=Pin(38, Pin.OUT), 63 | cs=Pin(12, Pin.OUT), 64 | ) 65 | display.init(landscape=False,mirror_y=True,mirror_x=True,inversion=True) 66 | ``` 67 | 68 | Then you can use directly the graphics primitives (see driver documentation), or if you want more speed, you can enalbe the framebuffer, draw in the framebuffer, and then show the content with the show method: 69 | 70 | ``` 71 | display.enable_framebuffer() 72 | display.fb.fill(display.db_color(0,0,0)) 73 | dispaly.fb.text("Hello world",10,10,10,display.fb_color(50,100,150)) 74 | display.show() 75 | ``` 76 | 77 | The speedup with hardware SPI is around 20x. Performances are much 78 | better using the in-memory framebuffer. Using both, it is possible to write 79 | quite fast graphics. 80 | 81 | ## Scroller example 82 | 83 | The Scroller example shows how to use the TFT driver like a terminal. 84 | 85 | mpremote cp *.py : 86 | mpremote run examples/scroller.py 87 | 88 | You will see numbers on the screen like if it was a terminal 89 | outputting a sequence, with smooth vertical scrolling. 90 | The Scroller also implements line wrapping, but that's not 91 | visible in the example. 92 | 93 | # Touch driver 94 | 95 | You can find the code in `ft6x06.py`. There is a test main at the end 96 | of the file. 97 | 98 | mpremote run ft6x06.py 99 | 100 | Then touch the device screen to see updates. 101 | 102 | Another exmaple, that let you write on the screen with your finger, can 103 | be executed with: 104 | 105 | mpremote cp *.py : 106 | mpremote run examples/touch.py 107 | 108 | # LoRa driver for the SX1262 109 | 110 | Check the `sx1262.py` file. It contains a full implementation of the LoRa driver for this device. There is a small test program at the end of the file. If you want to see a more serious port, check the [FreakWAN](https://github.com/antirez/freakwan) project. 111 | -------------------------------------------------------------------------------- /adafruit_drv2605.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Tony DiCola for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """ 6 | `adafruit_drv2605` 7 | ==================================================== 8 | 9 | Port to Micropython of CircuitPython module for the DRV2605 haptic feedback motor driver. See 10 | examples/simpletest.py for a demo of the usage. 11 | 12 | * Author(s): Tony DiCola 13 | """ 14 | from micropython import const 15 | 16 | from machine import I2C 17 | 18 | __version__ = "0.0.0+auto.0" 19 | __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_DRV2605.git" 20 | 21 | 22 | # Internal constants: 23 | _DRV2605_ADDR = const(0x5A) 24 | _DRV2605_REG_STATUS = const(0x00) 25 | _DRV2605_REG_MODE = const(0x01) 26 | _DRV2605_REG_RTPIN = const(0x02) 27 | _DRV2605_REG_LIBRARY = const(0x03) 28 | _DRV2605_REG_WAVESEQ1 = const(0x04) 29 | _DRV2605_REG_WAVESEQ2 = const(0x05) 30 | _DRV2605_REG_WAVESEQ3 = const(0x06) 31 | _DRV2605_REG_WAVESEQ4 = const(0x07) 32 | _DRV2605_REG_WAVESEQ5 = const(0x08) 33 | _DRV2605_REG_WAVESEQ6 = const(0x09) 34 | _DRV2605_REG_WAVESEQ7 = const(0x0A) 35 | _DRV2605_REG_WAVESEQ8 = const(0x0B) 36 | _DRV2605_REG_GO = const(0x0C) 37 | _DRV2605_REG_OVERDRIVE = const(0x0D) 38 | _DRV2605_REG_SUSTAINPOS = const(0x0E) 39 | _DRV2605_REG_SUSTAINNEG = const(0x0F) 40 | _DRV2605_REG_BREAK = const(0x10) 41 | _DRV2605_REG_AUDIOCTRL = const(0x11) 42 | _DRV2605_REG_AUDIOLVL = const(0x12) 43 | _DRV2605_REG_AUDIOMAX = const(0x13) 44 | _DRV2605_REG_RATEDV = const(0x16) 45 | _DRV2605_REG_CLAMPV = const(0x17) 46 | _DRV2605_REG_AUTOCALCOMP = const(0x18) 47 | _DRV2605_REG_AUTOCALEMP = const(0x19) 48 | _DRV2605_REG_FEEDBACK = const(0x1A) 49 | _DRV2605_REG_CONTROL1 = const(0x1B) 50 | _DRV2605_REG_CONTROL2 = const(0x1C) 51 | _DRV2605_REG_CONTROL3 = const(0x1D) 52 | _DRV2605_REG_CONTROL4 = const(0x1E) 53 | _DRV2605_REG_VBAT = const(0x21) 54 | _DRV2605_REG_LRARESON = const(0x22) 55 | 56 | # User-facing mode value constants: 57 | MODE_INTTRIG = 0x00 58 | MODE_EXTTRIGEDGE = 0x01 59 | MODE_EXTTRIGLVL = 0x02 60 | MODE_PWMANALOG = 0x03 61 | MODE_AUDIOVIBE = 0x04 62 | MODE_REALTIME = 0x05 63 | MODE_DIAGNOS = 0x06 64 | MODE_AUTOCAL = 0x07 65 | LIBRARY_EMPTY = 0x00 66 | LIBRARY_TS2200A = 0x01 67 | LIBRARY_TS2200B = 0x02 68 | LIBRARY_TS2200C = 0x03 69 | LIBRARY_TS2200D = 0x04 70 | LIBRARY_TS2200E = 0x05 71 | LIBRARY_LRA = 0x06 72 | 73 | 74 | class DRV2605: 75 | """TI DRV2605 haptic feedback motor driver module. 76 | 77 | :param I2C i2c: The board I2C object 78 | :param int address: The I2C address 79 | """ 80 | 81 | # Class-level buffer for reading and writing data with the sensor. 82 | # This reduces memory allocations but means the code is not re-entrant or 83 | # thread safe! 84 | _BUFFER = bytearray(1) 85 | 86 | def __init__(self, i2c: I2C, address: int = _DRV2605_ADDR) -> None: 87 | self._device = i2c 88 | self._address = address 89 | # Check chip ID is 3 or 7 (DRV2605 or DRV2605L). 90 | status = self._read_u8(_DRV2605_REG_STATUS) 91 | device_id = (status >> 5) & 0x07 92 | if device_id not in (3, 7): 93 | raise RuntimeError("Failed to find DRV2605, check wiring!") 94 | # Configure registers to initialize chip. 95 | self._write_u8(_DRV2605_REG_MODE, 0x00) # out of standby 96 | self._write_u8(_DRV2605_REG_RTPIN, 0x00) # no real-time-playback 97 | self._write_u8(_DRV2605_REG_WAVESEQ1, 1) # strong click 98 | self._write_u8(_DRV2605_REG_WAVESEQ2, 0) 99 | self._write_u8(_DRV2605_REG_OVERDRIVE, 0) # no overdrive 100 | self._write_u8(_DRV2605_REG_SUSTAINPOS, 0) 101 | self._write_u8(_DRV2605_REG_SUSTAINNEG, 0) 102 | self._write_u8(_DRV2605_REG_BREAK, 0) 103 | self._write_u8(_DRV2605_REG_AUDIOMAX, 0x64) 104 | # Set ERM open-loop mode. 105 | self.use_ERM() 106 | # turn on ERM_OPEN_LOOP 107 | control3 = self._read_u8(_DRV2605_REG_CONTROL3) 108 | self._write_u8(_DRV2605_REG_CONTROL3, control3 | 0x20) 109 | # Default to internal trigger mode and TS2200 A library. 110 | self.mode = MODE_INTTRIG 111 | self.library = LIBRARY_TS2200A 112 | self._sequence = _DRV2605_Sequence(self) 113 | 114 | def _read_u8(self, address: int) -> int: 115 | # Read an 8-bit unsigned value from the specified 8-bit address. 116 | self._device.readfrom_mem_into(self._address, address, self._BUFFER) 117 | return self._BUFFER[0] 118 | 119 | def _write_u8(self, address: int, val: int) -> None: 120 | # Write an 8-bit unsigned value to the specified 8-bit address. 121 | self._BUFFER[0] = val & 0xFF 122 | self._device.writeto_mem(self._address, address, self._BUFFER) 123 | 124 | def play(self) -> None: 125 | """Play back the select effect(s) on the motor.""" 126 | self._write_u8(_DRV2605_REG_GO, 1) 127 | 128 | def stop(self) -> None: 129 | """Stop vibrating the motor.""" 130 | self._write_u8(_DRV2605_REG_GO, 0) 131 | 132 | @property 133 | def mode(self) -> int: 134 | """ 135 | The mode of the chip. Should be a value of: 136 | 137 | * ``MODE_INTTRIG``: Internal triggering, vibrates as soon as you call 138 | play(). Default mode. 139 | * ``MODE_EXTTRIGEDGE``: External triggering, edge mode. 140 | * ``MODE_EXTTRIGLVL``: External triggering, level mode. 141 | * ``MODE_PWMANALOG``: PWM/analog input mode. 142 | * ``MODE_AUDIOVIBE``: Audio-to-vibration mode. 143 | * ``MODE_REALTIME``: Real-time playback mode. 144 | * ``MODE_DIAGNOS``: Diagnostics mode. 145 | * ``MODE_AUTOCAL``: Auto-calibration mode. 146 | 147 | See the datasheet for the meaning of modes beyond MODE_INTTRIG. 148 | """ 149 | return self._read_u8(_DRV2605_REG_MODE) 150 | 151 | @mode.setter 152 | def mode(self, val: int) -> None: 153 | if not 0 <= val <= 7: 154 | raise ValueError("Mode must be a value within 0-7!") 155 | self._write_u8(_DRV2605_REG_MODE, val) 156 | 157 | @property 158 | def library(self) -> int: 159 | """ 160 | The library selected for waveform playback. Should be 161 | a value of: 162 | 163 | * ``LIBRARY_EMPTY``: Empty 164 | * ``LIBRARY_TS2200A``: TS2200 library A (the default) 165 | * ``LIBRARY_TS2200B``: TS2200 library B 166 | * ``LIBRARY_TS2200C``: TS2200 library C 167 | * ``LIBRARY_TS2200D``: TS2200 library D 168 | * ``LIBRARY_TS2200E``: TS2200 library E 169 | * ``LIBRARY_LRA``: LRA library 170 | 171 | See the datasheet for the meaning and description of effects in each 172 | library. 173 | """ 174 | return self._read_u8(_DRV2605_REG_LIBRARY) & 0x07 175 | 176 | @library.setter 177 | def library(self, val: int) -> None: 178 | if not 0 <= val <= 6: 179 | raise ValueError("Library must be a value within 0-6!") 180 | self._write_u8(_DRV2605_REG_LIBRARY, val) 181 | 182 | @property 183 | def sequence(self) -> "_DRV2605_Sequence": 184 | """List-like sequence of waveform effects. 185 | Get or set an effect waveform for slot 0-7 by indexing the sequence 186 | property with the slot number. A slot must be set to either an :class:`~Effect` 187 | or :class:`~Pause` class. See the datasheet for a complete table of effect ID 188 | values and the associated waveform / effect. 189 | 190 | E.g.: 191 | 192 | .. code-block:: python 193 | 194 | # Getting the effect stored in a slot 195 | slot_0_effect = drv.sequence[0] 196 | 197 | # Setting an Effect in the first sequence slot 198 | drv.sequence[0] = Effect(88) 199 | """ 200 | return self._sequence 201 | 202 | @property 203 | def realtime_value(self) -> int: 204 | """The output value used in Real-Time Playback mode. When the device is 205 | switched to ``MODE_REALTIME``, the motor is driven continuously with an 206 | amplitude/direction determined by this value. 207 | 208 | By default, the device expects a SIGNED 8-bit integer, and its exact 209 | effect depends on both the type of motor (ERM/LRA) and whether the device 210 | is operating in open- or closed-loop (unidirectional/bidirectional) mode. 211 | 212 | See the datasheet for more information! 213 | 214 | E.g.: 215 | 216 | .. code-block:: python 217 | 218 | # Start real-time playback 219 | drv.realtime_value = 0 220 | drv.mode = adafruit_drv2605.MODE_REALTIME 221 | 222 | # Buzz the motor briefly at 50% and 100% amplitude 223 | drv.realtime_value = 64 224 | time.sleep(0.5) 225 | drv.realtime_value = 127 226 | time.sleep(0.5) 227 | 228 | # Stop real-time playback 229 | drv.realtime_value = 0 230 | drv.mode = adafruit_drv2605.MODE_INTTRIG 231 | """ 232 | return self._read_u8(_DRV2605_REG_RTPIN) 233 | 234 | @realtime_value.setter 235 | def realtime_value(self, val: int) -> None: 236 | if not -127 <= val <= 255: 237 | raise ValueError("Real-Time Playback value must be between -127 and 255!") 238 | self._write_u8(_DRV2605_REG_RTPIN, val) 239 | 240 | def set_waveform(self, effect_id: int, slot: int = 0) -> None: 241 | """Select an effect waveform for the specified slot (default is slot 0, 242 | but up to 8 effects can be combined with slot values 0 to 7). See the 243 | datasheet for a complete table of effect ID values and the associated 244 | waveform / effect. 245 | 246 | :param int effect_id: The effect ID of the waveform 247 | :param int slot: The sequence slot to use 248 | """ 249 | if not 0 <= effect_id <= 123: 250 | raise ValueError("Effect ID must be a value within 0-123!") 251 | if not 0 <= slot <= 7: 252 | raise ValueError("Slot must be a value within 0-7!") 253 | self._write_u8(_DRV2605_REG_WAVESEQ1 + slot, effect_id) 254 | 255 | # pylint: disable=invalid-name 256 | def use_ERM(self) -> None: 257 | """Use an eccentric rotating mass motor (the default).""" 258 | feedback = self._read_u8(_DRV2605_REG_FEEDBACK) 259 | self._write_u8(_DRV2605_REG_FEEDBACK, feedback & 0x7F) 260 | 261 | # pylint: disable=invalid-name 262 | def use_LRM(self) -> None: 263 | """Use a linear resonance actuator motor.""" 264 | feedback = self._read_u8(_DRV2605_REG_FEEDBACK) 265 | self._write_u8(_DRV2605_REG_FEEDBACK, feedback | 0x80) 266 | 267 | 268 | class Effect: 269 | """DRV2605 waveform sequence effect. 270 | 271 | :param int effect_id: The ID number of the effect 272 | """ 273 | 274 | def __init__(self, effect_id: int) -> None: 275 | self._effect_id = 0 276 | # pylint: disable=invalid-name 277 | self.id = effect_id 278 | 279 | @property 280 | def raw_value(self) -> int: 281 | """Raw effect ID.""" 282 | return self._effect_id 283 | 284 | @property 285 | # pylint: disable=invalid-name 286 | def id(self) -> int: 287 | """Effect ID.""" 288 | return self._effect_id 289 | 290 | @id.setter 291 | # pylint: disable=invalid-name 292 | def id(self, effect_id: int) -> None: 293 | """Set the effect ID.""" 294 | if not 0 <= effect_id <= 123: 295 | raise ValueError("Effect ID must be a value within 0-123!") 296 | self._effect_id = effect_id 297 | 298 | def __repr__(self) -> str: 299 | return "{}({})".format(type(self).__qualname__, self.id) 300 | 301 | 302 | class Pause: 303 | """DRV2605 waveform sequence timed delay. 304 | 305 | :param float duration: The duration of the pause in seconds 306 | """ 307 | 308 | def __init__(self, duration: float) -> None: 309 | # Bit 7 must be set for a slot to be interpreted as a delay 310 | self._duration = 0x80 311 | self.duration = duration 312 | 313 | @property 314 | def raw_value(self) -> int: 315 | """Raw pause duration.""" 316 | return self._duration 317 | 318 | @property 319 | def duration(self) -> float: 320 | """Pause duration in seconds.""" 321 | # Remove wait time flag bit and convert duration to seconds 322 | return (self._duration & 0x7F) / 100.0 323 | 324 | @duration.setter 325 | def duration(self, duration: float) -> None: 326 | """Sets the pause duration in seconds.""" 327 | if not 0.0 <= duration <= 1.27: 328 | raise ValueError("Pause duration must be a value within 0.0-1.27!") 329 | # Add wait time flag bit and convert duration to centiseconds 330 | self._duration = 0x80 | round(duration * 100.0) 331 | 332 | def __repr__(self) -> str: 333 | return "{}({})".format(type(self).__qualname__, self.duration) 334 | 335 | 336 | class _DRV2605_Sequence: 337 | """Class to enable List-like indexing of the waveform sequence slots. 338 | 339 | :param DRV2605 DRV2605_instance: The DRV2605 instance 340 | """ 341 | 342 | def __init__( 343 | self, DRV2605_instance: DRV2605 # pylint: disable=invalid-name 344 | ) -> None: 345 | self._drv2605 = DRV2605_instance 346 | 347 | def __setitem__(self, slot: int, effect: Union[Effect, Pause]) -> None: 348 | """Write an Effect or Pause to a slot.""" 349 | if not 0 <= slot <= 7: 350 | raise IndexError("Slot must be a value within 0-7!") 351 | if not isinstance(effect, (Effect, Pause)): 352 | raise TypeError("Effect must be either an Effect or Pause!") 353 | # pylint: disable=protected-access 354 | self._drv2605._write_u8(_DRV2605_REG_WAVESEQ1 + slot, effect.raw_value) 355 | 356 | def __getitem__(self, slot: int) -> Union[Effect, Pause]: 357 | """Read an effect ID from a slot. Returns either a Pause or Effect class.""" 358 | if not 0 <= slot <= 7: 359 | raise IndexError("Slot must be a value within 0-7!") 360 | # pylint: disable=protected-access 361 | slot_contents = self._drv2605._read_u8(_DRV2605_REG_WAVESEQ1 + slot) 362 | if slot_contents & 0x80: 363 | return Pause((slot_contents & 0x7F) / 100.0) 364 | return Effect(slot_contents) 365 | 366 | def __iter__(self) -> Union[Effect, Pause]: 367 | """Returns an iterator over the waveform sequence slots.""" 368 | for slot in range(0, 8): 369 | yield self[slot] 370 | 371 | def __repr__(self) -> str: 372 | """Return a string representation of all slot's effects.""" 373 | return repr(list(self)) 374 | -------------------------------------------------------------------------------- /axp2101.py: -------------------------------------------------------------------------------- 1 | from machine import Pin, SoftI2C 2 | 3 | # Setup the AXP2101 to power the device 4 | class AXP2101: 5 | def __init__(self): 6 | self.i2c = SoftI2C(sda=Pin(10), scl=Pin(11)) 7 | self.slave_addr = 0x34 # AXP2101 i2c slave address. 8 | 9 | if False: 10 | # Set above to True if you want to list all the address 11 | # of reachable devices. In the t-watch S3 they should 12 | # be 25, 52, 81, 90. Corresponding (in random order) to 13 | # the haptic motor, RTC clock, accelerometer and the 14 | # AXP2101. 15 | print("i2c devices replying to SDA:10 SCL:11",i2c.scan()) 16 | 17 | def read(self,reg): 18 | data = self.i2c.readfrom_mem(self.slave_addr,reg,1) 19 | return data[0] 20 | 21 | def write(self,reg,val): 22 | self.i2c.writeto_mem(self.slave_addr,reg,bytearray([val])) 23 | 24 | def setbit(self,reg,bit): 25 | oldval = self.read(reg) 26 | oldval |= 1<= dv: return 100 50 | return int(100/dv*v) 51 | 52 | # T-WATCH S3 specific power-on steps. 53 | def twatch_s3_poweron(self): 54 | # Read PMU STATUS 1 55 | pmu_status = self.read(0x00) 56 | print("[AXP2101] PMU status 1 at startup", bin(pmu_status)) 57 | 58 | # Set vbus voltage limit to 4.36v 59 | # Register 0x15 is Input voltage limit control. 60 | # A value of 6 means 4.36v as volts = 3.88 + value * 0.08 61 | # This should be the default. 62 | self.write(0x15,6) 63 | 64 | # Read it back. 65 | v = self.read(0x15) 66 | print("[AXP2101] vbus voltage limit set to", 3.88+v*0.08) 67 | 68 | # Set input current limit to 100ma. The value for 100ma is just 0, 69 | # and the regsiter 0x16 is "Input current limit control". 70 | self.write(0x16,0) 71 | 72 | # Set the voltage to sense in order to power-off the device. 73 | # we set it to 2.6 volts, that is the minimum, corresponding 74 | # to a value of 0 written in the 0x24 register named 75 | # "Vsys voltage for PWROFF threshold setting". 76 | self.write(0x24,0) 77 | 78 | # Now we need to set output voltages of the different output 79 | # "lines" we have. There are successive registers to set the 80 | # voltage: 81 | # 82 | # 0x92 for ALDO1 (RTC) 83 | # 0x93 for ALDO2 (TFT backlight) 84 | # 0x94 for ALDO3 (touchscreen driver) 85 | # 0x95 for ALDO4 (LoRa chip) 86 | # 0x96 for BLD01 is not used 87 | # 0x97 for BLD02 (drv2605, that is the haptic motor) 88 | # 89 | # We will set a current of 3.3 volts for all those. 90 | # The registers to value 'v' so that voltage is 91 | # 0.5 + (0.1*v), so to get 3.3 we need to set the register 92 | # to the value of 28. 93 | for reg in [0x92, 0x93, 0x94, 0x95, 0x97]: 94 | self.write(reg,28) 95 | 96 | # Note that while we set the voltages, currently the 97 | # output lines (but DC1, that powers the ESP32 and is already 98 | # enabled at startup) may be off, so we need to enable them. 99 | # Let's show the current situation by reading the folliwing 100 | # registers: 101 | # 0x90, LDOS ON/OFF control 0 102 | # 0x91, LDOS ON/OFF control 1 103 | # 0x80, DCDCS ON/OFF and DVM control 104 | # that is the one controlling what is ON or OFF: 105 | for reg in [0x90, 0x91, 0x80]: 106 | b = self.read(reg) 107 | print(f"[AXP2101] ON/OFF Control value for {hex(reg)}:", bin(b)) 108 | 109 | # Only enable DC1 from register 0x80 110 | self.write(0x80,1) 111 | 112 | # Enable ADLO1, 2, 3, 4, BLDO2 from register 0x90 113 | # and disable all the rest. 114 | self.write(0x90,1+2+4+8+32) 115 | self.write(0x91,0) 116 | 117 | # Disable TS pin measure channel from the ADC, it 118 | # causes issues while charging the device. 119 | # This is performed clearing bit 1 from the 120 | # 0x30 register: ADC channel enable control. 121 | self.clearbit(0x30,1) 122 | 123 | self.setbit(0x68,0) # Enable battery detection. 124 | self.setbit(0x30,0) # Enable battery voltage ADC channel. 125 | self.setbit(0x30,2) # Enable vbus voltage ADC channel. 126 | self.setbit(0x30,3) # Enable system voltage ADC channel. 127 | 128 | # We disable all IRQs: we don't use them for now. 129 | self.write(0x40,0) 130 | self.write(0x41,0) 131 | self.write(0x42,0) 132 | 133 | # Also clear IRQ status bits, in case later we enable 134 | # interrupts. 135 | self.write(0x48,0) 136 | self.write(0x49,0) 137 | self.write(0x4A,0) 138 | 139 | # Disable charging led handling. The device has 140 | # no charging led. 141 | self.clearbit(0x69,0) 142 | 143 | # Set precharge current limit to 50ma 144 | # constant current charge limit to 100ma 145 | # termination of charge limit to 25ma 146 | self.write(0x61,2) # ma = value * 0.25ma 147 | self.write(0x62,4) # ma = value * 0.25ma (only up to the value of 8). 148 | self.write(0x63,1) # ma = value * 0.25ma 149 | 150 | # Charge voltage limit 151 | self.write(0x64,4) # 4 means limit of 4.35v 152 | 153 | # Charge termination voltage for the button battery (RTC) 154 | self.write(0x6A,7) # 2.6v + (0.1 * value) = 2.6+0.1*7 = 3.3v 155 | 156 | # Enable button battery charge. 157 | self.setbit(0x18,2) # Bit 2 is "Button battery charge enabled" 158 | 159 | def twatch_s3_before_sleep(self): 160 | # Disable ADLO1, 2, 3, 4, BLDO2 from register 0x90. 161 | self.write(0x90,0) 162 | 163 | if __name__ == "__main__": 164 | twatch_pmu = AXP2101() 165 | twatch_pmu.twatch_s3_poweron() 166 | print("[AXP2101] Battery voltage is", twatch_pmu.get_battery_voltage()) 167 | -------------------------------------------------------------------------------- /examples/scroller.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Salvatore Sanfilippo 2 | # All Rights Reserved 3 | # 4 | # This code is released under the BSD 2 clause license. 5 | # See the LICENSE file for more information 6 | 7 | import time 8 | 9 | # This class implements terminal like view for any display implementing 10 | # the Framebuffer interface. 11 | class Scroller: 12 | Font8x8 = 0 13 | Font4x6 = 1 14 | StateActive = 0 # Display active 15 | StateDimmed = 1 # Dispaly still active but minimum contrast set 16 | StateSaver = 2 # Screen saver: only icons at random places on screen. 17 | 18 | def __init__(self, display, icons=None, dim_time=10, ss_time=120): 19 | self.display = display # Display driver 20 | self.icons = icons 21 | self.lines = [] 22 | self.xres = 240 23 | self.yres = 240 24 | # The framebuffer of MicroPython only supports 8x8 fonts so far, so: 25 | self.select_font("big") 26 | self.last_update = time.time() 27 | # OLED saving system state. We write text at an x,y offset, that 28 | # can be of 0 or 1 pixels. This way we use pixels more evenly 29 | # creating a less evident image ghosting effect in the more 30 | # used pixels. 31 | self.xoff = 0 32 | self.yoff = 0 33 | self.dim_t = dim_time # Inactivity to set to lower contrast. 34 | self.screensave_t = ss_time # Inactivity to enable screen saver. 35 | self.state = self.StateActive 36 | self.contrast = 255 37 | 38 | # Set maximum display contrast. It will be dimmed after some inactivity 39 | # time. 40 | def set_contrast(self,contrast): 41 | self.contrast = contrast 42 | 43 | # Get current contrast based on inactivity time. 44 | def get_contrast(self): 45 | if self.state == self.StateActive: 46 | return self.contrast 47 | elif self.state == self.StateDimmed or self.state == self.StateSaver: 48 | return 1 # Still pretty visible but in direct sunlight 49 | 50 | # Update self.state based on last activity time. 51 | def update_screensaver_state(self): 52 | inactivity = time.time() - self.last_update 53 | if inactivity > self.screensave_t: 54 | self.state = self.StateSaver 55 | elif inactivity > self.dim_t: 56 | self.state = self.StateDimmed 57 | else: 58 | self.state = self.StateActive 59 | 60 | def select_font(self,fontname): 61 | if fontname == "big": 62 | self.font = self.Font8x8 63 | self.font_width = 8 64 | self.font_height = 8 65 | elif fontname == "small": 66 | # Use 5/7 to provide the required spacing. The font 8x8 67 | # already includes spacing. 68 | self.font = self.Font4x6 69 | self.font_width = 5 70 | self.font_height = 7 71 | self.cols = int(self.xres/self.font_width) 72 | self.rows = int(self.yres/self.font_height) 73 | 74 | def render_text(self,text,x,y): 75 | if self.font == self.Font8x8: 76 | self.display.fb.text(text,x,y,self.display.fb_color(255,255,255)) 77 | else: 78 | for c in text: 79 | self.render_4x6_char(c, x, y) 80 | x += self.font_width 81 | 82 | def render_4x6_char(self,c,px,py): 83 | idx = ord(c) 84 | if idx > len(FontData4x6)/3: 85 | idx = ord("?") 86 | for y in range(0,6): 87 | bits = FontData4x6[idx*3+(int(y/2))] 88 | if not y & 1: bits >>= 4 89 | for x in range(0,4): 90 | if bits & (1<<(3-x)): 91 | self.display.fb.pixel(px+x,py+y,self.display.fb_color(255,255,255)) 92 | 93 | # Return the number of rows needed to display the current self.lines 94 | # This number may be > self.rows. 95 | def rows_needed(self): 96 | needed = 0 97 | for l in self.lines: 98 | needed += int((len(l)+(self.cols-1))/self.cols) 99 | return needed 100 | 101 | # When displaying images, we need to start from the row edge in order 102 | # make mixes of images and text well aligned. So we pad the image 103 | # height to the font height. 104 | def get_image_padded_height(self,height): 105 | if height % self.font_height: 106 | padded_height = height+(self.font_height-(height%self.font_height)) 107 | else: 108 | padded_height = height 109 | return padded_height 110 | 111 | 112 | # Draw the scroller "terminal" text. 113 | def draw_text(self): 114 | # We need to draw the lines backward starting from the last 115 | # row and going backward. This makes handling line wraps simpler, 116 | # as we consume from the end of the last line and so forth. 117 | y = (min(self.rows,self.rows_needed())-1) * self.font_height 118 | lines = self.lines[:] 119 | while y >= 0: 120 | # Handle text 121 | if len(lines[-1]) == 0: 122 | # We consumed all the current line. Remove it 123 | # and start again from the top of the loop, since 124 | # the next line could be an image. 125 | lines.pop(-1) 126 | if len(lines) == 0: return # Should not happen 127 | continue 128 | 129 | to_consume = len(lines[-1]) % self.cols 130 | if to_consume == 0: to_consume = self.cols 131 | rowchars = lines[-1][-to_consume:] # Part to display from the end 132 | lines[-1]=lines[-1][:-to_consume] # Remaining part. 133 | self.render_text(rowchars, 0+self.xoff, y+self.yoff) 134 | y -= self.font_height 135 | 136 | # Return the minimum time the caller should refresh the screen 137 | # the next time, in case of no activity. This is useful so that we 138 | # can dim the screen and update other time-dependent stuff (such status 139 | # icons) fast enough for the UI behavior to make sense. 140 | def min_refresh_time(self): 141 | icon_min_rt = self.icons.min_refresh_time() 142 | if self.state == self.StateActive: 143 | rt = self.dim_t+1 144 | elif self.state == self.StateDimmed: 145 | rt = self.screensave_t+1 146 | elif self.state == self.StateSaver: 147 | rt = 60 148 | return min(icon_min_rt,rt) 149 | 150 | # Update the screen content. 151 | def refresh(self): 152 | if not self.display: return 153 | self.update_screensaver_state() 154 | self.display.fb.fill(self.display.fb_color(0,0,0)) 155 | if self.state != self.StateSaver: 156 | minutes = int(time.time()/60) % 4 157 | # We use minutes from 0 to 3 to move text one pixel 158 | # left-right, top-bottom. This saves OLED from overusing 159 | # always the same set of pixels. 160 | self.xoff = minutes & 1 161 | self.yoff = (minutes>>1) & 1 162 | self.draw_text() 163 | random_icons_offset = self.state == self.StateSaver 164 | if self.icons: self.icons.refresh(random_offset=random_icons_offset) 165 | self.display.show() 166 | 167 | # Convert certain unicode points to our 4x6 font characters. 168 | def convert_from_utf8(self,msg): 169 | msg = msg.replace("è","\x80") 170 | msg = msg.replace("é","\x81") 171 | msg = msg.replace("😀","\x96\x97") 172 | return msg 173 | 174 | # Add a new line, without refreshing the display. 175 | def print(self,msg): 176 | if isinstance(msg,str): 177 | msg = self.convert_from_utf8(msg) 178 | self.lines.append(msg) 179 | self.lines = self.lines[-self.rows:] 180 | self.last_update = time.time() 181 | 182 | if __name__ == "__main__": 183 | from axp2101 import AXP2101 184 | from machine import Pin, SPI 185 | import st7789_base, st7789_ext 186 | 187 | # Setup the PMU chip & turn on the backlight. 188 | twatch_pmu = AXP2101() 189 | twatch_pmu.twatch_s3_poweron() 190 | bl = Pin(45,Pin.OUT) 191 | bl.on() 192 | 193 | # Setup TFT. 194 | display = st7789_ext.ST7789( 195 | SPI(1, baudrate=40000000, phase=0, polarity=1, sck=18, mosi=13, miso=37), 196 | 240, 240, 197 | reset=False, 198 | dc=Pin(38, Pin.OUT), 199 | cs=Pin(12, Pin.OUT), 200 | ) 201 | display.init(landscape=False,mirror_y=True,mirror_x=True,inversion=True) 202 | display.enable_framebuffer() 203 | 204 | # Use the Scroller. 205 | scroller = Scroller(display) 206 | counter = 0 207 | while True: 208 | counter += 1 209 | scroller.print(str(counter)) 210 | scroller.refresh() 211 | -------------------------------------------------------------------------------- /examples/touch.py: -------------------------------------------------------------------------------- 1 | # Example program for the T-WATCH S3 2 | 3 | import random 4 | 5 | from machine import Pin, SPI, SoftI2C 6 | import st7789_base, st7789_ext 7 | import time 8 | from axp2101 import AXP2101 9 | from haptic_motor import HAPTIC_MOTOR 10 | from ft6x06 import FT6206 11 | 12 | # Setup the PMU chip. 13 | twatch_pmu = AXP2101() 14 | twatch_pmu.twatch_s3_poweron() 15 | print("[AXP2101] Battery voltage is", twatch_pmu.get_battery_voltage()) 16 | 17 | # Power on the display backlight. 18 | bl = Pin(45,Pin.OUT) 19 | bl.on() 20 | 21 | # Our display does not have a MISO pin, but the MicroPython 22 | # SPI implementation does not allow to avoid specifying one, so 23 | # we use just a not used pin in the device. 24 | display = st7789_ext.ST7789( 25 | SPI(1, baudrate=40000000, phase=0, polarity=1, sck=18, mosi=13, miso=37), 26 | 240, 240, 27 | reset=False, 28 | dc=Pin(38, Pin.OUT), 29 | cs=Pin(12, Pin.OUT), 30 | ) 31 | display.init(landscape=False,mirror_y=True,mirror_x=True,inversion=True) 32 | 33 | # Setup touch driver 34 | def data_available(data): 35 | display.pixel(data[0]['x'],data[0]['y'],display.color(255,0,0)) 36 | if len(data) == 2: 37 | display.pixel(data[1]['x'],data[1]['y'],display.color(0,255,0)) 38 | 39 | i2c = SoftI2C(scl=40,sda=39) 40 | ft = FT6206(i2c,interrupt_pin=Pin(16,Pin.IN),callback=data_available) 41 | while True: time.sleep(1) 42 | -------------------------------------------------------------------------------- /ft6x06.py: -------------------------------------------------------------------------------- 1 | # FT6206/6306 simple driver. 2 | # Copyright (C) 2024 Salvatore Sanfilippo -- All Rights Reserved 3 | # This code is released under the MIT license 4 | # https://opensource.org/license/mit/ 5 | # 6 | # Written reading the specification at: 7 | # https://www.displayfuture.com/Display/datasheet/controller/FT6206.pdf 8 | 9 | from machine import Pin 10 | 11 | REG_DEV_MODE = const(0x00) 12 | REG_GEST_ID = const(0x01) 13 | REG_TD_STATUS = const(0x02) 14 | 15 | class FT6206: 16 | def __init__(self,i2c,*,interrupt_pin=None, callback=None): 17 | self.myaddr = 0x38 # I2C chip default address. 18 | self.i2c = i2c 19 | print("FT6206: scan i2c bus:", [hex(x) for x in i2c.scan()]) 20 | self.callback = callback 21 | self.interrupt_pin = interrupt_pin 22 | self.interrupt_pin.irq(handler=self.irq, trigger=Pin.IRQ_FALLING) 23 | 24 | def irq(self,pin): 25 | if self.callback == None: 26 | printf("FT6206: not handled IRQ. Pass 'callback' during initialization") 27 | return 28 | data = self.get_touch_coords() 29 | if data == None: return 30 | self.callback(data) 31 | 32 | # Return the single byte at the specified register 33 | def get_reg(self, register, count=1): 34 | if count == 1: 35 | return self.i2c.readfrom_mem(self.myaddr,register,1)[0] 36 | else: 37 | return self.i2c.readfrom_mem(self.myaddr,register,count) 38 | 39 | # Return the number of touches on the screen. 40 | # The function returns 0 if no finger is on the screen. 41 | def get_touch_count(self): 42 | return self.get_reg(REG_TD_STATUS) & 7 43 | 44 | # Return coordiantes and information about touch point "id" 45 | # The returned data is a tuple with: 46 | # x, y, event (0 = press down, 1 = lift up), weight, area. 47 | # In certain devices touch area/weight will be just zero. 48 | def get_coords_for_p(self,touch_id): 49 | # Touch information registers start here 0x03. 50 | # Each touch data is 6 registers. 51 | start_reg = 0x03 + (6*touch_id) 52 | data = self.get_reg(start_reg,6) 53 | event = data[0] >> 6 54 | x = ((data[0]&7)<<8) | data[1] 55 | y = ((data[2]&7)<<8) | data[3] 56 | weight = data[4] 57 | area = data[5]>>4 58 | return (x,y,event,weight,area) 59 | 60 | # Return an array of touches (1 or 2 touches) or None if no 61 | # touch is present on the display right now. 62 | def get_touch_coords(self): 63 | touches = self.get_touch_count() 64 | if touches == 0: return None 65 | 66 | touch_data = [] 67 | for i in range(touches): 68 | ev = self.get_coords_for_p(i) # Get event data. 69 | ev_type = "down" if ev[2] == 0 else "up" 70 | touch_data.append({ 71 | "x":ev[0], 72 | "y":ev[1], 73 | "type":ev_type, 74 | "weight":ev[3], 75 | "area":ev[4] 76 | }) 77 | return touch_data 78 | 79 | # Example usage and quick test to see if your device is working. 80 | if __name__ == "__main__": 81 | from machine import SoftI2C, Pin 82 | import time 83 | 84 | # This example can use the IRQ or just polling. 85 | # By default the IRQ usage is demostrated. 86 | use_irq = True 87 | 88 | i2c = SoftI2C(scl=40,sda=39) 89 | if use_irq: 90 | def data_available(data): 91 | print(data) 92 | 93 | ft = FT6206(i2c,interrupt_pin=Pin(16,Pin.IN),callback=data_available) 94 | while True: time.sleep(1) 95 | else: 96 | ft = FT6206(i2c) 97 | while True: 98 | print(ft.get_touch_coords()) 99 | time.sleep(1) 100 | -------------------------------------------------------------------------------- /haptic_motor.py: -------------------------------------------------------------------------------- 1 | from machine import Pin, SoftI2C 2 | import adafruit_drv2605 3 | 4 | # The DRV2605 chip controls the haptic motor. The motor can play many different waveforms each of which has a different effect id. 5 | # These effect IDs range from 0 to 123. 6 | # 7 | # Suggested effect IDs are: 8 | # 10 - short vibration 9 | # 12 - vibration with a pause and then another vibration 10 | # 14 - long vibrate 11 | # 12 | # Detailed information is available from Texas Instruments at https://www.ti.com/lit/ds/symlink/drv2605.pdf?ts=1706485376379 13 | # 14 | # The adafruit_drv2605 library is sourced from https://github.com/VynDragon/Adafruit_MicroPython_DRV2605 15 | # which is derived from https://github.com/VynDragon/Adafruit_MicroPython_DRV2605 16 | 17 | class HAPTIC_MOTOR: 18 | def __init__(self, effect_id): 19 | self.effect_id = effect_id 20 | print("[HAPTIC_MOTOR] initialized") 21 | 22 | def vibrate(self): 23 | # Initialize I2C bus and DRV2605 module. 24 | i2c = SoftI2C(Pin(11, Pin.OUT), Pin(10, Pin.OUT)) 25 | motor = adafruit_drv2605.DRV2605(i2c) 26 | 27 | # the motor has different waveform effects which have different IDs ranging from 0 to 123 28 | motor.sequence[0] = adafruit_drv2605.Effect(self.effect_id) # Set the effect on slot 0 29 | motor.play() # play the effect 30 | motor.stop() # and then stop (if it's still running) 31 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # Example program for the T-WATCH S3 2 | 3 | import random 4 | 5 | from machine import Pin, SPI 6 | import st7789_base, st7789_ext 7 | import time 8 | from axp2101 import AXP2101 9 | from haptic_motor import HAPTIC_MOTOR 10 | 11 | def main(): 12 | # Setup the PMU chip. 13 | twatch_pmu = AXP2101() 14 | twatch_pmu.twatch_s3_poweron() 15 | print("[AXP2101] Battery voltage is", twatch_pmu.get_battery_voltage()) 16 | 17 | # Power on the display backlight. 18 | bl = Pin(45,Pin.OUT) 19 | bl.on() 20 | 21 | # Our display does not have a MISO pin, but the MicroPython 22 | # SPI implementation does not allow to avoid specifying one, so 23 | # we use just a not used pin in the device. 24 | display = st7789_ext.ST7789( 25 | SPI(1, baudrate=40000000, phase=0, polarity=1, sck=18, mosi=13, miso=37), 26 | 240, 240, 27 | reset=False, 28 | dc=Pin(38, Pin.OUT), 29 | cs=Pin(12, Pin.OUT), 30 | ) 31 | display.init(landscape=False,mirror_y=True,mirror_x=True,inversion=True) 32 | display.enable_framebuffer() 33 | 34 | # vibrate using effect 14 35 | motor = HAPTIC_MOTOR(14) 36 | motor.vibrate() 37 | 38 | print("displaying random colors") 39 | while True: 40 | start = time.ticks_ms() 41 | display.fb.fill( 42 | display.fb_color( 43 | random.getrandbits(8), 44 | random.getrandbits(8), 45 | random.getrandbits(8), 46 | ), 47 | ) 48 | for i in range(250): 49 | display.fb.pixel(random.randint(0,240), 50 | random.randint(0,240), 51 | display.fb_color(255,255,255)) 52 | 53 | display.show() 54 | elapsed = time.ticks_ms() - start 55 | print("Ticks per screen fill:", elapsed) 56 | 57 | # Pause 2 seconds. 58 | time.sleep(2) 59 | 60 | main() 61 | -------------------------------------------------------------------------------- /st7789_base.py: -------------------------------------------------------------------------------- 1 | # This code is originally from https://github.com/devbis/st7789py_mpy 2 | # It's under the MIT license as well. 3 | # 4 | # Rewritten by Salvatore Sanfilippo. 5 | # 6 | # Copyright (C) 2024 Salvatore Sanfilippo 7 | # All Rights Reserved 8 | # All the changes released under the MIT license as the original code. 9 | 10 | import time 11 | from micropython import const 12 | import ustruct as struct 13 | import framebuf 14 | 15 | # Commands. We use a small subset of what is 16 | # available and assume no MISO pin to read 17 | # from the display. 18 | ST77XX_NOP = bytes([0x00]) 19 | ST77XX_SWRESET = bytes([0x01]) 20 | ST77XX_SLPIN = bytes([0x10]) 21 | ST77XX_SLPOUT = bytes([0x11]) 22 | ST77XX_NORON = bytes([0x13]) 23 | ST77XX_INVOFF = bytes([0x20]) 24 | ST77XX_INVON = bytes([0x21]) 25 | ST77XX_DISPON = bytes([0x29]) 26 | ST77XX_CASET = bytes([0x2A]) 27 | ST77XX_RASET = bytes([0x2B]) 28 | ST77XX_RAMWR = bytes([0x2C]) 29 | ST77XX_COLMOD = bytes([0x3A]) 30 | ST7789_MADCTL = bytes([0x36]) 31 | 32 | # MADCTL command flags 33 | ST7789_MADCTL_MY = const(0x80) 34 | ST7789_MADCTL_MX = const(0x40) 35 | ST7789_MADCTL_MV = const(0x20) 36 | ST7789_MADCTL_ML = const(0x10) 37 | ST7789_MADCTL_BGR = const(0x08) 38 | ST7789_MADCTL_MH = const(0x04) 39 | ST7789_MADCTL_RGB = const(0x00) 40 | 41 | # COLMOD command flags 42 | ColorMode_65K = const(0x50) 43 | ColorMode_262K = const(0x60) 44 | ColorMode_12bit = const(0x03) 45 | ColorMode_16bit = const(0x05) 46 | ColorMode_18bit = const(0x06) 47 | ColorMode_16M = const(0x07) 48 | 49 | # Struct pack formats for pixel/pos encoding 50 | _ENCODE_PIXEL = ">H" 51 | _ENCODE_POS = ">HH" 52 | 53 | class ST7789_base: 54 | def __init__(self, spi, width, height, reset, dc, cs=None): 55 | """ 56 | display = st7789.ST7789( 57 | SPI(1, baudrate=40000000, phase=0, polarity=1), 58 | 240, 240, 59 | reset=machine.Pin(5, machine.Pin.OUT), 60 | dc=machine.Pin(2, machine.Pin.OUT), 61 | ) 62 | 63 | """ 64 | self.width = width 65 | self.height = height 66 | self.spi = spi 67 | self.reset = reset 68 | self.dc = dc 69 | self.cs = cs 70 | 71 | # Always allocate a tiny 8x8 framebuffer in RGB565 for fast 72 | # single chars plotting. This is useful in order to draw text 73 | # using the framebuffer 8x8 font inside micropython and using 74 | # a single SPI write for each whole character. 75 | self.charfb_data = bytearray(8*8*2) 76 | self.charfb = framebuf.FrameBuffer(self.charfb_data,8,8,framebuf.RGB565) 77 | 78 | # That's the color format our API takes. We take r, g, b, translate 79 | # to 16 bit value and pack it as as two bytes. 80 | def color(self, r=0, g=0, b=0): 81 | # Convert red, green and blue values (0-255) into a 16-bit 565 encoding. 82 | c = (r & 0xf8) << 8 | (g & 0xfc) << 3 | b >> 3 83 | return struct.pack(_ENCODE_PIXEL, c) 84 | 85 | def write(self, command=None, data=None): 86 | """SPI write to the device: commands and data""" 87 | if command is not None: 88 | self.dc.off() 89 | self.spi.write(command) 90 | if data is not None: 91 | self.dc.on() 92 | if len(data): self.spi.write(data) 93 | 94 | def hard_reset(self): 95 | if self.reset: 96 | self.reset.on() 97 | time.sleep_ms(50) 98 | self.reset.off() 99 | time.sleep_ms(50) 100 | self.reset.on() 101 | time.sleep_ms(150) 102 | 103 | def soft_reset(self): 104 | self.write(ST77XX_SWRESET) 105 | time.sleep_ms(150) 106 | 107 | def sleep_mode(self, value): 108 | if value: 109 | self.write(ST77XX_SLPIN) 110 | else: 111 | self.write(ST77XX_SLPOUT) 112 | 113 | def inversion_mode(self, value): 114 | if value: 115 | self.write(ST77XX_INVON) 116 | else: 117 | self.write(ST77XX_INVOFF) 118 | 119 | def _set_color_mode(self, mode): 120 | self.write(ST77XX_COLMOD, bytes([mode & 0x77])) 121 | 122 | def init(self, landscape=False, mirror_x=False, mirror_y=False, is_bgr=False, xstart = None, ystart = None, inversion = False): 123 | 124 | self.inversion = inversion 125 | self.mirror_x = mirror_x 126 | self.mirror_y = mirror_y 127 | 128 | # Configure display parameters that depend on the 129 | # screen size. 130 | if xstart != None and ystart != None: 131 | self.xstart = xstart 132 | self.ystart = ystart 133 | elif (self.width, self.height) == (128, 160): 134 | self.xstart = 0 135 | self.ystart = 0 136 | elif (self.width, self.height) == (240, 240): 137 | self.xstart = 0 138 | self.ystart = 0 139 | if self.mirror_y: self.ystart = 40 140 | elif (self.width, self.height) == (135, 240): 141 | self.xstart = 52 142 | self.ystart = 40 143 | else: 144 | self.xstart = 0 145 | self.ystart = 0 146 | 147 | self.cs.off() # This this like that forever, much faster than 148 | # continuously setting it on/off and rarely the 149 | # SPI is connected to any other hardware. 150 | self.hard_reset() 151 | self.soft_reset() 152 | self.sleep_mode(False) 153 | 154 | color_mode=ColorMode_65K | ColorMode_16bit 155 | self._set_color_mode(color_mode) 156 | time.sleep_ms(50) 157 | self._set_mem_access_mode(landscape, mirror_x, mirror_y, is_bgr) 158 | self.inversion_mode(self.inversion) 159 | time.sleep_ms(10) 160 | self.write(ST77XX_NORON) 161 | time.sleep_ms(10) 162 | self.fill(self.color(0,0,0)) 163 | self.write(ST77XX_DISPON) 164 | time.sleep_ms(500) 165 | 166 | def _set_mem_access_mode(self, landscape, mirror_x, mirror_y, is_bgr): 167 | value = 0 168 | if landscape: value |= ST7789_MADCTL_MV 169 | if mirror_x: value |= ST7789_MADCTL_MX 170 | if mirror_y: value |= ST7789_MADCTL_MY 171 | if is_bgr: value |= ST7789_MADCTL_BGR 172 | self.write(ST7789_MADCTL, bytes([value])) 173 | 174 | def _encode_pos(self, x, y): 175 | """Encode a postion into bytes.""" 176 | return struct.pack(_ENCODE_POS, x, y) 177 | 178 | def _set_columns(self, start, end): 179 | self.write(ST77XX_CASET, self._encode_pos(start+self.xstart, end+self.xstart)) 180 | 181 | def _set_rows(self, start, end): 182 | start += self.ystart 183 | end += self.ystart 184 | self.write(ST77XX_RASET, self._encode_pos(start+self.ystart, end+self.ystart)) 185 | 186 | # Set the video memory windows that will be receive our 187 | # SPI data writes. Note that this function assumes that 188 | # x0 <= x1 and y0 <= y1. 189 | def set_window(self, x0, y0, x1, y1): 190 | self._set_columns(x0, x1) 191 | self._set_rows(y0, y1) 192 | self.write(ST77XX_RAMWR) 193 | 194 | # Drawing raw pixels is a fundamental operation so we go low 195 | # level avoiding function calls. This and other optimizations 196 | # made drawing 10k pixels with an ESP2866 from 420ms to 100ms. 197 | def pixel(self,x,y,color): 198 | if x < 0 or x >= self.width or y < 0 or y >= self.height: return 199 | self.dc.off() 200 | self.spi.write(ST77XX_CASET) 201 | self.dc.on() 202 | self.spi.write(self._encode_pos(x+self.xstart, x+self.xstart)) 203 | 204 | self.dc.off() 205 | self.spi.write(ST77XX_RASET) 206 | self.dc.on() 207 | self.spi.write(self._encode_pos(y+self.ystart*2, y+self.ystart*2)) 208 | 209 | self.dc.off() 210 | self.spi.write(ST77XX_RAMWR) 211 | self.dc.on() 212 | self.spi.write(color) 213 | 214 | # Just fill the whole display memory with the specified color. 215 | # We use a buffer of screen-width pixels. Even in the worst case 216 | # of 320 pixels, it's just 640 bytes. Note that writing a scanline 217 | # per loop dramatically improves performances. 218 | def fill(self,color): 219 | self.set_window(0, 0, self.width-1, self.height-1) 220 | buf = color*self.width 221 | for i in range(self.height): self.write(None, buf) 222 | 223 | # Draw a full or empty rectangle. 224 | # x,y are the top-left corner coordinates. 225 | # w and h are width/height in pixels. 226 | def rect(self,x,y,w,h,color,fill=False): 227 | if fill: 228 | self.set_window(x,y,x+w-1,y+1-w) 229 | if w*h > 256: 230 | buf = color*w 231 | for i in range(h): self.write(None, buf) 232 | else: 233 | buf = color*(w*h) 234 | self.write(None, buf) 235 | else: 236 | self.hline(x,x+w-1,y,color) 237 | self.hline(x,x+w-1,y+h-1,color) 238 | self.vline(y,y+h-1,x,color) 239 | self.vline(y,y+h-1,x+w-1,color) 240 | 241 | # We can draw horizontal and vertical lines very fast because 242 | # we can just set a 1 pixel wide/tall window and fill it. 243 | def hline(self,x0,x1,y,color): 244 | if y < 0 or y >= self.height: return 245 | x0,x1 = max(min(x0,x1),0),min(max(x0,x1),self.width-1) 246 | self.set_window(x0, y, x1, y) 247 | self.write(None, color*(x1-x0+1)) 248 | 249 | # Same as hline() but for vertical lines. 250 | def vline(self,y0,y1,x,color): 251 | y0,y1 = max(min(y0,y1),0),min(max(y0,y1),self.height-1) 252 | self.set_window(x, y0, x, y1) 253 | self.write(None, color*(y1-y0+1)) 254 | 255 | # Draw a single character 'char' using the font in the MicroPython 256 | # framebuffer implementation. It is possible to specify the background and 257 | # foreground color in RGB. 258 | # Note: in order to uniform this API with all the rest, that takes 259 | # the color as two bytes, we convert the colors back into a 16 bit 260 | # rgb565 value since this is the format that the framebuffer 261 | # implementation expects. 262 | def char(self,x,y,char,fgcolor,bgcolor): 263 | if x >= self.width or y >= self.height: 264 | return # Totally out of display area 265 | 266 | # Obtain the character representation in our 267 | # 8x8 framebuffer. 268 | self.charfb.fill(bgcolor[1]<<8|bgcolor[0]) 269 | self.charfb.text(char,0,0,fgcolor[1]<<8|fgcolor[0]) 270 | 271 | if x+7 >= self.width: 272 | # Right side of char does not fit on the screen. 273 | # Partial update. 274 | width = self.width-x # Visible width pixels 275 | self.set_window(x, y, x+width-1, y+7) 276 | copy = bytearray(width*8*2) 277 | for dy in range(8): 278 | src_idx = (dy*8)*2 279 | dst_idx = (dy*width)*2 280 | copy[dst_idx:dst_idx+width*2] = self.charfb_data[src_idx:src_idx+width*2] 281 | self.write(None,copy) 282 | else: 283 | self.set_window(x, y, x+7, y+7) 284 | self.write(None,self.charfb_data) 285 | 286 | # Write text. Like 'char' but for full strings. 287 | def text(self,x,y,txt,fgcolor,bgcolor): 288 | for i in range(len(txt)): 289 | self.char(x+i*8,y,txt[i],fgcolor,bgcolor) 290 | 291 | 292 | -------------------------------------------------------------------------------- /st7789_ext.py: -------------------------------------------------------------------------------- 1 | # This code is originally from https://github.com/devbis/st7789py_mpy 2 | # It's under the MIT license as well. 3 | # 4 | # Rewritten by Salvatore Sanfilippo. 5 | # 6 | # Copyright (C) 2024 Salvatore Sanfilippo 7 | # All Rights Reserved 8 | # All the changes released under the MIT license as the original code. 9 | 10 | import st7789_base, framebuf, struct 11 | 12 | class ST7789(st7789_base.ST7789_base): 13 | # Bresenham's algorithm with fast path for horizontal / vertical lines. 14 | # Note that accumulating partial successive small horizontal/vertical 15 | # lines is actually slower than the vanilla pixel approach. 16 | def line(self, x0, y0, x1, y1, color): 17 | if y0 == y1: return self.hline(x0, x1, y0, color) 18 | if x0 == x1: return self.vline(y0, y1, x0, color) 19 | 20 | dx = abs(x1 - x0) 21 | dy = -abs(y1 - y0) 22 | sx = 1 if x0 < x1 else -1 23 | sy = 1 if y0 < y1 else -1 24 | err = dx + dy # Error value for xy 25 | 26 | while True: 27 | self.pixel(x0, y0, color) 28 | if x0 == x1 and y0 == y1: break 29 | e2 = 2 * err 30 | if e2 >= dy: 31 | err += dy 32 | x0 += sx 33 | if e2 <= dx: 34 | err += dx 35 | y0 += sy 36 | 37 | # Midpoint Circle algorithm for filled circle. 38 | def circle(self, x, y, radius, color, fill=False): 39 | f = 1 - radius 40 | dx = 1 41 | dy = -2 * radius 42 | x0 = 0 43 | y0 = radius 44 | 45 | if fill: 46 | self.hline(x - radius, x + radius, y, color) # Draw diameter 47 | else: 48 | self.pixel(x - radius, y, color) # Left-most point 49 | self.pixel(x + radius, y, color) # Right-most point 50 | 51 | while x0 < y0: 52 | if f >= 0: 53 | y0 -= 1 54 | dy += 2 55 | f += dy 56 | x0 += 1 57 | dx += 2 58 | f += dx 59 | 60 | if fill: 61 | # We can exploit our relatively fast horizontal line 62 | # here, and just draw an h line for each two points at 63 | # the extremes. 64 | self.hline(x - x0, x + x0, y + y0, color) # Upper half 65 | self.hline(x - x0, x + x0, y - y0, color) # Lower half 66 | self.hline(x - y0, x + y0, y + x0, color) # Right half 67 | self.hline(x - y0, x + y0, y - x0, color) # Left half 68 | else: 69 | # Plot points in each of the eight octants 70 | self.pixel(x + x0, y + y0, color) 71 | self.pixel(x - x0, y + y0, color) 72 | self.pixel(x + x0, y - y0, color) 73 | self.pixel(x - x0, y - y0, color) 74 | self.pixel(x + y0, y + x0, color) 75 | self.pixel(x - y0, y + x0, color) 76 | self.pixel(x + y0, y - x0, color) 77 | self.pixel(x - y0, y - x0, color) 78 | 79 | # This function draws a filled triangle: it is an 80 | # helper of .triangle when the fill flag is true. 81 | def fill_triangle(self, x0, y0, x1, y1, x2, y2, color): 82 | # Vertex are required to be ordered by y. 83 | if y0 > y1: x0, y0, x1, y1 = x1, y1, x0, y0 84 | if y0 > y2: x0, y0, x2, y2 = x2, y2, x0, y0 85 | if y1 > y2: x1, y1, x2, y2 = x2, y2, x1, y1 86 | 87 | # Calculate slopes. 88 | inv_slope1 = (x1 - x0) / (y1 - y0) if y1 - y0 != 0 else 0 89 | inv_slope2 = (x2 - x0) / (y2 - y0) if y2 - y0 != 0 else 0 90 | inv_slope3 = (x2 - x1) / (y2 - y1) if y2 - y1 != 0 else 0 91 | 92 | x_start, x_end = x0, x0 93 | 94 | # Fill upper part. 95 | for y in range(y0, y1 + 1): 96 | self.hline(int(x_start), int(x_end), y, color) 97 | x_start += inv_slope1 98 | x_end += inv_slope2 99 | 100 | # Adjust for the middle segment. 101 | x_start = x1 102 | 103 | # Fill the lower part. 104 | for y in range(y1 + 1, y2 + 1): 105 | self.hline(int(x_start), int(x_end), y, color) 106 | x_start += inv_slope3 107 | x_end += inv_slope2 108 | 109 | # Draw full or empty triangles. 110 | def triangle(self, x0, y0, x1, y1, x2, y2, color, fill=False): 111 | if fill: 112 | return self.fill_triangle(x0,y0,x1,y1,x2,y2,color) 113 | else: 114 | self.line(x0,y0,x1,y1,color) 115 | self.line(x1,y1,x2,y2,color) 116 | self.line(x2,y2,x0,y0,color) 117 | 118 | # Write an upscaled character. Slower, but allows for big characters 119 | # and to set the background color to None. 120 | def upscaled_char(self,x,y,char,fgcolor,bgcolor,upscaling): 121 | bitmap = bytearray(8) # 64 bits of total image data. 122 | fb = framebuf.FrameBuffer(bitmap,8,8,framebuf.MONO_HMSB) 123 | fb.text(char,0,0,fgcolor[1]<<8|fgcolor[0]) 124 | charsize = 8*upscaling 125 | if bgcolor: self.rect(x,y,charsize,charsize,bgcolor,fill=True) 126 | for py in range(8): 127 | for px in range(8): 128 | if not (bitmap[py] & (1< 1: 130 | self.rect(x+px*upscaling,y+py*upscaling,upscaling,upscaling,fgcolor,fill=True) 131 | else: 132 | self.pixel(x+px,y+py,fgcolor) 133 | 134 | def upscaled_text(self,x,y,txt,fgcolor,*,bgcolor=None,upscaling=2): 135 | for i in range(len(txt)): 136 | self.upscaled_char(x+i*(8*upscaling),y,txt[i],fgcolor,bgcolor,upscaling) 137 | 138 | # Show a 565 file (see conversion tool into "pngto565". 139 | def image(self,x,y,filename): 140 | try: 141 | f = open(filename,"rb") 142 | except: 143 | print("Warning: file not found displaying image:", filename) 144 | return 145 | hdr = f.read(4) 146 | w,h = struct.unpack(">HH",hdr) 147 | self.set_window(x,y,x+w-1,y+h-1) 148 | buf = bytearray(256) 149 | nocopy = memoryview(buf) 150 | while True: 151 | nread = f.readinto(buf) 152 | if nread == 0: return 153 | self.write(None, nocopy[:nread]) 154 | 155 | # Turn on framebuffer. You can write to it directly addressing 156 | # the fb instance like in: 157 | # 158 | # display.fb.fill(display.fb_color(100,50,50)) 159 | # display.show() 160 | def enable_framebuffer(self): 161 | self.rawbuffer = bytearray(self.width*self.height*2) 162 | self.fb = framebuf.FrameBuffer(self.rawbuffer, 163 | self.width,self.height,framebuf.RGB565) 164 | 165 | # This function is used to conver an RGB value to the 166 | # equivalent color for the framebuffer functions. 167 | def fb_color(self,r,g,b): 168 | c = self.color(r,g,b) 169 | return c[1]<<8 | c[0] 170 | 171 | # Transfer the framebuffer image into the display 172 | def show(self): 173 | self.set_window(0,0,self.width-1,self.height-1) 174 | self.write(None, self.rawbuffer) 175 | -------------------------------------------------------------------------------- /sx1262.py: -------------------------------------------------------------------------------- 1 | # SX1262 driver for MicroPython 2 | # Copyright (C) 2024 Salvatore Sanfilippo 3 | # All Rights Reserved 4 | # 5 | # This code is released under the BSD 2 clause license. 6 | # See the LICENSE file for more information 7 | 8 | # TODO: 9 | # - Improve modem_is_receiving_packet() if possible at all with the SX1262. 10 | 11 | from machine import Pin, SoftSPI 12 | from micropython import const 13 | import time, struct, urandom 14 | 15 | # SX1262 constants 16 | 17 | # Registers IDs and notable values 18 | RegRxGain = const(0x8ac) 19 | RegRxGain_PowerSaving = const(0x94) # Value for RegRxGain 20 | RegRxGain_Boosted = const(0x96) # Value for RegRxGain 21 | RegLoRaSyncWordMSB = const(0x0740) 22 | RegLoRaSyncWordLSB = const(0x0741) 23 | RegTxClampConfig = const(0x08d8) 24 | 25 | # Dio0 mapping 26 | IRQSourceNone = const(0) 27 | IRQSourceTxDone = const(1 << 0) 28 | IRQSourceRxDone = const(1 << 1) 29 | IRQSourcePreambleDetected = const(1 << 2) 30 | IRQSourceSyncWordValid = const(1 << 3) 31 | IRQSourceHeaderValid = const(1 << 4) 32 | IRQSourceHeaderErr = const(1 << 5) 33 | IRQSourceCrcErr = const(1 << 6) 34 | IRQSourceCadDone = const(1 << 7) 35 | IRQSourceCadDetected = const(1 << 8) 36 | IRQSourceTimeout = const(1 << 9) 37 | 38 | # Commands opcodes 39 | ClearIrqStatusCmd = const(0x02) 40 | SetDioIrqParamsCmd = const(0x08) 41 | WriteRegisterCmd = const(0x0d) 42 | WriteBufferCmd = const(0x0e) 43 | GetIrqStatusCmd = const(0x12) 44 | GetRxBufferStatusCmd = const(0x13) 45 | GetPacketStatusCmd = const(0x14) 46 | ReadRegisterCmd = const(0x1d) 47 | ReadBufferCmd = const(0x1e) 48 | SetStandByCmd = const(0x80) 49 | SetRxCmd = const(0x82) 50 | SetTxCmd = const(0x83) 51 | SleepCmd = const(0x84) 52 | SetRfFrequencyCmd = const(0x86) 53 | SetPacketTypeCmd = const(0x8a) 54 | SetModulationParamsCmd = const(0x8b) 55 | SetPacketParamsCmd = const(0x8c) 56 | SetTxParamsCmd = const(0x8e) 57 | SetBufferBaseAddressCmd = const(0x8f) 58 | SetPaConfigCmd = const(0x95) 59 | SetDIO3AsTCXOCtrlCmd = const(0x97) 60 | CalibrateImageCmd = const(0x98) 61 | SetDIO2AsRfSwitchCtrlCmd = const(0x9d) 62 | 63 | # Constants for SetPacketParam() arguments 64 | PacketHeaderTypeExplicit = const(0) 65 | PacketHeaderTypeImplicit = const(1) 66 | PacketCRCOff = const(1) 67 | PacketCRCOn = const(1) 68 | PacketStandardIQ = const(0) 69 | PacketInvertedIQ = const(1) 70 | 71 | # Constants used for listen-before-talk method 72 | # modem_is_receiving_packet() 73 | POAPreamble = const(0) 74 | POAHeader = const(1) 75 | 76 | class SX1262: 77 | def __init__(self, pinset, rx_callback, tx_callback = None): 78 | self.receiving = False # True if we are in receive mode. 79 | self.tx_in_progress = False 80 | self.packet_on_air = False # see modem_is_receiving_packet(). 81 | self.msg_sent = 0 82 | self.received_callback = rx_callback 83 | self.transmitted_callback = tx_callback 84 | self.busy_pin = Pin(pinset['busy'],Pin.IN) 85 | self.reset_pin = Pin(pinset['reset'],Pin.OUT) 86 | self.chipselect_pin = Pin(pinset['chipselect'], Pin.OUT) 87 | self.clock_pin = Pin(pinset['clock']) 88 | self.mosi_pin = Pin(pinset['mosi']) 89 | self.miso_pin = Pin(pinset['miso']) 90 | self.dio_pin = Pin(pinset['dio'], Pin.IN) 91 | self.spi = SoftSPI(baudrate=10000000, polarity=0, phase=0, sck=self.clock_pin, mosi=self.mosi_pin, miso=self.miso_pin) 92 | self.bw = 0 # Currently set bandwidth. Saved to compute freq error. 93 | 94 | def reset(self): 95 | self.reset_pin.off() 96 | time.sleep_us(500) 97 | self.reset_pin.on() 98 | time.sleep_us(500) 99 | self.receiving = False 100 | self.tx_in_progress = False 101 | 102 | def standby(self): 103 | self.command(SetStandByCmd,0) # argument 0 menas STDBY_RC mode. 104 | 105 | # Note: the CS pin logic is inverted. It requires to be set to low 106 | # when the chip is NOT selected for data transfer. 107 | def deselect_chip(self): 108 | self.chipselect_pin.on() 109 | 110 | def select_chip(self): 111 | self.chipselect_pin.off() 112 | 113 | # Send a read or write command, and return the reply we 114 | # got back. 'data' can be both an array of a single integer. 115 | def command(self, opcode, data=None): 116 | if data != None: 117 | if isinstance(data,int): data = [data] 118 | payload = bytearray(1+len(data)) # opcode + payload 119 | payload[0] = opcode 120 | payload[1:] = bytes(data) 121 | else: 122 | payload = bytearray([opcode]) 123 | reply = bytearray(len(payload)) 124 | 125 | # Wait for the chip to return available. 126 | while self.busy_pin.value(): 127 | time.sleep_us(1) 128 | 129 | self.select_chip() 130 | self.spi.write_readinto(payload,reply) 131 | self.deselect_chip() 132 | 133 | # Enable this for debugging. 134 | if False: print(f"Reply for {hex(opcode)} is {repr(reply)}") 135 | 136 | return reply 137 | 138 | def readreg(self, addr, readlen=1): 139 | payload = bytearray(2+1+readlen) # address + nop + nop*bytes_to_read 140 | payload[0] = (addr&0xff00)>>8 141 | payload[1] = addr&0xff 142 | reply = self.command(ReadRegisterCmd,payload) 143 | return reply[4:] 144 | 145 | def writereg(self, addr, data): 146 | if isinstance(data,int): data = bytes([data]) 147 | payload = bytearray(2+len(data)) # address + bytes_to_write 148 | payload[0] = (addr&0xff00)>>8 149 | payload[1] = addr&0xff 150 | payload[2:] = data 151 | self.command(WriteRegisterCmd,payload) 152 | 153 | def readbuf(self, off, numbytes): 154 | payload = bytearray(2+numbytes) 155 | payload[0] = off 156 | data = self.command(ReadBufferCmd,payload) 157 | return data[3:] 158 | 159 | def writebuf(self, off, data): 160 | payload = bytearray(1+len(data)) 161 | payload[0] = off 162 | payload[1:] = data 163 | self.command(WriteBufferCmd,payload) 164 | 165 | def set_frequency(self, mhz): 166 | # The final frequency is (rf_freq * xtal freq) / 2^25. 167 | oscfreq = 32000000 # Oscillator frequency for registers calculation 168 | rf_freq = int(mhz * (2**25) / oscfreq) 169 | arg = [(rf_freq & 0xff000000) >> 24, 170 | (rf_freq & 0xff0000) >> 16, 171 | (rf_freq & 0xff00) >> 8, 172 | (rf_freq & 0xff)] 173 | self.command(SetRfFrequencyCmd, arg) 174 | 175 | def set_packet_params(self, preamble_len = 12, header_type = PacketHeaderTypeExplicit, payload_len = 255, crc = PacketCRCOn, iq_setup = PacketStandardIQ): 176 | pp = bytearray(6) 177 | pp[0] = preamble_len >> 8 178 | pp[1] = preamble_len & 0xff 179 | pp[2] = header_type 180 | pp[3] = payload_len 181 | pp[4] = crc 182 | pp[5] = iq_setup 183 | self.command(SetPacketParamsCmd,pp) 184 | 185 | def begin(self): 186 | self.reset() 187 | self.deselect_chip() 188 | self.standby() # SX126x gets configured in standby. 189 | self.command(SetPacketTypeCmd,0x01) # Put the chip in LoRa mode. 190 | 191 | # Apply fix for PA clamping as specified in datasheet. 192 | curval = self.readreg(RegTxClampConfig)[0] 193 | curval |= 0x1E 194 | self.writereg(RegTxClampConfig,curval) 195 | 196 | # Set the radio parameters. Allowed spreadings are from 6 to 12. 197 | # Bandwidth and coding rate are listeed below in the dictionaries. 198 | # TX power is from -9 to +22 dbm. 199 | def configure(self, freq, bandwidth, rate, spreading, txpower): 200 | Bw = { 7800: 0, 201 | 10400: 0x8, 202 | 15600: 0x1, 203 | 20800: 0x9, 204 | 31250: 0x2, 205 | 41700: 0xa, 206 | 62500: 0x3, 207 | 125000: 0x4, 208 | 250000: 0x5, 209 | 500000: 0x6} 210 | CodingRate = { 5:1, 211 | 6:2, 212 | 7:3, 213 | 8:4} 214 | 215 | # Make sure the chip is in standby mode 216 | # during configuration. 217 | self.standby() 218 | 219 | # Set LoRa parameters. 220 | lp = bytearray(4) 221 | lp[0] = spreading 222 | lp[1] = Bw[bandwidth] 223 | lp[2] = CodingRate[rate] 224 | lp[3] = 1 # Enable low data rate optimization 225 | self.command(SetModulationParamsCmd,lp) 226 | 227 | # Set packet params. 228 | self.set_packet_params() 229 | 230 | # Set RF frequency. 231 | self.set_frequency(freq) 232 | 233 | # Use maximum sensibility 234 | self.writereg(RegRxGain,0x96) 235 | 236 | # Set TCXO voltage to 1.7 with 5000us delay. 237 | tcxo_delay = int(5000.0 / 15.625) 238 | tcxo_config = bytearray(4) 239 | tcxo_config[0] = 1 # 1.7v 240 | tcxo_config[1] = (tcxo_delay >> 16) & 0xff 241 | tcxo_config[2] = (tcxo_delay >> 8) & 0xff 242 | tcxo_config[3] = (tcxo_delay >> 0) & 0xff 243 | self.command(SetDIO3AsTCXOCtrlCmd,tcxo_config) 244 | 245 | # Set DIO2 as RF switch like in Semtech examples. 246 | self.command(SetDIO2AsRfSwitchCtrlCmd,1) 247 | 248 | # Set the power amplifier configuration. 249 | paconfig = bytearray(4) 250 | paconfig[0] = 4 # Duty Cycle of 4 251 | paconfig[1] = 7 # Max output +22 dBm 252 | paconfig[2] = 0 # Select PA for SX1262 (1 would be SX1261) 253 | paconfig[3] = 1 # Always set to 1 as for datasheet 254 | self.command(SetPaConfigCmd,paconfig) 255 | 256 | # Set TX power and ramping. We always use high power mode. 257 | txpower = min(max(-9,txpower),22) 258 | txparams = bytearray(2) 259 | txparams[0] = (0xF7 + (txpower+9)) % 256 260 | txparams[1] = 4 # 200us ramping time 261 | self.command(SetTxParamsCmd,txparams) 262 | 263 | # We either receive or send, so let's use all the 256 bytes 264 | # of FIFO available by setting both recv and send FIFO address 265 | # to the base. 266 | self.command(SetBufferBaseAddressCmd,[0,0]) 267 | 268 | # Setup the IRQ handler to receive the packet tx/rx and 269 | # other events. Note that the chip will put the packet 270 | # on the FIFO even on CRC error. 271 | # We will enable all DIOs for all the interrputs. In 272 | # practice most of the times only one chip DIO is connected 273 | # to the MCU. 274 | self.dio_pin.irq(handler=self.txrxdone, trigger=Pin.IRQ_RISING) 275 | self.command(SetDioIrqParamsCmd,[0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff]) 276 | self.clear_irq() 277 | 278 | # Set sync word to 0x12 (private network). 279 | # Note that "12" is in the most significant hex digits of 280 | # the two registers: [1]4 and [2]4. 281 | self.writereg(RegLoRaSyncWordMSB,0x14) 282 | self.writereg(RegLoRaSyncWordLSB,0x24) 283 | 284 | # Calibrate for the specific selected frequency 285 | if 430 <= freq <= 440: f1,f2 = 0x6b,0x6f 286 | elif 470 <= freq <= 510: f1,f2 = 0x75,0x81 287 | elif 779 <= freq <= 787: f1,f2 = 0xc1,0xc5 288 | elif 863 <= freq <= 870: f1,f2 = 0xd7,0xdb 289 | elif 902 <= freq <= 928: f1,f2 = 0xe1,0xe9 290 | else: f1,f2 = None,None 291 | 292 | if f1 and f2: 293 | self.command(CalibrateImageCmd,[f1,f2]) 294 | 295 | # This is just for debugging. We can understand if a given command 296 | # caused a failure while debugging the driver since the command status 297 | # will be set to '5'. We can also observe if the chip is in the 298 | # right mode (tx, rx, standby...). 299 | def show_status(self): 300 | status = self.command(0xc0,0)[1] 301 | print("Chip mode = ", (status >> 4) & 7) 302 | print("Cmd status = ", (status >> 1) & 7) 303 | 304 | # Put the chip in continuous receive mode. 305 | # Note that the SX1262 is bugged and if there is a strong 306 | # nearby signal sometimes it "crashes" and no longer 307 | # receives anything, so it may be a better approach to 308 | # set a timeout and re-enter receive from time to time? 309 | def receive(self): 310 | self.command(SetRxCmd,[0xff,0xff,0xff]) 311 | self.receiving = True 312 | 313 | def get_irq(self): 314 | reply = self.command(GetIrqStatusCmd,[0,0,0]) 315 | return (reply[2]<<8) | reply[3] 316 | 317 | def clear_irq(self): 318 | reply = self.command(ClearIrqStatusCmd,[0xff,0xff]) 319 | 320 | # This is our IRQ handler. By default we don't mask any interrupt 321 | # so the function may be called for more events we actually handle. 322 | def txrxdone(self, pin): 323 | event = self.get_irq() 324 | self.clear_irq() 325 | 326 | if event & (IRQSourceRxDone|IRQSourceCrcErr): 327 | # Packet received. The channel is no longer busy. 328 | self.packet_on_air = False 329 | 330 | # Obtain packet information. 331 | bs = self.command(GetRxBufferStatusCmd,[0]*3) 332 | ps = self.command(GetPacketStatusCmd,[0]*4) 333 | 334 | # Extract packet information. 335 | packet_len = bs[2] 336 | packet_start = bs[3] 337 | rssi = -ps[2]/2 # Average RSSI in dB. 338 | snr = ps[3]-256 if ps[3] > 128 else ps[3] # Convert to unsigned 339 | snr /= 4 # The reported value is upscaled 4 times. 340 | 341 | packet = self.readbuf(packet_start,packet_len) 342 | bad_crc = (event & IRQSourceCrcErr) != 0 343 | 344 | if bad_crc: 345 | print("SX1262: packet with bad CRC received") 346 | 347 | # Call the callback the user registered, if any. 348 | if self.received_callback: 349 | self.received_callback(self, packet, rssi, bad_crc) 350 | elif event & IRQSourceTxDone: 351 | self.msg_sent += 1 352 | # After sending a message, the chip will return in 353 | # standby mode. However if we were receiving we 354 | # need to return back to such state. 355 | if self.transmitted_callback: self.transmitted_callback() 356 | if self.receiving: self.receive() 357 | self.tx_in_progress = False 358 | elif event & IRQSourcePreambleDetected : 359 | # Packet detected, we will return true for some 360 | # time when user calls modem_is_receiving_packet(). 361 | self.packet_on_air = time.ticks_ms() 362 | self.packet_on_air_type = POAPreamble 363 | elif event & IRQSourceHeaderValid: 364 | # The same as above, but if we also detected a header 365 | # we will take this condition for a bit longer. 366 | self.packet_on_air = time.ticks_ms() 367 | self.packet_on_air_type = POAHeader 368 | else: 369 | print("SX1262: not handled event IRQ flags "+bin(event)) 370 | 371 | def get_instantaneous_rss(self): 372 | data = self.command(0x15,[0,0]) 373 | return -data[2]/2 374 | 375 | # This modem is used for listen-before-talk and returns true if 376 | # even if the interrupt of packet reception completed was not yet 377 | # called, we believe there is a current packet-on-air that we are 378 | # possibly receiving. This way we can avoid to transmit while there 379 | # is already a packet being transmitted, avoiding collisions. 380 | # 381 | # While the RX1276 has a register that tells us if a reception is 382 | # in progress, the RX1262 lacks it, so we try to do our best using 383 | # other systems... 384 | def modem_is_receiving_packet(self): 385 | if self.packet_on_air != False: 386 | # We are willing to wait more or less before cleaning 387 | # the channel busy flag, depending on the fact we 388 | # were able to detect just a preamble or also a valid 389 | # header. 390 | timeout = 2000 if self.packet_on_air_type == POAPreamble else 5000 391 | age = time.ticks_diff(time.ticks_ms(),self.packet_on_air) 392 | if age > timeout: self.packet_on_air = False 393 | return self.packet_on_air != False 394 | 395 | # Send the specified packet immediately. Will raise the interrupt 396 | # when finished. 397 | def send(self, data): 398 | self.tx_in_progress = True 399 | self.set_packet_params(payload_len = len(data)) 400 | self.writebuf(0x00,data) 401 | self.command(SetTxCmd,[0,0,0]) # Enter TX mode without timeout. 402 | 403 | # Example usage. 404 | if __name__ == "__main__": 405 | pinset = { 406 | 'busy': 7, 407 | 'reset': 8, 408 | 'chipselect': 5, 409 | 'clock': 3, 410 | 'mosi': 1, 411 | 'miso': 4, 412 | 'dio': 9 413 | } 414 | 415 | # The callback will be called every time a packet was 416 | # received. 417 | def onrx(lora_instance,packet,rssi,bad_crc): 418 | print(f"Received packet {packet} RSSI:{rssi} bad_crc:{bad_crc}") 419 | 420 | lora = SX1262(pinset=pinset,rx_callback=onrx) 421 | lora.begin() # Initialize the chip. 422 | lora.configure(869500000, 250000, 8, 12, 22) # Set our configuration. 423 | lora.receive() # Enter RX mode. 424 | lora.show_status() # Show the current device mode. 425 | 426 | # Send packets from time to time, while receiving if there 427 | # is something in the air. 428 | while True: 429 | if True: 430 | time.sleep(10) 431 | # Example packet in FreakWAN format. 432 | # Note that after we send a packet, if we were in 433 | # receive mode, we will return back in receive mode. 434 | lora.send(bytearray(b'\x00\x024j\x92\x11\x0f\x0c\x8b\x95\xa1\xe70\x07anti433Hi 626')) 435 | --------------------------------------------------------------------------------