commit 38023a9bf50288d0ac296e725a990b330333d958 Author: kicer Date: Sat Jan 24 13:02:13 2026 +0800 init weather_station repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bc4bed --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/archives diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd67bca --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# WiFi天气微站 + +esp8266版天气信息显示设备 + + +## 参考资料 +[MicroPython remote control: mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) diff --git a/docs/Nanoweb.md b/docs/Nanoweb.md new file mode 100644 index 0000000..65e869a --- /dev/null +++ b/docs/Nanoweb.md @@ -0,0 +1,65 @@ +# Nanoweb + +Nanoweb is a full asynchronous web server for micropython created in order to benefit from +a correct ratio between memory size and features. + +It is thus able to run on an ESP8266, ESP32, Raspberry Pico, etc... + +## Features + +* Completely asynchronous +* Declaration of routes via a dictionary or directly by decorator +* Management of static files (see assets_extensions) +* Callbacks functions when a new query or an error occurs +* Extraction of HTML headers +* User code dense and conci +* Routing wildcards + +## Installation + +You just have to copy the `nanoweb.py` file on the target (ESP32, Nano, etc...). + +## Use + +See the [example.py](example.py) file for an advanced example where you will be able to: + +* Make a JSON response +* Use pages protected with credentials +* Upload file +* Use `DELETE` method +* Read `POST` data + +And this is a simpler example: + +```Python +import uasyncio +from nanoweb import Nanoweb + +naw = Nanoweb() + +async def api_status(request): + """API status endpoint""" + await request.write("HTTP/1.1 200 OK\r\n") + await request.write("Content-Type: application/json\r\n\r\n") + await request.write('{"status": "running"}') + +# You can declare route from the Nanoweb routes dict... +naw.routes = { + '/api/status': api_status, +} + +# ... or declare route directly from the Nanoweb route decorator +@naw.route("/ping") +async def ping(request): + await request.write("HTTP/1.1 200 OK\r\n\r\n") + await request.write("pong") + +loop = uasyncio.get_event_loop() +loop.create_task(naw.run()) +loop.run_forever() +``` + +## Contribute + +* Your code must respects `flake8` and `isort` tools +* Format your commits with `Commit Conventional` (https://www.conventionalcommits.org/en/v1.0.0/) diff --git a/docs/mp_optimize_demo.md b/docs/mp_optimize_demo.md new file mode 100644 index 0000000..9a50157 --- /dev/null +++ b/docs/mp_optimize_demo.md @@ -0,0 +1,322 @@ +# Micropython程序优化实例 + +这个优化例子来自 Damien 在 pycomau 上的演讲使用MicroPython高效快速编程。 + +首先我们看下面的程序,它在循环中翻转LED,然后通过运行的时间和翻转次数,计算出每秒翻转的频率。 + +```py +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +t0 = time.ticks_us() + +for i in range(N): + led.on() + led.off() + +t1 = time.ticks_us() +dt = time.ticks_diff(t1, t0) +fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' +print(fmt.format(dt * 1e-6, dt / N, N / dt * 1e3)) +``` + +我们将这段代码保存为文件led1.py,然后import led1执行。在pybv10或者pyboardCN上结果是: + +> 3.381 sec, 16.905 usec/blink : 59.16 kblink/sec + +在 MicroPython程序优化原则 中,提到尽量在程序中执行功能,不要在主程序中运行,因此可以将LED翻转放在函数中执行。 + +```py +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +def blink_simple(n): + for i in range(n): + led.on() + led.off() + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + + +运行后的结果是: + +> 2.902 sec, 14.509 usec/blink : 68.92 kblink/sec + +可以看到,我们没有做什么实质的改到,就明显提高了速度。 + +循环是最消耗运行时间的,我们对循环中led.on()和led.off()两个动作进行优化,将它们预先载入内存,而无需循环中每次载入。 + +``` +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +def blink_simple(n): + on = led.on + off = led.off + for i in range(n): + on() + off() + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + +运行结果是 + +> 1.617 sec, 8.086 usec/blink : 123.68 kblink/sec + +速度提高了将近一倍。 + +进一步将循环中对 range(n) 也进行优化 + +```py +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +def blink_simple(n): + on = led.on + off = led.off + r = range(n) + for i in r: + on() + off() + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + + +运行结果是 + +> 1.121 sec, 5.607 usec/blink : 178.35 kblink/sec + +效果非常明显。 + +进一步对循环中的操作优化,减少循环次数 + +```py +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +def blink_simple(n): + n //= 8 + on = led.on + off = led.off + r = range(n) + for i in r: + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + +速度又有明显提升。 + +> 0.913 sec, 4.563 usec/blink : 219.16 kblink/sec + +根据MicroPython的优化功能,可以将程序声明为native code(本地代码),它使用CPU的操作码(opcode),而不是字节码(bytecode) + +```py +from machine import Pin +import time + +led = Pin('A13') +N = 200000 + +@micropython.native +def blink_simple(n): + n //= 8 + on = led.on + off = led.off + r = range(n) + for i in r: + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + on() + off() + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + +结果如下 + +> 0.704 sec, 3.521 usec/blink : 284.00 kblink/sec + +除了native,还可以使用viper code模式,它进一步提升了整数计算和位操作性能 + +```py +from machine import Pin +import time, stm + +led = Pin('A13') +N = 200000 + +@micropython.viper +def blink_simple(n:int): + n //= 8 + p = ptr16(stm.GPIOB + stm.GPIO_BSRR) + for i in range(n): + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + p[0] = 1 << 4 + p[1] = 1 << 4 + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + +运行结果的确是大幅提升了性能 + +> 0.016 sec, 0.078 usec/blink : 12879.13 kblink/sec + +最终我们还可以通过嵌入汇编方式,最大限度提升性能 + +```py +from machine import Pin +import time, stm + +led = Pin('A13') +N = 200000 + +@micropython.asm_thumb +def blink_simple(r0): + lsr(r0, r0, 3) + movwt(r1, stm.GPIOB + stm.GPIO_BSRR) + mov(r2, 1 << 4) + label(loop) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + strh(r2, [r1, 0]) + strh(r2, [r1, 2]) + sub(r0, 1) + bne(loop) + +def time_it(f, n): + t0 = time.ticks_us() + f(n) + t1 = time.ticks_us() + dt = time.ticks_diff(t1, t0) + fmt = '{:5.3f} sec, {:6.3f} usec/blink : {:8.2f} kblink/sec' + print(fmt.format(dt * 1e-6, dt / n, n / dt * 1e3)) + +time_it(blink_simple, N) +``` + +运行结果是 + +> 0.007 sec, 0.037 usec/blink : 27322.40 kblink/sec + +这个结果已经非常接近极限了。 + +从前面的优化顺序,可以看到我们并没有大幅修改程序,就可以极高程序的性能。实际使用中,大家可以灵活选择,提高程序的性能。 diff --git a/docs/mpremote.rst b/docs/mpremote.rst new file mode 100644 index 0000000..8aecc3a --- /dev/null +++ b/docs/mpremote.rst @@ -0,0 +1,762 @@ +.. _mpremote: + +MicroPython remote control: mpremote +==================================== + +The ``mpremote`` command line tool provides an integrated set of utilities to +remotely interact with, manage the filesystem on, and automate a MicroPython +device over a serial connection. + +To use mpremote, first install it via ``pip``: + +.. code-block:: bash + + $ pip install --user mpremote + +Or via `pipx `_: + +.. code-block:: bash + + $ pipx install mpremote + +The simplest way to use this tool is just by invoking it without any arguments: + +.. code-block:: bash + + $ mpremote + +This command automatically detects and connects to the first available USB +serial device and provides an interactive terminal that you can use to access +the REPL and your program's output. Serial ports are opened in exclusive mode, +so running a second (or third, etc) instance of ``mpremote`` will connect to +subsequent serial devices, if any are available. + +Additionally ``pipx`` also allows you to directly run ``mpremote`` without +installing first: + +.. code-block:: bash + + $ pipx run mpremote ...args + +Commands +-------- + +``mpremote`` supports being given a series of commands given at the command line +which will perform various actions in sequence on a remote MicroPython device. +See the :ref:`examples section ` below to get an idea of how +this works and for some common combinations of commands. + +Each command is of the form `` [--options] [args...]``. For commands +that support multiple arguments (e.g. a list of files), the argument list can +be terminated with ``+``. + +If no command is specified, the default command is ``repl``. Additionally, if +any command needs to access the device, and no earlier ``connect`` has been +specified, then an implicit ``connect auto`` is added. + +In order to get the device into a known state for any action command +(except ``repl``), once connected ``mpremote`` will stop any running program +and soft-reset the device before running the first command. You can control +this behavior using the ``resume`` and ``soft-reset`` commands. +See :ref:`auto-connection and auto-soft-reset ` for more details. + +Multiple commands can be specified and they will be run sequentially. + +The full list of supported commands are: + +- `connect ` +- `disconnect ` +- `resume ` +- `soft_reset ` +- `repl ` +- `eval ` +- `exec ` +- `run ` +- `fs ` +- `df ` +- `edit ` +- `mip ` +- `mount ` +- `unmount ` +- `romfs ` +- `rtc ` +- `sleep ` +- `reset ` +- `bootloader ` + +.. _mpremote_command_connect: + +- **connect** -- connect to specified device via name: + + .. code-block:: bash + + $ mpremote connect + + ```` may be one of: + + - ``list``: list available devices + - ``auto``: connect to the first available USB serial port + - ``id:``: connect to the device with USB serial number + ```` (the second column from the ``connect list`` + command output) + - ``port:``: connect to the device with the given path (the first column + from the ``connect list`` command output + - ``rfc2217://:``: connect to the device using serial over TCP + (e.g. a networked serial port based on RFC2217) + - any valid device name/path, to connect to that device + + **Note:** Instead of using the ``connect`` command, there are several + :ref:`pre-defined shortcuts ` for common device paths. For + example the ``a0`` shortcut command is equivalent to + ``connect /dev/ttyACM0`` (Linux), or ``c1`` for ``COM1`` (Windows). + + **Note:** The ``auto`` option will only detect USB serial ports, i.e. a serial + port that has an associated USB VID/PID (i.e. CDC/ACM or FTDI-style + devices). Other types of serial ports will not be auto-detected. + +.. _mpremote_command_disconnect: + +- **disconnect** -- disconnect current device: + + .. code-block:: bash + + $ mpremote disconnect + + After a disconnect, :ref:`auto-soft-reset ` is enabled. + +.. _mpremote_command_resume: + +- **resume** -- maintain existing interpreter state for subsequent commands: + + .. code-block:: bash + + $ mpremote resume + + This disables :ref:`auto-soft-reset `. This is useful if you + want to run a subsequent command on a board without first soft-resetting it. + +.. _mpremote_command_soft_reset: + +- **soft-reset** -- perform a soft-reset of the device: + + .. code-block:: bash + + $ mpremote soft-reset + + This will clear out the Python heap and restart the interpreter. It also + prevents the subsequent command from triggering :ref:`auto-soft-reset `. + +.. _mpremote_command_repl: + +- **repl** -- enter the REPL on the connected device: + + .. code-block:: bash + + $ mpremote repl [--options] + + Options are: + + - ``--escape-non-printable``, to print non-printable bytes/characters as their hex code + - ``--capture ``, to capture output of the REPL session to the given + file + - ``--inject-code ``, to specify characters to inject at the REPL when + ``Ctrl-J`` is pressed. This allows you to automate a common command. + - ``--inject-file ``, to specify a file to inject at the REPL when + ``Ctrl-K`` is pressed. This allows you to run a file (e.g. containing some + useful setup code, or even the program you are currently working on). + + While the ``repl`` command running, you can use ``Ctrl-]`` or ``Ctrl-x`` to + exit. + + **Note:** The name "REPL" here reflects that the common usage of this command + to access the Read Eval Print Loop that is running on the MicroPython + device. Strictly, the ``repl`` command is just functioning as a terminal + (or "serial monitor") to access the device. Because this command does not + trigger the :ref:`auto-reset behavior `, this means that if + a program is currently running, you will first need to interrupt it with + ``Ctrl-C`` to get to the REPL, which will then allow you to access program + state. You can also use ``mpremote soft-reset repl`` to get a "clean" REPL + with all program state cleared. + +.. _mpremote_command_eval: + +- **eval** -- evaluate and print the result of a Python expression: + + .. code-block:: bash + + $ mpremote eval + +.. _mpremote_command_exec: + +- **exec** -- execute the given Python code: + + .. code-block:: bash + + $ mpremote exec + + By default, ``mpremote exec`` will display any output from the expression until it + terminates. The ``--no-follow`` flag can be specified to return immediately and leave + the device running the expression in the background. + +.. _mpremote_command_run: + +- **run** -- run a script from the local filesystem: + + .. code-block:: bash + + $ mpremote run + + This will execute the file directly from RAM on the device without copying it + to the filesystem. This is a very useful way to iterate on the development of + a single piece of code without having to worry about deploying it to the + filesystem. + + By default, ``mpremote run`` will display any output from the script until it + terminates. The ``--no-follow`` flag can be specified to return immediately and leave + the device running the script in the background. + +.. _mpremote_command_fs: + +- **fs** -- execute filesystem commands on the device: + + .. code-block:: bash + + $ mpremote fs + + ```` may be: + + - ``cat `` to show the contents of a file or files on the device + - ``ls`` to list the current directory + - ``ls `` to list the given directories + - ``cp [-rf] `` to copy files + - ``rm [-r] `` to remove files or folders on the device + - ``mkdir `` to create directories on the device + - ``rmdir `` to remove directories on the device + - ``touch `` to create the files (if they don't already exist) + - ``sha256sum `` to calculate the SHA256 sum of files + - ``tree [-vsh] `` to print a tree of the given directories + + The ``cp`` command uses a convention where a leading ``:`` represents a remote + path. Without a leading ``:`` means a local path. This is based on the + convention used by the `Secure Copy Protocol (scp) client + `_. + + So for example, ``mpremote fs cp main.py :main.py`` copies ``main.py`` from + the current local directory to the remote filesystem, whereas + ``mpremote fs cp :main.py main.py`` copies ``main.py`` from the device back + to the current directory. + + The ``mpremote rm -r`` command accepts both relative and absolute paths. + Use ``:`` to refer to the current remote working directory (cwd) to allow a + directory tree to be removed from the device's default path (eg ``/flash``, ``/``). + Use ``-v/--verbose`` to see the files being removed. + + For example: + + - ``mpremote rm -r :libs`` will remove the ``libs`` directory and all its + child items from the device. + - ``mpremote rm -rv :/sd`` will remove all files from a mounted SDCard and result + in a non-blocking warning. The mount will be retained. + - ``mpremote rm -rv :/`` will remove all files on the device, including any + located in mounted vfs such as ``/sd`` or ``/flash``. After removing all folders + and files, this will also return an error to mimic unix ``rm -rf /`` behaviour. + + .. warning:: + There is no supported way to undelete files removed by ``mpremote rm -r :``. + Please use with caution. + + The ``tree`` command will print a tree of the given directories. + Using the ``--size/-s`` option will print the size of each file, or use + ``--human/-h`` to use a more human readable format. + Note: Directory size is only printed when a non-zero size is reported by the device's filesystem. + The ``-v`` option can be used to include the name of the serial device in + the output. + + All other commands implicitly assume the path is a remote path, but the ``:`` + can be optionally used for clarity. + + All of the filesystem sub-commands take multiple path arguments, so if there + is another command in the sequence, you must use ``+`` to terminate the + arguments, e.g. + + .. code-block:: bash + + $ mpremote fs cp main.py :main.py + repl + + This will copy the file to the device then enter the REPL. The ``+`` prevents + ``"repl"`` being interpreted as a path. + + The ``cp`` command supports the ``-r`` option to make a recursive copy. By + default ``cp`` will skip copying files to the remote device if the SHA256 hash + of the source and destination file matches. To force a copy regardless of the + hash use the ``-f`` option. + + **Note:** For convenience, all of the filesystem sub-commands are also + :ref:`aliased as regular commands `, i.e. you can write + ``mpremote cp ...`` instead of ``mpremote fs cp ...``. + +.. _mpremote_command_df: + +- **df** -- query device free/used space + + .. code-block:: bash + + $ mpremote df + + The ``df`` command will print size/used/free statistics for the device + filesystem, similar to the Unix ``df`` command. + +.. _mpremote_command_edit: + +- **edit** -- edit a file on the device: + + .. code-block:: bash + + $ mpremote edit + + The ``edit`` command will copy each file from the device to a local temporary + directory and then launch your editor for each file (defined by the environment + variable ``$EDITOR``). If the editor exits successfully, the updated file will + be copied back to the device. + +.. _mpremote_command_mip: + +- **mip** -- install packages from :term:`micropython-lib` (or GitHub) using the ``mip`` tool: + + .. code-block:: bash + + $ mpremote mip install + + See :ref:`packages` for more information. + +.. _mpremote_command_mount: + +- **mount** -- mount the local directory on the remote device: + + .. code-block:: bash + + $ mpremote mount [options] + + This allows the remote device to see the local host directory as if it were + its own filesystem. This is useful for development, and avoids the need to + copy files to the device while you are working on them. + + The device installs a filesystem driver, which is then mounted in the + :ref:`device VFS ` as ``/remote``, which uses the serial + connection to ``mpremote`` as a side-channel to access files. The device + will have its current working directory (via ``os.chdir``) set to + ``/remote`` so that imports and file access will occur there instead of the + default filesystem path while the mount is active. + + **Note:** If the ``mount`` command is not followed by another action in the + sequence, a ``repl`` command will be implicitly added to the end of the + sequence. + + During usage, Ctrl-D will trigger a soft-reset as normal, but the mount will + automatically be re-connected. If the unit has a main.py running at startup + however the remount cannot occur. In this case a raw mode soft reboot can be + used: Ctrl-A Ctrl-D to reboot, then Ctrl-B to get back to normal repl at + which point the mount will be ready. + + Options are: + + - ``-l``, ``--unsafe-links``: By default an error will be raised if the device + accesses a file or directory which is outside (up one or more directory levels) the + local directory that is mounted. This option disables this check for symbolic + links, allowing the device to follow symbolic links outside of the local directory. + +.. _mpremote_command_unmount: + +- **unmount** -- unmount the local directory from the remote device: + + .. code-block:: bash + + $ mpremote umount + + This happens automatically when ``mpremote`` terminates, but it can be used + in a sequence to unmount an earlier mount before subsequent command are run. + +.. _mpremote_command_romfs: + +- **romfs** -- manage ROMFS partitions on the device: + + .. code-block:: bash + + $ mpremote romfs + + ```` may be: + + - ``romfs query`` to list all the available ROMFS partitions and their size + - ``romfs [-o ] build `` to create a ROMFS image from the given + source directory; the default output file is the source appended by ``.romfs`` + - ``romfs [-p ] deploy `` to deploy a ROMFS image to the device; + will also create a temporary ROMFS image if the source is a directory + + The ``build`` and ``deploy`` sub-commands both support the ``-m``/``--mpy`` option + to automatically compile ``.py`` files to ``.mpy`` when creating the ROMFS image. + This option is enabled by default, but only works if the ``mpy_cross`` Python + package has been installed (eg via ``pip install mpy_cross``). If the package is + not installed then a warning is printed and ``.py`` files remain as is. Compiling + of ``.py`` files can be disabled with the ``--no-mpy`` option. + +.. _mpremote_command_rtc: + +- **rtc** -- set/get the device clock (RTC): + + .. code-block:: bash + + $ mpremote rtc + + This will query the device RTC for the current time and print it as a datetime + tuple. + + .. code-block:: bash + + $ mpremote rtc --set + + This will set the device RTC to the host PC's current time. + +.. _mpremote_command_sleep: + +- **sleep** -- sleep (delay) before executing the next command + + .. code-block:: bash + + $ mpremote sleep 0.5 + + This will pause execution of the command sequence for the specified duration + in seconds, e.g. to wait for the device to do something. + +.. _mpremote_command_reset: + +- **reset** -- hard reset the device + + .. code-block:: bash + + $ mpremote reset + + **Note:** hard reset is equivalent to :func:`machine.reset`. + +.. _mpremote_command_bootloader: + +- **bootloader** enter the bootloader + + .. code-block:: bash + + $ mpremote bootloader + + This will make the device enter its bootloader. The bootloader is port- and + board-specific (e.g. DFU on stm32, UF2 on rp2040/Pico). + +.. _mpremote_reset: + +Auto connection and soft-reset +------------------------------ + +Connection and disconnection will be done automatically at the start and end of +the execution of the tool, if such commands are not explicitly given. Automatic +connection will search for the first available USB serial device. + +Once connected to a device, ``mpremote`` will automatically soft-reset the +device if needed. This clears the Python heap and restarts the interpreter, +making sure that subsequent Python code executes in a fresh environment. Auto +soft-reset is performed the first time one of the following commands are +executed: ``mount``, ``eval``, ``exec``, ``run``, ``fs``. After doing a +soft-reset for the first time, it will not be done again automatically, until a +``disconnect`` command is issued. + +Auto-soft-reset behaviour can be controlled by the ``resume`` command. This +might be useful to use the ``eval`` command to inspect the state of of the +device. The ``soft-reset`` command can be used to perform an explicit soft +reset in the middle of a sequence of commands. + +.. _mpremote_shortcuts: + +Shortcuts +--------- + +Shortcuts can be defined using the macro system. Built-in shortcuts are: + +- ``devs``: Alias for ``connect list`` + +- ``a0``, ``a1``, ``a2``, ``a3``: Aliases for ``connect /dev/ttyACMn`` + +- ``u0``, ``u1``, ``u2``, ``u3``: Aliases for ``connect /dev/ttyUSBn`` + +- ``c0``, ``c1``, ``c2``, ``c3``: Aliases for ``connect COMn`` + +- ``cat``, ``edit``, ``ls``, ``cp``, ``rm``, ``mkdir``, ``rmdir``, ``touch``: Aliases for ``fs `` + +Additional shortcuts can be defined in the user configuration file ``mpremote/config.py``, +located in the User Configuration Directory. +The correct location for each OS is determined using the ``platformdirs`` module. + +This is typically: +- ``$XDG_CONFIG_HOME/mpremote/config.py`` +- ``$HOME/.config/mpremote/config.py`` +- ``$env:LOCALAPPDATA/mpremote/config.py`` + +The ``config.py``` file should define a dictionary named ``commands``. The keys of this dictionary are the shortcuts +and the values are either a string or a list-of-strings: + +.. code-block:: python3 + + "c33": "connect id:334D335C3138", + +The command ``c33`` is replaced by ``connect id:334D335C3138``. + +.. code-block:: python3 + + "test": ["mount", ".", "exec", "import test"], + +The command ``test`` is replaced by ``mount . exec "import test"``. + +Shortcuts can also accept arguments. For example: + +.. code-block:: python3 + + "multiply x=4 y=7": "eval x*y", + +Running ``mpremote times 3 7`` will set ``x`` and ``y`` as variables on the device, then evaluate the expression ``x*y``. + +An example ``config.py`` might look like: + +.. code-block:: python3 + + commands = { + "c33": "connect id:334D335C3138", # Connect to a specific device by ID. + "bl": "bootloader", # Shorter alias for bootloader. + "double x=4": "eval x*2", # x is an argument, with default 4 + "wl_scan": ["exec", """ + import network + wl = network.WLAN() + wl.active(1) + for ap in wl.scan(): + print(ap) + """,], # Print out nearby WiFi networks. + "wl_ipconfig": [ + "exec", + "import network; sta_if = network.WLAN(network.WLAN.IF_STA); print(sta_if.ipconfig('addr4'))", + """,], # Print ip address of station interface. + "test": ["mount", ".", "exec", "import test"], # Mount current directory and run test.py. + "demo": ["run", "path/to/demo.py"], # Execute demo.py on the device. + } + +.. _mpremote_examples: + +Examples +-------- + +.. code-block:: bash + + mpremote + +Connect to the first available device and implicitly run the ``repl`` command. + +.. code-block:: bash + + mpremote a1 + +Connect to the device at ``/dev/ttyACM1`` (Linux) and implicitly run the +``repl`` command. See :ref:`shortcuts ` above. + +.. code-block:: bash + + mpremote c1 + +Connect to the device at ``COM1`` (Windows) and implicitly run the ``repl`` +command. See :ref:`shortcuts ` above. + +.. code-block:: bash + + mpremote connect /dev/ttyUSB0 + +Explicitly specify which device to connect to, and as above, implicitly run the +``repl`` command. + +.. code-block:: bash + + mpremote a1 ls + +Connect to the device at ``/dev/ttyACM0`` and then run the ``ls`` command. + +It is equivalent to ``mpremote connect /dev/ttyACM1 fs ls``. + +.. code-block:: bash + + mpremote exec "import micropython; micropython.mem_info()" + +Run the specified Python command and display any output. This is equivalent to +typing the command at the REPL prompt. + +.. code-block:: bash + + mpremote eval 1/2 eval 3/4 + +Evaluate each expression in turn and print the results. + +.. code-block:: bash + + mpremote a0 eval 1/2 a1 eval 3/4 + +Evaluate ``1/2`` on the device at ``/dev/ttyACM0``, then ``3/4`` on the +device at ``/dev/ttyACM1``, printing each result. + +.. code-block:: bash + + mpremote resume exec "print_state_info()" soft-reset + +Connect to the device without triggering a :ref:`soft reset ` and +execute the ``print_state_info()`` function (e.g. to find out information about +the current program state), then trigger a soft reset. + +.. code-block:: bash + + mpremote reset sleep 0.5 bootloader + +Hard-reset the device, wait 500ms for it to become available, then enter the +bootloader. + +.. code-block:: bash + + mpremote cp utils/driver.py :utils/driver.py + run test.py + +Update the copy of utils/driver.py on the device, then execute the local +``test.py`` script on the device. ``test.py`` is never copied to the device +filesystem, rather it is run from RAM. + +.. code-block:: bash + + mpremote cp utils/driver.py :utils/driver.py + exec "import app" + +Update the copy of utils/driver.py on the device, then execute app.py on the +device. + +This is a common development workflow to update a single file and then re-start +your program. In this scenario, your ``main.py`` on the device would also do +``import app``. + +.. code-block:: bash + + mpremote cp utils/driver.py :utils/driver.py + soft-reset repl + +Update the copy of utils/driver.py on the device, then trigger a soft-reset to +restart your program, and then monitor the output via the ``repl`` command. + +.. code-block:: bash + + mpremote cp -r utils/ :utils/ + soft-reset repl + +Same as above, but update the entire utils directory first. + +.. code-block:: bash + + mpremote mount . + +Mount the current local directory at ``/remote`` on the device and starts a +``repl`` session which will use ``/remote`` as the working directory. + +.. code-block:: bash + + mpremote mount . exec "import demo" + +After mounting the current local directory, executes ``demo.py`` from the +mounted directory. + +.. code-block:: bash + + mpremote mount app run test.py + +After mounting the local directory ``app`` as ``/remote`` on the device, +executes the local ``test.py`` from the host's current directory without +copying it to the filesystem. + +.. code-block:: bash + + mpremote mount . repl --inject-code "import demo" + +After mounting the current local directory, executes ``demo.py`` from the +mounted directory each time ``Ctrl-J`` is pressed. + +You will first need to press ``Ctrl-D`` to reset the interpreter state +(which will preserve the mount) before pressing ``Ctrl-J`` to re-import +``demo.py``. + +.. code-block:: bash + + mpremote mount app repl --inject-file demo.py + +Same as above, but executes the contents of the local file demo.py at the REPL +every time ``Ctrl-K`` is pressed. As above, use Ctrl-D to reset the interpreter +state first. + +.. code-block:: bash + + mpremote cat boot.py + +Displays the contents of ``boot.py`` on the device. + +.. code-block:: bash + + mpremote edit utils/driver.py + +Edit ``utils/driver.py`` on the device using your local ``$EDITOR``. + +.. code-block:: bash + + mpremote cp :main.py . + +Copy ``main.py`` from the device to the local directory. + +.. code-block:: bash + + mpremote cp main.py : + +Copy ``main.py`` from the local directory to the device. + +.. code-block:: bash + + mpremote cp :a.py :b.py + +Copy ``a.py`` on the device to ``b.py`` on the device. + +.. code-block:: bash + + mpremote cp -r dir/ : + +Recursively copy the local directory ``dir`` to the remote device. + +.. code-block:: bash + + mpremote cp a.py b.py : + repl + +Copy ``a.py`` and ``b.py`` from the local directory to the device, then run the +``repl`` command. + +.. code-block:: bash + + mpremote mip install aioble + +Install the ``aioble`` package from :term:`micropython-lib` to the device. +See :ref:`packages`. + +.. code-block:: bash + + mpremote mip install github:org/repo@branch + +Install the package from the specified branch at org/repo on GitHub to the +device. See :ref:`packages`. + +.. code-block:: bash + + mpremote mip install gitlab:org/repo@branch + +Install the package from the specified branch at org/repo on GitLab to the +device. See :ref:`packages`. + +.. code-block:: bash + + mpremote mip install --target /flash/third-party functools + +Install the ``functools`` package from :term:`micropython-lib` to the +``/flash/third-party`` directory on the device. See :ref:`packages`. diff --git a/docs/st7789_mpy.md b/docs/st7789_mpy.md new file mode 100644 index 0000000..cdbaf36 --- /dev/null +++ b/docs/st7789_mpy.md @@ -0,0 +1,746 @@ + +# ST7789 Driver for MicroPython + +This driver is based on [devbis' st7789_mpy driver.](https://github.com/devbis/st7789_mpy) +I modified the original driver for one of my projects to add: + +- Display Rotation. +- Scrolling +- Writing text using bitmaps converted from True Type fonts +- Drawing text using 8 and 16-bit wide bitmap fonts +- Drawing text using Hershey vector fonts +- Drawing JPGs, including a SLOW mode to draw jpg's larger than available ram + using the TJpgDec - Tiny JPEG Decompressor R0.01d. from + http://elm-chan.org/fsw/tjpgd/00index.html +- Drawing PNGs using the pngle library from https://github.com/kikuchan/pngle +- Drawing and rotating Polygons and filled Polygons. +- Tracking bounds +- Custom init capability to support st7735, ili9341, ili9342 and other displays. See the examples/configs folder for M5Stack Core, M5Stack Core2, T-DONGLE-S3 and Wio_Terminal devices. + +Included are 12 bitmap fonts derived from classic pc text mode fonts, 26 +Hershey vector fonts and several example programs for different devices. + +## Display Configuration + +Some displays may use a BGR color order or inverted colors. The `cfg_helper.py` +program can be used to determine the color order, inversion_mode, colstart, and +rowstart values needed for a display. + +### Color Modes + +You can test for the correct color order needed by a display by filling it with +the `st7789.RED` color and observing the actual color displayed. + + - If the displayed color is RED, the settings are correct. + - If the displayed color is BLUE, `color_order` should be `st7789.BGR`. + - If the displayed color is YELLOW, `inversion_mode` should be `True.` + - If the displayed color is CYAN, `color_order` should be `st7789.BGR` and + `inversion_mode` should be `True.` + +### colstart and rowstart + +Some displays have a frame buffer memory larger than the physical display +matrix. In these cases, the driver must be configured with the position of the +first physical column and row pixels relative to the frame buffer. Each +rotation setting of the display may require different colstart and rowstart +values. + +The driver automatically sets the `colstart` and `rowstart` values for common +135x240, 240x240, 170x320 and 240x320 displays. If the default values do not work for +your display, these values can be overridden using the `offsets` method. The +`offsets` method should be called after any `rotation` method calls. + +#### 128x128 st7735 cfg_helper.py example + +``` +inversion_mode(False) +color_order = st7789.BGR +for rotation 0 use offset(2, 1) +for rotation 1 use offset(1, 2) +for rotation 2 use offset(2, 3) +for rotation 3 use offset(3, 2) +``` + +#### 128x160 st7735 cfg_helper.py example + +``` +inversion_mode(False) +color_order = st7789.RGB +for rotation 0 use offset(0, 0) +for rotation 1 use offset(0, 0) +for rotation 2 use offset(0, 0) +for rotation 3 use offset(0, 0) +``` + +## Pre-compiled firmware files + +The firmware directory contains pre-compiled firmware for various devices with +the st7789 C driver and frozen python font files. See the README.md file in the +fonts folder for more information on the font files. + +MicroPython MicroPython v1.20.0 compiled with ESP IDF v4.4.4 using CMake + +Directory | File | Device +--------------------- | ------------ | ---------------------------------- +GENERIC-7789 | firmware.bin | Generic ESP32 devices +GENERIC_SPIRAM-7789 | firmware.bin | Generic ESP32 devices with SPI Ram +GENERIC_C3 | firmware.bin | Generic ESP32-C3 devices +LOLIN_S2_MINI | firmware.bin | Wemos S2 mini +PYBV11 | firmware.dfu | Pyboard v1.1 (No PNG) +RP2 | firmware.uf2 | Raspberry Pi Pico RP2040 +RP2W | firmware.uf2 | Raspberry Pi PicoW RP2040 +T-DISPLAY | firmware.bin | LILYGO® TTGO T-Display +T-Watch-2020 | firmware.bin | LILYGO® T-Watch 2020 +WIO_TERMINAL | firmware.bin | Seeed Wio Terminal + + +## Additional Modules + +Module | Source +------------------ | ----------------------------------------------------------- +axp202c | https://github.com/lewisxhe/AXP202X_Libraries +focaltouch | https://gitlab.com/mooond/t-watch2020-esp32-with-micropython + +## Video Examples + +Example | Video +--------------------- | ----------------------------------------------------------- +PYBV11 hello.py | https://youtu.be/OtcERmad5ps +PYBV11 scroll.py | https://youtu.be/ro13rvaLKAc +T-DISPLAY fonts.py | https://youtu.be/2cnAhEucPD4 +T-DISPLAY hello.py | https://youtu.be/z41Du4GDMSY +T-DISPLAY scroll.py | https://youtu.be/GQa-RzHLBak +T-DISPLAY roids.py | https://youtu.be/JV5fPactSPU +TWATCH-2020 draw.py | https://youtu.be/O_lDBnvH1Sw +TWATCH-2020 hello.py | https://youtu.be/Bwq39tuMoY4 +TWATCH-2020 bitmap.py | https://youtu.be/DgYzgnAW2d8 +TWATCH-2020 watch.py | https://youtu.be/NItKb6umMc4 + +This is a work in progress. + +## Thanks go out to: + +- https://github.com/devbis for the original driver this is based on. +- https://github.com/hklang10 for letting me know of the new mp_raise_ValueError(). +- https://github.com/aleggon for finding the correct offsets for 240x240 + displays and for discovering issues compiling STM32 ports. + +-- Russ + +## Overview + +This is a driver for MicroPython to handle cheap displays based on the ST7789 +chip. The driver is written in C. Firmware is provided for ESP32, ESP32 with SPIRAM, +pyboard1.1, and Raspberry Pi Pico devices. + + +

+ ST7789 display photo +

+ + +# Setup MicroPython Build Environment in Ubuntu 20.04.2 + +See the MicroPython +[README.md](https://github.com/micropython/micropython/blob/master/ports/esp32/README.md#setting-up-esp-idf-and-the-build-environment) +if you run into any build issues not directly related to the st7789 driver. The +recommended MicroPython build instructions may have changed. + +Update and upgrade Ubuntu using apt-get if you are using a new install of +Ubuntu or the Windows Subsystem for Linux. + +```bash +sudo apt-get -y update +sudo apt-get -y upgrade +``` + +Use apt-get to install the required build tools. + +```bash +sudo apt-get -y install build-essential libffi-dev git pkg-config cmake virtualenv python3-pip python3-virtualenv +``` + +### Install a compatible esp-idf SDK + +The MicroPython README.md states: "The ESP-IDF changes quickly, and MicroPython +only supports certain versions. Currently, MicroPython supports v4.0.2, v4.1.1, +and v4.2 although other IDF v4 versions may also work." I have had good luck +using IDF v4.4 + +Clone the esp-idf SDK repo -- this usually takes several minutes. + +```bash +git clone -b v4.4 --recursive https://github.com/espressif/esp-idf.git +cd esp-idf/ +git pull +``` + +If you already have a copy of the IDF, you can checkout a version compatible +with MicroPython and update the submodules using: + +```bash +$ cd esp-idf +$ git checkout v4.4 +$ git submodule update --init --recursive +``` + +Install the esp-idf SDK. + +```bash +./install.sh +``` + +Source the esp-idf export.sh script to set the required environment variables. +You must source the file and not run it using ./export.sh. You will need to +source this file before compiling MicroPython. + +```bash +source export.sh +cd .. +``` + +Clone the MicroPython repo. + +```bash +git clone https://github.com/micropython/micropython.git +``` + +Clone the st7789 driver repo. + +```bash +git clone https://github.com/russhughes/st7789_mpy.git +``` + +Update the git submodules and compile the MicroPython cross-compiler + +```bash +cd micropython/ +git submodule update --init +cd mpy-cross/ +make +cd .. +cd ports/esp32 +``` + +Copy any .py files you want to include in the firmware as frozen python modules +to the modules subdirectory in ports/esp32. Be aware there is a limit to the +flash space available. You will know you have exceeded this limit if you +receive an error message saying the code won't fit in the partition or if your +firmware continuously reboots with an error. + +For example: + +```bash +cp ../../../st7789_mpy/fonts/bitmap/vga1_16x16.py modules +cp ../../../st7789_mpy/fonts/truetype/NotoSans_32.py modules +cp ../../../st7789_mpy/fonts/vector/scripts.py modules +``` + +Build the MicroPython firmware with the driver and frozen .py files in the +modules directory. If you did not add any .py files to the modules directory, +you can leave out the FROZEN_MANIFEST and FROZEN_MPY_DIR settings. + +```bash +make USER_C_MODULES=../../../../st7789_mpy/st7789/micropython.cmake FROZEN_MANIFEST="" FROZEN_MPY_DIR=$UPYDIR/modules +``` + +Erase and flash the firmware to your device. Set PORT= to the ESP32's usb +serial port. I could not get the USB serial port to work under the Windows +Subsystem (WSL2) for Linux. If you have the same issue, you can copy the +firmware.bin file and use the Windows esptool.py to flash your device. + +```bash +make USER_C_MODULES=../../../../st7789_mpy/st7789/micropython.cmake PORT=/dev/ttyUSB0 erase +make USER_C_MODULES=../../../../st7789_mpy/st7789/micropython.cmake PORT=/dev/ttyUSB0 deploy +``` + +The firmware.bin file will be in the build-GENERIC directory. To flash using +the python esptool.py utility. Use pip3 to install the esptool if it's not +already installed. + +```bash +pip3 install esptool +``` + +Set PORT= to the ESP32's USB serial port + +```bash +esptool.py --port COM3 erase_flash +esptool.py --chip esp32 --port COM3 write_flash -z 0x1000 firmware.bin +``` +## CMake building instructions for MicroPython 1.14 and later + +for ESP32: + + $ cd micropython/ports/esp32 + +And then compile the module with specified USER_C_MODULES dir. + + $ make USER_C_MODULES=../../../../st7789_mpy/st7789/micropython.cmake + +for Raspberry Pi PICO: + + $ cd micropython/ports/rp2 + +And then compile the module with specified USER_C_MODULES dir. + + $ make USER_C_MODULES=../../../st7789_mpy/st7789/micropython.cmake + +## Working examples + +This module was tested on ESP32, STM32 based pyboard v1.1, and the Raspberry Pi +Pico. You have to provide an `SPI` object and the pin to use for the `dc' input +of the screen. + + + # ESP32 Example + # To use baudrates above 26.6MHz you must use my firmware or modify the micropython + # source code to increase the SPI baudrate limit by adding SPI_DEVICE_NO_DUMMY to the + # .flag member of the spi_device_interface_config_t struct in the machine_hw_spi_init_internal.c + # file. Not doing so will cause the ESP32 to crash if you use a baudrate that is too high. + + import machine + import st7789 + spi = machine.SPI(2, baudrate=40000000, polarity=1, sck=machine.Pin(18), mosi=machine.Pin(23)) + display = st7789.ST7789(spi, 240, 240, reset=machine.Pin(4, machine.Pin.OUT), dc=machine.Pin(2, machine.Pin.OUT)) + display.init() + + +## Methods + +- `st7789.ST7789(spi, width, height, dc, reset, cs, backlight, rotations, rotation, custom_init, color_order, inversion, options, buffer_size)` + + ### Required positional arguments: + - `spi` spi device + - `width` display width + - `height` display height + + ### Required keyword arguments: + - `dc` sets the pin connected to the display data/command selection input. + This parameter is always required. + + ### Optional keyword arguments: + + - `reset` sets the pin connected to the display's hardware reset input. If + the displays reset pin is tied high, the `reset` parameter is not + required. + + - `cs` sets the pin connected to the displays chip select input. If the + display's CS pin is tied low, the display must be the only device + connected to the SPI port. The display will always be the selected + device, and the `cs` parameter is not required. + + - `backlight` sets the pin connected to the display's backlight enable + input. The display's backlight input can often be left floating or + disconnected as the backlight on some displays is always powered on and + cannot be turned off. + + - `rotations` sets the orientation table. The orientation table is a list + of tuples for each `rotation` used to set the MADCTL register, display width, + display height, start_x, and start_y values. + + Default `rotations` are included for the following st7789 and st7735 + display sizes: + + Display | Default Orientation Tables + ------- | -------------------------- + 240x320 | [(0x00, 240, 320, 0, 0), (0x60, 320, 240, 0, 0), (0xc0, 240, 320, 0, 0), (0xa0, 320, 240, 0, 0)] + 170x320 | [(0x00, 170, 320, 35, 0), (0x60, 320, 170, 0, 35), (0xc0, 170, 320, 35, 0), (0xa0, 320, 170, 0, 35)] + 240x240 | [(0x00, 240, 240, 0, 0), (0x60, 240, 240, 0, 0), (0xc0, 240, 240, 0, 80), (0xa0, 240, 240, 80, 0)] + 135x240 | [(0x00, 135, 240, 52, 40), (0x60, 240, 135, 40, 53), (0xc0, 135, 240, 53, 40), (0xa0, 240, 135, 40, 52)] + 128x160 | [(0x00, 128, 160, 0, 0), (0x60, 160, 128, 0, 0), (0xc0, 128, 160, 0, 0), (0xa0, 160, 128, 0, 0)] + 128x128 | [(0x00, 128, 128, 2, 1), (0x60, 128, 128, 1, 2), (0xc0, 128, 128, 2, 3), (0xa0, 128, 128, 3, 2)] + other | [(0x00, width, height, 0, 0)] + + You may define as many rotations as you wish. + + - `rotation` sets the display rotation according to the orientation table. + + The default orientation table defines four counter-clockwise rotations for 240x320, 240x240, + 134x240, 128x160 and 128x128 displays with the LCD's ribbon cable at the bottom of the display. + The default rotation is Portrait (0 degrees). + + Index | Rotation + ----- | -------- + 0 | Portrait (0 degrees) + 1 | Landscape (90 degrees) + 2 | Reverse Portrait (180 degrees) + 3 | Reverse Landscape (270 degrees) + + - `custom_init` List of display configuration commands to send to the display during the display init(). + The list contains tuples with a bytes object, optionally followed by a delay specified in ms. The first + byte of the bytes object contains the command to send optionally followed by data bytes. + See the `examples/configs/t_dongle_s3/tft_config.py` file or an example. + + - `color_order` Sets the color order used by the driver (st7789.RGB or st7789.BGR) + + - `inversion` Sets the display color inversion mode if True, clears the + display color inversion mode if false. + + - `options` Sets driver option flags. + + Option | Description + ------------- | ----------- + st7789.WRAP | pixels, lines, polygons, and Hershey text will wrap around the display both horizontally and vertically. + st7789.WRAP_H | pixels, lines, polygons, and Hershey text will wrap around the display horizontally. + st7789.WRAP_V | pixels, lines, polygons, and Hershey text will wrap around the display vertically. + + - `buffer_size` If a buffer_size is not specified, a dynamically allocated + buffer is created and freed as needed. If a buffer_size is set, it must + be large enough to contain the largest bitmap, font character, and + decoded JPG image used (Rows * Columns * 2 bytes, 16bit colors in RGB565 + notation). Dynamic allocation is slower and can cause heap fragmentation, + so garbage collection (GC) should be enabled. + +- `inversion_mode(bool)` Sets the display color inversion mode if True, clears + the display color inversion mode if False. + +- `madctl(value)` Returns the current value of the MADCTL register or sets the MADCTL register if a value is passed to the + method. The MADCTL register is used to set the display rotation and color order. + + #### [MADCTL constants](#madctl-constants) + + Constant Name | Value | Description + ---------------- | ----- | ---------------------- + st7789.MADCTL_MY | 0x80 | Page Address Order + st7789_MADCTL_MX | 0x40 | Column Address Order + st7789_MADCTL_MV | 0x20 | Page/Column Order + st7789_MADCTL_ML | 0x10 | Line Address Order + st7789_MADCTL_MH | 0x04 | Display Data Latch Order + st7789_RGB | 0x00 | RGB color order + st7789_BGR | 0x08 | BGR color order + + #### [MADCTL examples](#madctl-examples) + + + Orientation | MADCTL Values for RGB color order, for BGR color order add 0x08 to the value. + ----------- | --------------------------------------------------------------------------------- + | 0x00 + | 0x80 ( MADCTL_MY ) + | 0x40 ( MADCTL_MX ) + | 0xC0 ( MADCTL_MX + MADCTL_MY ) + | 0x20 ( MADCTL_MV ) + | 0xA0 ( MADCTL_MV + MADCTL_MY ) + | 0x60 ( MADCTL_MV + MADCTL_MX ) + | 0xE0 ( MADCTL_MV + MADCTL_MX + MADCTL_MY ) + +- `init()` + + Must be called to initialize the display. + +- `on()` + + Turn on the backlight pin if one was defined during init. + +- `off()` + + Turn off the backlight pin if one was defined during init. + +- `sleep_mode(value)` + + If value is True, cause the display to enter sleep mode, otherwise wake up if value is False. During sleep display content may not be preserved. + + +- `fill(color)` + + Fill the display with the specified color. + +- `pixel(x, y, color)` + + Set the specified pixel to the given `color`. + +- `line(x0, y0, x1, y1, color)` + + Draws a single line with the provided `color` from (`x0`, `y0`) to + (`x1`, `y1`). + +- `hline(x, y, length, color)` + + Draws a single horizontal line with the provided `color` and `length` + in pixels. Along with `vline`, this is a fast version with fewer SPI calls. + +- `vline(x, y, length, color)` + + Draws a single horizontal line with the provided `color` and `length` + in pixels. + +- `rect(x, y, width, height, color)` + + Draws a rectangle from (`x`, `y`) with corresponding dimensions + +- `fill_rect(x, y, width, height, color)` + + Fill a rectangle starting from (`x`, `y`) coordinates + +- `circle(x, y, r, color)` + + Draws a circle with radius `r` centered at the (`x`, `y`) coordinates in the given + `color`. + +- `fill_circle(x, y, r, color)` + + Draws a filled circle with radius `r` centered at the (`x`, `y`) coordinates + in the given `color`. + +- `blit_buffer(buffer, x, y, width, height)` + + Copy bytes() or bytearray() content to the screen internal memory. Note: + every color requires 2 bytes in the array + +- `text(font, s, x, y[, fg, bg])` + + Write `s` (integer, string or bytes) to the display using the specified bitmap + `font` with the coordinates as the upper-left corner of the text. The optional + arguments `fg` and `bg` can set the foreground and background colors of the + text; otherwise the foreground color defaults to `WHITE`, and the background + color defaults to `BLACK`. See the `README.md` in the `fonts/bitmap` directory + for example fonts. + +- `write(bitmap_font, s, x, y[, fg, bg, background_tuple, fill_flag])` + + Write text to the display using the specified proportional or Monospace bitmap + font module with the coordinates as the upper-left corner of the text. The + foreground and background colors of the text can be set by the optional + arguments `fg` and `bg`, otherwise the foreground color defaults to `WHITE` + and the background color defaults to `BLACK`. + + Transparency can be emulated by providing a `background_tuple` containing + (bitmap_buffer, width, height). This is the same format used by the jpg_decode + method. See examples/T-DISPLAY/clock/clock.py for an example. + + See the `README.md` in the `truetype/fonts` directory for example fonts. + Returns the width of the string as printed in pixels. Accepts UTF8 encoded strings. + + The `font2bitmap` utility creates compatible 1 bit per pixel bitmap modules + from Proportional or Monospaced True Type fonts. The character size, + foreground, background colors, and characters in the bitmap + module may be specified as parameters. Use the -h option for details. If you + specify a buffer_size during the display initialization, it must be large + enough to hold the widest character (HEIGHT * MAX_WIDTH * 2). + +- `write_len(bitap_font, s)` + + Returns the string's width in pixels if printed in the specified font. + +- `draw(vector_font, s, x, y[, fg, scale])` + + Draw text to the display using the specified Hershey vector font with the + coordinates as the lower-left corner of the text. The foreground color of the + text can be set by the optional argument `fg`. Otherwise the foreground color + defaults to `WHITE`. The size of the text can be scaled by specifying a + `scale` value. The `scale` value must be larger than 0 and can be a + floating-point or an integer value. The `scale` value defaults to 1.0. See + the README.md in the `vector/fonts` directory, for example fonts and the + utils directory for a font conversion program. + +- `draw_len(vector_font, s[, scale])` + + Returns the string's width in pixels if drawn with the specified font. + +- `jpg(jpg, x, y [, method])` + + Draw a `jpg` on the display with the given `x` and `y` coordinates as the + upper left corner of the image. `jpg` may be a string containing a filename + or a buffer containing the JPEG image data. + + The memory required to decode and display a JPG can be considerable as a full-screen + 320x240 JPG would require at least 3100 bytes for the working area + 320 * 240 * 2 + bytes of ram to buffer the image. Jpg images that would require a buffer larger than + available memory can be drawn by passing `SLOW` for the `method`. The `SLOW` `method` + will draw the image one piece at a time using the Minimum Coded Unit (MCU, typically + a multiple of 8x8) of the image. The default method is `FAST`. + +- `jpg_decode(jpg_filename [, x, y, width, height])` + + Decode a jpg file and return it or a portion of it as a tuple composed of + (buffer, width, height). The buffer is a color565 blit_buffer compatible byte + array. The buffer will require width * height * 2 bytes of memory. + + If the optional x, y, width, and height parameters are given, the buffer will + only contain the specified area of the image. See examples/T-DISPLAY/clock/clock.py + examples/T-DISPLAY/toasters_jpg/toasters_jpg.py for examples. + +- `png(png_filename, x, y [, mask])` + + Draw a PNG file on the display with upper left corner of the image at the given `x` and `y` + coordinates. The PNG will be clipped if it is not able to fit fully on the display. The + PNG will be drawn one line at a time. Since the driver does not contain a frame buffer, + transparency is not supported. Providing a `True` value for the `mask` parameter + will prevent pixels with a zero alpha channel value from being displayed. Drawing masked PNG's is + slower than non-masked as each visible line segment is drawn separately. For an example of using a + mask, see the alien.py program in the examples/png folder. + +- `polygon_center(polygon)` + + Return the center of the `polygon` as an (x, y) tuple. The `polygon` should + consist of a list of (x, y) tuples forming a closed convex polygon. + +- `fill_polygon(polygon, x, y, color[, angle, center_x, center_y])` + + Draw a filled `polygon` at the `x`, and `y` coordinates in the `color` given. + The polygon may be rotated `angle` radians about the `center_x` and + `center_y` point. The polygon should consist of a list of (x, y) tuples + forming a closed convex polygon. + + See the TWATCH-2020 `watch.py` demo for an example. + +- `polygon(polygon, x, y, color, angle, center_x, center_y)` + + Draw a `polygon` at the `x`, and `y` coordinates in the `color` given. The + polygon may be rotated `angle` radians about the `center_x` and `center_y` + point. The polygon should consist of a list of (x, y) tuples forming a closed + convex polygon. + + See the T-Display `roids.py` for an example. + +- `bounding({status, as_rect})` + + Bounding enables or disables tracking the display area that has been written + to. Initially, tracking is disabled; pass a True value to enable tracking and + False to disable it. Passing a True or False parameter will reset the current + bounding rectangle to (display_width, display_height, 0, 0). + + Returns a four integer tuple containing (min_x, min_y, max_x, max_y) + indicating the area of the display that has been written to since the last + clearing. + + If `as_rect` parameter is True, the returned tuple will contain (min_x, + min_y, width, height) values. + + See the TWATCH-2020 `watch.py` demo for an example. + +- `bitmap(bitmap, x , y [, index])` + + Draw `bitmap` using the specified `x`, `y` coordinates as the upper-left + corner of the `bitmap`. The optional `index` parameter provides a method to + select from multiple bitmaps contained a `bitmap` module. The `index` is used + to calculate the offset to the beginning of the desired bitmap using the + modules HEIGHT, WIDTH, and BPP values. + + The `imgtobitmap.py` utility creates compatible 1 to 8 bit per pixel bitmap + modules from image files using the Pillow Python Imaging Library. + + The `monofont2bitmap.py` utility creates compatible 1 to 8 bit per pixel + bitmap modules from Monospaced True Type fonts. See the `inconsolata_16.py`, + `inconsolata_32.py` and `inconsolata_64.py` files in the `examples/lib` + folder for sample modules and the `mono_font.py` program for an example using + the generated modules. + + The character sizes, bit per pixel, foreground, background colors, and the + characters to include in the bitmap module may be specified as parameters. + Use the -h option for details. Bits per pixel settings larger than one may be + used to create antialiased characters at the expense of memory use. If you + specify a buffer_size during the display initialization, it must be large + enough to hold the one character (HEIGHT * WIDTH * 2). + +- `width()` + + Returns the current logical width of the display. (ie a 135x240 display + rotated 90 degrees is 240 pixels wide) + +- `height()` + + Returns the current logical height of the display. (ie a 135x240 display + rotated 90 degrees is 135 pixels high) + +- `rotation(r)` + + Set the rotates the logical display in a counter-clockwise direction. + 0-Portrait (0 degrees), 1-Landscape (90 degrees), 2-Inverse Portrait (180 + degrees), 3-Inverse Landscape (270 degrees) + +- `offset(x_start, y_start)` The memory in the ST7789 controller is configured + for a 240x320 display. When using a smaller display like a 240x240 or + 135x240, an offset needs to be added to the x and y parameters so that the + pixels are written to the memory area corresponding to the visible display. + The offsets may need to be adjusted when rotating the display. + + For example, the TTGO-TDisplay is 135x240 and uses the following offsets. + + | Rotation | x_start | y_start | + |----------|---------|---------| + | 0 | 52 | 40 | + | 1 | 40 | 53 | + | 2 | 53 | 40 | + | 3 | 40 | 52 | + + When the rotation method is called, the driver will adjust the offsets for a + 135x240 or 240x240 display. Your display may require using different offset + values; if so, use the `offset` method after `rotation` to set the offset + values. + + The values needed for a particular display may not be documented and may + require some experimentation to determine the correct values. One technique + is to draw a box the same size as the display and then make small changes to + the offsets until the display looks correct. See the `cfg_helper.py` program + in the examples folder for more information. + + +The module exposes predefined colors: + `BLACK`, `BLUE`, `RED`, `GREEN`, `CYAN`, `MAGENTA`, `YELLOW`, and `WHITE` + +## Scrolling + +The st7789 display controller contains a 240 by 320-pixel frame buffer used to +store the pixels for the display. For scrolling, the frame buffer consists of +three separate areas; The (`tfa`) top fixed area, the (`height`) scrolling +area, and the (`bfa`) bottom fixed area. The `tfa` is the upper portion of the +frame buffer in pixels not to scroll. The `height` is the center portion of the +frame buffer in pixels to scroll. The `bfa` is the lower portion of the frame +buffer in pixels not to scroll. These values control the ability to scroll the +entire or a part of the display. + +For displays that are 320 pixels high, setting the `tfa` to 0, `height` to 320, +and `bfa` to 0 will allow scrolling of the entire display. You can set the +`tfa` and `bfa` to a non-zero value to scroll a portion of the display. `tfa` + +`height` + `bfa` = should equal 320, otherwise the scrolling mode is undefined. + +Displays less than 320 pixels high, the `tfa`, `height`, and `bfa` will need to +be adjusted to compensate for the smaller LCD panel. The actual values will +vary depending on the configuration of the LCD panel. For example, scrolling +the entire 135x240 TTGO T-Display requires a `tfa` value of 40, `height` value +of 240, and `bfa` value of 40 (40+240+40=320) because the T-Display LCD shows +240 rows starting at the 40th row of the frame buffer, leaving the last 40 rows +of the frame buffer undisplayed. + +Other displays like the Waveshare Pico LCD 1.3 inch 240x240 display require the +`tfa` set to 0, `height` set to 240, and `bfa` set to 80 (0+240+80=320) to +scroll the entire display. The Pico LCD 1.3 shows 240 rows starting at the 0th +row of the frame buffer, leaving the last 80 rows of the frame buffer +undisplayed. + +The `vscsad` method sets the (VSSA) Vertical Scroll Start Address. The VSSA +sets the line in the frame buffer that will be the first line after the `tfa`. + + The ST7789 datasheet warns: + + The value of the vertical scrolling start address is absolute (with reference to the frame memory), + it must not enter the fixed area (defined by Vertical Scrolling Definition, otherwise undesirable + image will be displayed on the panel. + +- `vscrdef(tfa, height, bfa)` Set the vertical scrolling parameters. + + `tfa` is the top fixed area in pixels. The top fixed area is the upper + portion of the display frame buffer that will not be scrolled. + + `height` is the total height in pixels of the area scrolled. + + `bfa` is the bottom fixed area in pixels. The bottom fixed area is the lower + portion of the display frame buffer that will not be scrolled. + +- `vscsad(vssa)` Set the vertical scroll address. + + `vssa` is the vertical scroll start address in pixels. The vertical scroll + start address is the line in the frame buffer will be the first line shown + after the TFA. + +## Helper functions + +- `color565(r, g, b)` + + Pack a color into 2-bytes rgb565 format + +- `map_bitarray_to_rgb565(bitarray, buffer, width, color=WHITE, bg_color=BLACK)` + + Convert a `bitarray` to the rgb565 color `buffer` suitable for blitting. Bit + 1 in `bitarray` is a pixel with `color` and 0 - with `bg_color`. diff --git a/scripts/download.sh b/scripts/download.sh new file mode 100755 index 0000000..9e15b1a --- /dev/null +++ b/scripts/download.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# 连接并打印文件列表 +mpremote connect /dev/tty.wchusbserial14310 ls + +# 生成romfs文件系统并上传 +mpremote romfs -o website.romfs build website +mpremote romfs deploy website.romfs + +# 复制文件 +for file in src/*.py; do + if [ -f "$file" ]; then + mpremote fs cp "$file" : + fi +done + +# 打印文件列表 +mpremote ls / +mpremote ls /rom diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..b963511 --- /dev/null +++ b/src/app.py @@ -0,0 +1,93 @@ +# ESP8266天气站主程序 +# 首先尝试连接已保存的WiFi,失败则启动CaptivePortal进行配置 + +import gc, time, sys, machine + +# 使用全局的WiFiManager实例 +from wifi_manager import wifi_manager + + + +print("尝试连接已保存的WiFi网络...") + +if wifi_manager.connect(): + # 连接成功 + ip = wifi_manager.get_ip() + print(f"WiFi连接成功,IP地址: {ip}") + + # 在这里可以添加主应用程序代码 + # 例如:启动天气数据获取和显示 + print("启动主应用程序...") + + # 示例:保持连接 + try: + while True: + # 检查连接状态 + if not wifi_manager.is_connected(): + print("WiFi连接断开") + break + + # 每3秒报告一次状态 + time.sleep(3) + gc.collect() + print(f"正常运行中,IP: {ip}, 可用内存: {gc.mem_free()} bytes") + + except KeyboardInterrupt: + print("用户中断") + + finally: + wifi_manager.disconnect() + print("应用程序结束") + +else: + # 连接失败,启动CaptivePortal进行WiFi配置 + print("无法连接到WiFi,启动CaptivePortal进行配置") + + from captive_portal import CaptivePortal + + # 启动CaptivePortal + portal = CaptivePortal() + + try: + if portal.start(): + # clear + del portal + sys.modules.pop('CaptivePortal', None) + sys.modules.pop('captive_dns', None) + sys.modules.pop('captive_http', None) + sys.modules.pop('server_base', None) + gc.collect() + # CaptivePortal成功配置并连接 + print("WiFi配置成功并已连接") + + # 在这里可以添加主应用程序代码 + print("启动主应用程序...") + + # 示例:保持连接 + try: + while True: + # 检查连接状态 + if not wifi_manager.is_connected(): + print("WiFi连接断开") + break + + # 每3秒报告一次状态 + time.sleep(3) + gc.collect() + print(f"正常运行中,可用内存: {gc.mem_free()} bytes") + + except KeyboardInterrupt: + print("用户中断") + + finally: + wifi_manager.disconnect() + print("应用程序结束") + else: + print("CaptivePortal未能成功建立连接") + + except KeyboardInterrupt: + print("用户中断") + + finally: + time.sleep(3) + machine.reset() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..bf3d0fb --- /dev/null +++ b/src/main.py @@ -0,0 +1,11 @@ +import machine, sys, time +import rom.app + +try: + app.start() +except Exception as e: + print("Fatal error in main:") + sys.print_exception(e) + +time.sleep(3) +machine.reset() diff --git a/src/rom/boot.py b/src/rom/boot.py new file mode 100644 index 0000000..65e0782 --- /dev/null +++ b/src/rom/boot.py @@ -0,0 +1,12 @@ +# This file is executed on every boot (including wake-boot from deepsleep) +import esp, gc, uos, machine + +esp.osdebug(None) +#uos.dupterm(None, 1) # disable REPL on UART(0) + +# cpu freq = 160MHz +machine.freq(160000000) + +# memory auto collect (<16KB) +gc.threshold(16384) +gc.collect() diff --git a/src/rom/captive_dns.py b/src/rom/captive_dns.py new file mode 100644 index 0000000..3894934 --- /dev/null +++ b/src/rom/captive_dns.py @@ -0,0 +1,75 @@ +import gc + +import usocket as socket +from server_base import BaseServer + + +class DNSQuery: + def __init__(self, data): + self.data = data + self.domain = "" + # header is bytes 0-11, so question starts on byte 12 + head = 12 + # length of this label defined in first byte + length = data[head] + while length != 0: + label = head + 1 + # add the label to the requested domain and insert a dot after + self.domain += data[label : label + length].decode("utf-8") + "." + # check if there is another label after this one + head += length + 1 + length = data[head] + + def answer(self, ip_addr): + # ** create the answer header ** + # copy the ID from incoming request + packet = self.data[:2] + # set response flags (assume RD=1 from request) + packet += b"\x81\x80" + # copy over QDCOUNT and set ANCOUNT equal + packet += self.data[4:6] + self.data[4:6] + # set NSCOUNT and ARCOUNT to 0 + packet += b"\x00\x00\x00\x00" + + # ** create the answer body ** + # respond with original domain name question + packet += self.data[12:] + # pointer back to domain name (at byte 12) + packet += b"\xc0\x0c" + # set TYPE and CLASS (A record and IN class) + packet += b"\x00\x01\x00\x01" + # set TTL to 60sec + packet += b"\x00\x00\x00\x3c" + # set response length to 4 bytes (to hold one IPv4 address) + packet += b"\x00\x04" + # now actually send the IP address as 4 bytes (without the "."s) + packet += bytes(map(int, ip_addr.split("."))) + + gc.collect() + + return packet + + +class DNSServer(BaseServer): + def __init__(self, poller, ip_addr): + super().__init__(poller, 53, socket.SOCK_DGRAM, "DNS Server") + self.ip_addr = ip_addr + + def handle(self, sock, event, others): + # server doesn't spawn other sockets, so only respond to its own socket + if sock is not self.sock: + return + + # check the DNS question, and respond with an answer + try: + data, sender = sock.recvfrom(1024) + request = DNSQuery(data) + + print("Sending {:s} -> {:s}".format(request.domain, self.ip_addr)) + sock.sendto(request.answer(self.ip_addr), sender) + + # help MicroPython with memory management + del request + gc.collect() + except Exception as e: + print("DNS server exception:", e) diff --git a/src/rom/captive_http.py b/src/rom/captive_http.py new file mode 100644 index 0000000..0bb1c82 --- /dev/null +++ b/src/rom/captive_http.py @@ -0,0 +1,292 @@ +from collections import namedtuple + +import network +import uerrno +import uio +import uselect as select +import usocket as socket +from config import config +from server_base import BaseServer +from wifi_manager import wifi_manager + +WriteConn = namedtuple("WriteConn", ["body", "buff", "buffmv", "write_range"]) +ReqInfo = namedtuple("ReqInfo", ["type", "path", "params", "host"]) + +import gc + + +def unquote(string): + """stripped down implementation of urllib.parse unquote_to_bytes""" + + if not string: + return b"" + + if isinstance(string, str): + string = string.encode("utf-8") + string = string.replace(b"+", b" ") + + # split into substrings on each escape character + bits = string.split(b"%") + if len(bits) == 1: + return string # there was no escape character + + res = [bits[0]] # everything before the first escape character + + # for each escape character, get the next two digits and convert to + for item in bits[1:]: + code = item[:2] + char = bytes([int(code, 16)]) # convert to utf-8-encoded byte + res.append(char) # append the converted character + res.append( + item[2:] + ) # append anything else that occurred before the next escape character + + return b"".join(res) + + +class HTTPServer(BaseServer): + def __init__(self, poller, local_ip): + super().__init__(poller, 80, socket.SOCK_STREAM, "HTTP Server") + if type(local_ip) is bytes: + self.local_ip = local_ip + else: + self.local_ip = local_ip.encode() + self.request = dict() + self.conns = dict() + self.routes = { + b"/": b"/rom/index.html", + b"/login": self.login, + b"/scan": self.scan_networks, + } + + self.ssid = None + + # queue up to 2 connection requests before refusing (ESP8266 memory optimization) + self.sock.listen(2) + self.sock.setblocking(False) + + #@micropython.native + def handle(self, sock, event, others): + if sock is self.sock: + # client connecting on port 80, so spawn off a new + # socket to handle this connection + print("- Accepting new HTTP connection") + self.accept(sock) + elif event & select.POLLIN: + # socket has data to read in + print("- Reading incoming HTTP data") + self.read(sock) + elif event & select.POLLOUT: + # existing connection has space to send more data + print("- Sending outgoing HTTP data") + self.write_to(sock) + + def accept(self, server_sock): + """accept a new client request socket and register it for polling""" + + try: + client_sock, addr = server_sock.accept() + except OSError as e: + if e.args[0] == uerrno.EAGAIN: + return + + client_sock.setblocking(False) + client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.poller.register(client_sock, select.POLLIN) + + def parse_request(self, req): + """parse a raw HTTP request to get items of interest""" + + req_lines = req.split(b"\r\n") + req_type, full_path, http_ver = req_lines[0].split(b" ") + path = full_path.split(b"?") + base_path = path[0] + query = path[1] if len(path) > 1 else None + query_params = ( + { + key: val + for key, val in [param.split(b"=") for param in query.split(b"&")] + } + if query + else {} + ) + host = [line.split(b": ")[1] for line in req_lines if b"Host:" in line][0] + + return ReqInfo(req_type, base_path, query_params, host) + + def login(self, params): + # 从URL参数中提取表单数据 + ssid = unquote(params.get(b"ssid", None)) + password = unquote(params.get(b"password", None)) + city = unquote(params.get(b"city", None)) + + # 使用全局Config实例保存配置 + config.set("ssid", ssid) + config.set("password", password) + config.set("city", city) + if config.write(): + print("配置保存成功") + else: + print("配置保存失败,数据无效") + + # 重定向local_ip + headers = ( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{:s}/\r\n".format( + self.local_ip + ) + ) + + return b"", headers + + def scan_networks(self, params): + """扫描WiFi网络并返回JSON数据""" + try: + # 使用wifi_manager扫描网络 + networks = wifi_manager.scan_networks() + + import ujson + + json_data = ujson.dumps({"networks": networks}) + except Exception as e: + print(f"扫描网络时出错: {e}") + json_data = ujson.dumps({"networks": []}) + + headers = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n" + return json_data.encode(), headers + + def get_response(self, req): + """generate a response body and headers, given a route""" + + headers = b"HTTP/1.1 200 OK\r\n" + route = self.routes.get(req.path, None) + + if type(route) is bytes: + # expect a filename, so return contents of file + return open(route, "rb"), headers + + if callable(route): + # call a function, which may or may not return a response + response = route(req.params) + body = response[0] or b"" + headers = response[1] or headers + return uio.BytesIO(body), headers + + headers = b"HTTP/1.1 404 Not Found\r\n" + return uio.BytesIO(b""), headers + + def is_valid_req(self, req): + if req.host != self.local_ip: + # force a redirect to the MCU's IP address + return False + # redirect if we don't have a route for the requested path + return req.path in self.routes + + def read(self, s): + """read in client request from socket""" + + data = s.read() + if not data: + # no data in the TCP stream, so close the socket + self.close(s) + return + + # add new data to the full request + sid = id(s) + self.request[sid] = self.request.get(sid, b"") + data + + # check if additional data expected + if data[-4:] != b"\r\n\r\n": + # HTTP request is not finished if no blank line at the end + # wait for next read event on this socket instead + return + + # get the completed request + req = self.parse_request(self.request.pop(sid)) + + if not self.is_valid_req(req): + headers = ( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{:s}/\r\n".format( + self.local_ip + ) + ) + body = uio.BytesIO(b"") + self.prepare_write(s, body, headers) + return + + # by this point, we know the request has the correct + # host and a valid route + body, headers = self.get_response(req) + self.prepare_write(s, body, headers) + + def prepare_write(self, s, body, headers): + # add newline to headers to signify transition to body + headers += "\r\n" + # TCP/IP MSS is 536 bytes, so create buffer of this size and + # initially populate with header data + buff = bytearray(headers + "\x00" * (536 - len(headers))) + # use memoryview to read directly into the buffer without copying + buffmv = memoryview(buff) + # start reading body data into the memoryview starting after + # the headers, and writing at most the remaining space of the buffer + # return the number of bytes written into the memoryview from the body + bw = body.readinto(buffmv[len(headers) :], 536 - len(headers)) + # save place for next write event + c = WriteConn(body, buff, buffmv, [0, len(headers) + bw]) + self.conns[id(s)] = c + # let the poller know we want to know when it's OK to write + self.poller.modify(s, select.POLLOUT) + + def write_to(self, sock): + """write the next message to an open socket""" + + # get the data that needs to be written to this socket + c = self.conns[id(sock)] + if c: + # write next 536 bytes (max) into the socket + try: + bytes_written = sock.write( + c.buffmv[c.write_range[0] : c.write_range[1]] + ) + except OSError: + print("cannot write to a closed socket") + self.close(sock) + return + if not bytes_written or c.write_range[1] < 536: + # either we wrote no bytes, or we wrote < TCP MSS of bytes + # so we're done with this connection + self.close(sock) + else: + # more to write, so read the next portion of the data into + # the memoryview for the next send event + self.buff_advance(c, bytes_written) + + def buff_advance(self, c, bytes_written): + """advance the writer buffer for this connection to next outgoing bytes""" + + if bytes_written == c.write_range[1] - c.write_range[0]: + # wrote all the bytes we had buffered into the memoryview + # set next write start on the memoryview to the beginning + c.write_range[0] = 0 + # set next write end on the memoryview to length of bytes + # read in from remainder of the body, up to TCP MSS + c.write_range[1] = c.body.readinto(c.buff, 536) + else: + # didn't read in all the bytes that were in the memoryview + # so just set next write start to where we ended the write + c.write_range[0] += bytes_written + + def close(self, s): + """close the socket, unregister from poller, and delete connection""" + + s.close() + self.poller.unregister(s) + sid = id(s) + if sid in self.request: + del self.request[sid] + if sid in self.conns: + c = self.conns[sid] + # 检查body是文件对象(而不是BytesIO),则关闭文件 + if hasattr(c.body, "close"): + c.body.close() + del self.conns[sid] + gc.collect() diff --git a/src/rom/captive_portal.py b/src/rom/captive_portal.py new file mode 100644 index 0000000..9cadc1c --- /dev/null +++ b/src/rom/captive_portal.py @@ -0,0 +1,133 @@ +import gc + +import network +import ubinascii as binascii +import uselect as select +import utime as time +from captive_dns import DNSServer +from captive_http import HTTPServer +from config import config +from wifi_manager import wifi_manager + + +class CaptivePortal: + AP_IP = "192.168.4.1" + MAX_CONN_ATTEMPTS = 10 + + def __init__(self, essid=None): + self.local_ip = self.AP_IP + self.ap_if = network.WLAN(network.AP_IF) + + if essid is None: + essid = b"ws2-%s" % binascii.hexlify(self.ap_if.config("mac")[-3:]) + self.essid = essid + + self.dns_server = None + self.http_server = None + self.poller = select.poll() + + def start_access_point(self): + # sometimes need to turn off AP before it will come up properly + self.ap_if.active(False) + while not self.ap_if.active(): + print("Waiting for access point to turn on") + self.ap_if.active(True) + time.sleep(1) + # IP address, netmask, gateway, DNS + self.ap_if.ifconfig( + (self.local_ip, "255.255.255.0", self.local_ip, self.local_ip) + ) + self.ap_if.config(essid=self.essid, authmode=network.AUTH_OPEN) + print("AP mode configured:", self.ap_if.ifconfig()) + + def connect_to_wifi(self): + # 使用全局WiFiManager进行连接 + if wifi_manager.connect(): + self.local_ip = wifi_manager.get_ip() + return True + return False + + def check_valid_wifi(self): + if not wifi_manager.is_connected(): + if config.is_valid(): + # have credentials to connect, but not yet connected + # return value based on whether the connection was successful + return self.connect_to_wifi() + # not connected, and no credentials to connect yet + return False + + return True + + def captive_portal(self): + print("Starting captive portal") + self.start_access_point() + + if self.http_server is None: + self.http_server = HTTPServer(self.poller, self.local_ip) + print("Configured HTTP server") + if self.dns_server is None: + self.dns_server = DNSServer(self.poller, self.local_ip) + print("Configured DNS server") + + try: + while True: + gc.collect() + # check for socket events and handle them + for response in self.poller.ipoll(1000): + sock, event, *others = response + is_handled = self.handle_dns(sock, event, others) + if not is_handled: + self.handle_http(sock, event, others) + + if self.check_valid_wifi(): + print("Connected to WiFi!") + self.http_server.stop(self.poller) + self.dns_server.stop(self.poller) + break + + except KeyboardInterrupt: + print("Captive portal stopped") + self.cleanup() + return wifi_manager.is_connected() + + def handle_dns(self, sock, event, others): + if sock is self.dns_server.sock: + # ignore UDP socket hangups + if event == select.POLLHUP: + return True + self.dns_server.handle(sock, event, others) + return True + return False + + def handle_http(self, sock, event, others): + self.http_server.handle(sock, event, others) + + def cleanup(self): + print("Cleaning up") + if self.ap_if.active(): + self.ap_if.active(False) + print("Turned off access point") + if self.dns_server: + self.dns_server.stop(self.poller) + self.dns_server = None + print("Discard portal.dns_server") + if self.http_server: + self.http_server.stop(self.poller) + self.http_server = None + print("Discard portal.http_server") + gc.collect() + + def try_connect_from_file(self): + if config.is_valid(): + if self.connect_to_wifi(): + return True + + # WiFi Connection failed but keep credentials for future retries + print("连接失败但保留配置,可以稍后重试") + return False + + def start(self): + # turn off station interface to force a reconnect + wifi_manager.disconnect() + if not self.try_connect_from_file(): + return self.captive_portal() diff --git a/src/rom/config.py b/src/rom/config.py new file mode 100644 index 0000000..962f77a --- /dev/null +++ b/src/rom/config.py @@ -0,0 +1,91 @@ +import ujson +import uos + + +class Config: + """通用配置类,用于保存各种系统配置项""" + + CONFIG_FILE = "/config.json" + _instance = None + _initialized = False + + def __new__(cls): + """单一实例模式实现""" + if cls._instance is None: + cls._instance = super(Config, cls).__new__(cls) + return cls._instance + + def __init__(self, ssid=None, password=None, city=None, **kwargs): + """初始化配置,只在第一次调用时执行""" + if not self._initialized: + self.config_data = {"ssid": ssid, "password": password, "city": city} + # 添加其他可能的自定义配置项 + self.config_data.update(kwargs) + self._initialized = True + # 自动加载配置文件 + self.load() + + def write(self): + """将配置写入JSON格式的配置文件""" + if self.is_valid(): + # 只将非None的值保存到文件 + save_data = {k: v for k, v in self.config_data.items() if v is not None} + + with open(self.CONFIG_FILE, "w") as f: + ujson.dump(save_data, f) + print(f"写入配置到 {self.CONFIG_FILE}") + # 写入后重新加载配置 + return self.load() + return False + + def load(self): + """从配置文件加载配置""" + try: + with open(self.CONFIG_FILE, "r") as f: + loaded_data = ujson.load(f) + + self.config_data.update(loaded_data) + print(f"从 {self.CONFIG_FILE} 加载配置") + + # 如果核心配置不完整,可能需要清除文件 + if not self.is_valid(): + print("配置不完整,清除配置文件") + self.remove() + except (OSError, ValueError): + pass + + return self + + def get(self, key, default=None): + """获取配置项""" + return self.config_data.get(key, default) + + def set(self, key, value): + """设置配置项""" + self.config_data[key] = value + + def remove(self): + """删除配置文件并重置配置""" + try: + uos.remove(self.CONFIG_FILE) + except OSError: + pass + + # 保留默认的关键配置项 + self.config_data = {"ssid": None, "password": None, "city": None} + + def is_valid(self): + """检查核心配置项是否有效""" + ssid = self.config_data.get("ssid") + password = self.config_data.get("password") + # 确保SSID和密码都是字符串类型且不为空 + if not ssid: + return False + if not password: + return False + + return True + + +# 全局配置实例 +config = Config() diff --git a/src/rom/index.html b/src/rom/index.html new file mode 100644 index 0000000..4efbdc5 --- /dev/null +++ b/src/rom/index.html @@ -0,0 +1,317 @@ + + + + + WiFi认证 + + + +
+

WiFi 配置

+ +
+
+
+
+ +
+ + +
+
+

正在连接到WiFi

+
+

设备正在尝试连接,请稍候...

+
+
+ + + diff --git a/src/rom/nanoweb.py b/src/rom/nanoweb.py new file mode 100644 index 0000000..7a626de --- /dev/null +++ b/src/rom/nanoweb.py @@ -0,0 +1,196 @@ +import uasyncio as asyncio +import uerrno + + +__version__ = '1.0.0' + + +class HttpError(Exception): + pass + + +class Request: + url = "" + method = "" + headers = {} + route = "" + read = None + write = None + close = None + + def __init__(self): + self.url = "" + self.method = "" + self.headers = {} + self.route = "" + self.read = None + self.write = None + self.close = None + + +async def write(request, data): + await request.write( + data.encode('ISO-8859-1') if type(data) == str else data + ) + + +async def error(request, code, reason): + await request.write("HTTP/1.1 %s %s\r\n\r\n" % (code, reason)) + await request.write("

%s

" % (reason)) + + +async def send_file(request, filename, segment=64, binary=False): + try: + with open(filename, 'rb' if binary else 'r') as f: + while True: + data = f.read(segment) + if not data: + break + await request.write(data) + except OSError as e: + if e.args[0] != uerrno.ENOENT: + raise + raise HttpError(request, 404, "File Not Found") + + +class Nanoweb: + + extract_headers = ('Authorization', 'Content-Length', 'Content-Type') + headers = {} + + routes = {} + assets_extensions = ('html', 'css', 'js') + + callback_request = None + callback_error = staticmethod(error) + + STATIC_DIR = '' + INDEX_FILE = STATIC_DIR + '/index.html' + + def __init__(self, port=80, address='0.0.0.0'): + self.port = port + self.address = address + + def route(self, route): + """Route decorator""" + def decorator(func): + self.routes[route] = func + return func + return decorator + + async def generate_output(self, request, handler): + """Generate output from handler + + `handler` can be : + * dict representing the template context + * string, considered as a path to a file + * tuple where the first item is filename and the second + is the template context + * callable, the output of which is sent to the client + """ + while True: + if isinstance(handler, dict): + handler = (request.url, handler) + + if isinstance(handler, str): + await write(request, "HTTP/1.1 200 OK\r\n\r\n") + await send_file(request, handler) + elif isinstance(handler, tuple): + await write(request, "HTTP/1.1 200 OK\r\n\r\n") + filename, context = handler + context = context() if callable(context) else context + try: + with open(filename, "r") as f: + for l in f: + await write(request, l.format(**context)) + except OSError as e: + if e.args[0] != uerrno.ENOENT: + raise + raise HttpError(request, 404, "File Not Found") + else: + handler = await handler(request) + if handler: + # handler can returns data that can be fed back + # to the input of the function + continue + break + + async def handle(self, reader, writer): + items = await reader.readline() + items = items.decode('ascii').split() + if len(items) != 3: + return + + request = Request() + request.read = reader.read + request.write = writer.awrite + request.close = writer.aclose + + request.method, request.url, version = items + + try: + try: + if version not in ("HTTP/1.0", "HTTP/1.1"): + raise HttpError(request, 505, "Version Not Supported") + + while True: + items = await reader.readline() + items = items.decode('ascii').split(":", 1) + + if len(items) == 2: + header, value = items + value = value.strip() + + if header in self.extract_headers: + request.headers[header] = value + elif len(items) == 1: + break + + if self.callback_request: + self.callback_request(request) + + if request.url in self.routes: + # 1. If current url exists in routes + request.route = request.url + await self.generate_output(request, + self.routes[request.url]) + else: + # 2. Search url in routes with wildcard + for route, handler in self.routes.items(): + if route == request.url \ + or (route[-1] == '*' and + request.url.startswith(route[:-1])): + request.route = route + await self.generate_output(request, handler) + break + else: + # 3. Try to load index file + if request.url in ('', '/'): + await send_file(request, self.INDEX_FILE) + else: + # 4. Current url have an assets extension ? + for extension in self.assets_extensions: + if request.url.endswith('.' + extension): + await send_file( + request, + '%s%s' % ( + self.STATIC_DIR, + request.url, + ), + binary=True, + ) + break + else: + raise HttpError(request, 404, "File Not Found") + except HttpError as e: + request, code, message = e.args + await self.callback_error(request, code, message) + except OSError as e: + # Skip ECONNRESET error (client abort request) + if e.args[0] != uerrno.ECONNRESET: + raise + finally: + await writer.aclose() + + async def run(self): + return await asyncio.start_server(self.handle, self.address, self.port) diff --git a/src/rom/server_base.py b/src/rom/server_base.py new file mode 100644 index 0000000..b607a98 --- /dev/null +++ b/src/rom/server_base.py @@ -0,0 +1,27 @@ +import select +import socket + + +class BaseServer: + """基础服务器类,为HTTP和DNS服务器提供通用功能""" + + def __init__(self, poller, port, sock_type, name): + self.name = name + # create socket with correct type: stream (TCP) or datagram (UDP) + self.sock = socket.socket(socket.AF_INET, sock_type) + + # register to get event updates for this socket + self.poller = poller + self.poller.register(self.sock, select.POLLIN) + + addr = socket.getaddrinfo("0.0.0.0", port)[0][-1] + # allow new requests while still sending last response + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(addr) + + print(self.name, "listening on", addr) + + def stop(self, poller): + poller.unregister(self.sock) + self.sock.close() + print(self.name, "stopped") diff --git a/src/rom/wifi_manager.py b/src/rom/wifi_manager.py new file mode 100644 index 0000000..dae66b7 --- /dev/null +++ b/src/rom/wifi_manager.py @@ -0,0 +1,164 @@ +import time + +import network +from config import config + + +class WiFiManager: + """WiFi连接管理类,负责处理WiFi连接相关功能""" + + MAX_CONN_ATTEMPTS = 12 + + def __init__(self): + self.sta_if = network.WLAN(network.STA_IF) + self.config = config + self._interface_initialized = False + + def _ensure_interface_active(self): + """确保WLAN接口处于活动状态""" + try: + if not self.sta_if.active(): + self.sta_if.active(True) + time.sleep(1) # 等待接口激活 + self._interface_initialized = True + return True + except Exception as e: + print(f"激活WiFi接口失败: {e}") + return False + + def _safe_connect_check(self): + """安全地检查连接状态""" + try: + return self.sta_if.isconnected() + except: + return False + + def is_connected(self): + """检查是否已连接到WiFi""" + if not self._interface_initialized: + return False + return self._safe_connect_check() + + def get_ip(self): + """获取当前IP地址""" + if self.is_connected(): + try: + return self.sta_if.ifconfig()[0] + except: + return None + return None + + def connect(self): + """尝试连接到WiFi""" + # 加载配置 + if not self.config.load().is_valid(): + print("没有有效的WiFi配置") + return False + + ssid = self.config.get("ssid") + password = self.config.get("password") + + if not ssid or not password: + print("SSID或密码为空") + return False + + print(f"正在尝试连接到SSID: {ssid}") + + try: + # 确保接口处于活动状态 + if not self._ensure_interface_active(): + return False + + # 如果已经连接,先断开 + if self._safe_connect_check(): + self.sta_if.disconnect() + time.sleep(1) + + # 执行连接 + self.sta_if.connect(ssid, password) + + # 等待连接完成 + attempts = 1 + while attempts <= self.MAX_CONN_ATTEMPTS: + if self._safe_connect_check(): + ip = self.get_ip() + if ip: + print(f"连接成功! IP: {ip}") + return True + + print(f"连接尝试 {attempts}/{self.MAX_CONN_ATTEMPTS}...") + time.sleep(2) + attempts += 1 + + # 连接失败 + print(f"连接失败: {ssid}") + self.clear_config() + try: + print(f"WLAN状态: {self.sta_if.status()}") + except: + pass + return False + + except Exception as e: + print(f"连接过程中发生错误: {e}") + return False + + def disconnect(self): + """断开WiFi连接""" + try: + if self._safe_connect_check(): + self.sta_if.disconnect() + time.sleep(1) + print("已断开WiFi连接") + except Exception as e: + print(f"断开连接时出错: {e}") + + def scan_networks(self): + """扫描可用的WiFi网络""" + try: + # 确保接口处于活动状态 + if not self._ensure_interface_active(): + return [] + + # 如果已连接,先断开 + if self._safe_connect_check(): + self.sta_if.disconnect() + time.sleep(1) + + # 执行扫描 + networks = self.sta_if.scan() + + # 处理结果 + result = [] + for net in networks: + try: + ssid = net[0].decode("utf-8", "ignore") + if ssid: # 只添加非隐藏网络 + result.append({"ssid": ssid, "rssi": net[3]}) + except: + continue # 跳过有问题的网络 + + return result + except Exception as e: + print(f"扫描WiFi网络时出错: {e}") + return [] + + def reset(self): + """重置WiFi配置""" + try: + self.config.remove() + print("WiFi配置已重置") + except Exception as e: + print(f"重置配置时出错: {e}") + + def clear_config(self): + """清除WiFi配置(可选操作)""" + try: + self.config.set("ssid", None) + print("WiFi配置已临时清空") + except Exception as e: + print(f"清除配置时出错: {e}") + + +# 全局WiFi管理器实例 +wifi_manager = WiFiManager()