├── .gitattributes
├── .github
└── workflows
│ ├── main.yml
│ └── microhydra-build.yml
├── .gitignore
├── .gitmodules
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── devices
├── CARDPUTER
│ ├── definition.yml
│ └── lib
│ │ └── userinput
│ │ └── _keys.py
├── TDECK
│ ├── definition.yml
│ ├── lib
│ │ └── userinput
│ │ │ ├── _keys.py
│ │ │ └── _touch.py
│ └── mpconfigboard.cmake
├── default.yml
└── esp32_mpy_build
│ ├── MH-partitions-8MiB.csv
│ └── boards
│ └── MICROHYDRA_GENERIC_S3
│ ├── board.json
│ ├── board.md
│ ├── manifest.py
│ ├── mpconfigboard.cmake
│ ├── mpconfigboard.h
│ ├── sdkconfig.board
│ └── sdkconfig.flash_4m
├── misc
├── A_B_speedtesting.py
├── images
│ ├── cardputerg0.jpg
│ ├── releasebin.png
│ ├── releasecompiled.png
│ ├── releases.png
│ ├── thonnyconfigureinterpreter.png
│ ├── thonnyfiles.png
│ ├── thonnyflashbin.png
│ ├── thonnyflashsettings.png
│ ├── thonnyhamburgermenu.png
│ ├── thonnyinstallmicropython.png
│ ├── thonnyinstallmicropythonwindow.png
│ ├── thonnyinterpreteroptions.png
│ ├── thonnylocalmicropython.png
│ ├── thonnyuploadfiles.png
│ └── thonnyuploadfiles2.png
└── mountSD.py
├── pyproject.toml
├── src
├── apps
│ └── apptemplate.py
├── font
│ ├── NotoSansMono_32.py
│ ├── utf8_8x8.bin
│ ├── utf8_8x8.py
│ ├── vga1_8x16.py
│ └── vga2_16x32.py
├── launcher
│ ├── editor
│ │ ├── __init__.py
│ │ ├── cursor.py
│ │ ├── displayline.py
│ │ ├── editor.py
│ │ ├── filelines.py
│ │ ├── tokenizers
│ │ │ ├── common.py
│ │ │ ├── plaintext.py
│ │ │ └── python.py
│ │ └── undomanager.py
│ ├── files.py
│ ├── getapps.py
│ ├── icons
│ │ ├── appicons.py
│ │ └── battery.py
│ ├── launcher.py
│ ├── settings.py
│ └── terminal
│ │ ├── __init__.py
│ │ ├── commands.py
│ │ ├── terminal.py
│ │ └── termline.py
├── lib
│ ├── audio
│ │ ├── __init__.py
│ │ └── i2ssound.py
│ ├── battlevel.py
│ ├── display
│ │ ├── __init__.py
│ │ ├── display.py
│ │ ├── displaycore.py
│ │ ├── fancydisplay.py
│ │ ├── namedpalette.py
│ │ ├── palette.py
│ │ ├── rawbitmap.py
│ │ └── st7789.py
│ ├── easing
│ │ ├── back.py
│ │ ├── circ.py
│ │ ├── cubic.py
│ │ ├── quad.py
│ │ └── sine.py
│ ├── hydra
│ │ ├── beeper.py
│ │ ├── color.py
│ │ ├── config.py
│ │ ├── i18n.py
│ │ ├── loader.py
│ │ ├── menu.py
│ │ ├── popup.py
│ │ ├── simpleterminal.py
│ │ ├── statusbar.py
│ │ └── utils.py
│ ├── sdcard
│ │ ├── __init__.py
│ │ ├── mhsdcard.py
│ │ └── sdcard.py
│ ├── userinput
│ │ ├── __init__.py
│ │ └── userinput.py
│ └── zipextractor.py
└── main.py
├── tools
├── bitmaps
│ ├── iconsprites.png
│ ├── image_converter.py
│ └── sprites_converter.py
├── build_device_bin.sh
├── build_mpy_cross.py
├── clean_all.py
├── compile_firmwares.py
├── compile_hydra_mpy.py
├── create_frozen_folders.py
├── generate_default_device.py
├── icons
│ ├── image_to_icon.py
│ └── polygon_to_raw_bmp.py
├── microhydra_build_all.sh
├── parse_files.py
├── quick_format_const.py
├── setup_esp_idf.py
└── utf8_util
│ ├── generate_utf8_font.py
│ ├── guanzhi.ttf
│ ├── guanzhi8x8.bin
│ ├── merge_font_bins.py
│ ├── unifont-16.0.01.otf
│ ├── unifont8x8.bin
│ ├── utf8_8x8.bin
│ └── utf8bin_to_py.py
└── wiki
├── Accessing-config-files.md
├── App-Format.md
├── Device.md
├── Display.md
├── Home.md
├── HydraMenu.md
├── Internet.md
├── MH-Origins.md
├── Palette.md
├── Playing-Sound.md
├── Supported-Devices.md
├── color.md
├── multi-platform.md
├── popup.md
└── userinput.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
3 | *.txt text eol=lf
4 | *.xml text eol=lf
5 | *.json text eol=lf
6 | *.properties text eol=lf
7 | *.conf text eol=lf
8 | *.py text eol=lf
9 | *.sh text eol=lf
10 |
11 | *.png binary
12 | *.jpg binary
13 | *.bin binary
14 | *.zip binary
15 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Update Wiki
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | paths:
9 | - 'wiki/**'
10 | branches:
11 | - main
12 | jobs:
13 | update-wiki:
14 | runs-on: ubuntu-latest
15 | name: Update wiki
16 | steps:
17 | - uses: OrlovM/Wiki-Action@v1
18 | with:
19 | path: 'wiki'
20 | token: ${{ secrets.GITHUB_TOKEN }}
21 |
--------------------------------------------------------------------------------
/.github/workflows/microhydra-build.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3 |
4 | name: MicroHydra Build
5 |
6 | on:
7 | workflow_dispatch:
8 | inputs:
9 | releaseTag:
10 | description: "Tag to use when creating release. If blank, don't create release."
11 | required: false
12 | default: ''
13 | type: string
14 |
15 | permissions:
16 | contents: read
17 |
18 | jobs:
19 | build:
20 |
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 |
26 | - name: Set up Python 3.10
27 | uses: actions/setup-python@v5
28 | with:
29 | python-version: "3.10"
30 |
31 | - name: Install dependencies
32 | run: |
33 | python -m pip install --upgrade pip
34 | python -m pip install pyyaml
35 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
36 |
37 | - name: Run Build Scripts
38 | run: |
39 | git submodule update --init --recursive esp-idf
40 | git submodule update --init MicroPython
41 | ./esp-idf/install.sh
42 | ./tools/microhydra_build_all.sh
43 |
44 | - uses: actions/upload-artifact@v4
45 | with:
46 | name: finished-build
47 | path: |
48 | MicroHydra/*.zip
49 | MicroHydra/*.bin
50 |
51 |
52 |
53 | release:
54 | needs: build
55 | runs-on: ubuntu-latest
56 | permissions:
57 | contents: write
58 |
59 | if: ${{ inputs.releaseTag }}
60 |
61 | steps:
62 | - uses: actions/checkout@v4
63 | - uses: actions/download-artifact@v4
64 | with:
65 | name: finished-build
66 |
67 | - uses: ncipollo/release-action@v1
68 | with:
69 | artifacts: "*.bin,*.zip"
70 | tag: ${{ inputs.releaseTag }}
71 |
72 | commit: main
73 | generateReleaseNotes: true
74 | draft: true
75 |
76 | allowUpdates: true
77 | updateOnlyUnreleased: true
78 | replacesArtifacts: true
79 | omitBodyDuringUpdate: true
80 | omitDraftDuringUpdate: true
81 | omitNameDuringUpdate: true
82 | omitPrereleaseDuringUpdate: true
83 |
84 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build directories
2 | /MicroHydra
3 |
4 | # Local config files
5 | /.vscode
6 | /src/config.json
7 |
8 | # Temporary files
9 | *.bak
10 |
11 | # Python cache files
12 | __pycache__/
13 | tools/__pycache__/
14 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "MicroPython"]
2 | path = MicroPython
3 | url = https://github.com/echo-lalia/microhydra-frozen.git
4 | ignore = dirty
5 | [submodule "esp-idf"]
6 | path = esp-idf
7 | url = https://github.com/espressif/esp-idf.git
8 | ignore = all
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to MicroHydra!
2 |
3 | >**Note:** *These guidelines only apply to the main MicroHydra program. There are no restrictions to the design or functionality of apps for MH.*
4 |
5 |
6 |
7 |
8 | ## Overview on communicating and sharing your changes:
9 |
10 |
11 |
12 | ### - If you'd like to contribute a fix, or enhancement to MicroHydra, consider making an issue for it before getting to work.
13 | This will aide in communication with other contributors, and give a convenient space to discuss the topic.
14 | *Note: If you've already made your changes, feel free to just submit a pull request.*
15 |
16 | ---
17 |
18 | ### - Create a personal fork, and implement your changes
19 | Try keeping it to one topic at a time, and if your changes are somewhat complex, make sure to test as you go.
20 | *Sometimes the behavior can differ when run directly from Thonny, vs running on the device on its own.*
21 |
22 | ---
23 |
24 | ### - Test what you can, and let me know about it
25 | MicroHydra needs to be tested both on a plain MicroPython install, and as a complete firmware.
26 | Changes also need to be tested on multiple devices before they are released in a stable build.
27 |
28 | You aren't expected to do all the testing yourself, but if you can do *some* testing, and let me know what testing you did, it is a huge help!
29 |
30 | > *Don't worry about this too hard if you're making relatively small changes; especially ones that only affect one app. I'll merge it and just test it along with my own changes before releasing the next version.*
31 |
32 | ---
33 |
34 | ### - Consider updating the contents of `wiki/` to reflect your changes
35 | This repo automatically publishes changes made in the `wiki/` directory to the 'wiki' tab on GitHub.
36 | If your changes make some information on the wiki outdated, it would be very helpful for you to update the wiki to communicate your changes, as well.
37 |
38 | ---
39 |
40 | ### - Create a pull request with a summary of your changes, and link the pull request to the issue (if it exists)
41 | Once it's been reviewed, if no further changes need to be made to it, it'll be pulled to the main repo, and the issue will be closed
42 |
43 |
44 |
45 | *If you have any concerns or questions about this workflow, feel free to reach out!*
46 |
47 | ---
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | > *The rest of this guide can probably be ignored for most contributions, but provide further details on MicroHydra's design for those who are interested:*
61 |
62 | ## Guidelines and philosophy for core MicroHydra functionality
63 |
64 |
65 |
66 | ### - One of MicroHydra's earliest goals was accessibility.
67 |
68 | This project aims to encourage tinkerers, makers, and developers, both experienced and completely new, to use and create apps for their Cardputers. The design for the app loading system was created with this in mind.
69 | By only requiring a simple .py file to be placed in the /apps folder, and restarting between apps, MH is able to support even extremely basic MP scripts, with little to no modificaiton. And more complex programs, which require additional libraries or other files, can simply point their fetch those files from a subfolder of the apps folder (or anywhere else).
70 |
71 | Another way MicroHydra aims to be accesible, is by providing a large amount of documentation on the use of the launcher, and on the use of the libraries that come with it.
72 | It is a goal of mine to keep the wiki updated with answers to common questions, and provide examples, and instructions, to help new developers make apps or contributions for MH.
73 |
74 | And a final note on accesibility; This launcher is intended to work on "plain MicroPython". This is important because it minimizes restrictions on the version of MicroPython that MicroHydra can work with, and therefore reduces restrictions on the kinds of apps that can be made for MicroHydra.
75 | For example, if you had an ambitious idea for a game, and you wanted to use a display driver such as [s3lcd](https://github.com/russhughes/s3lcd) *(which is provided as a custom C module in a compiled MicroPython firmware)*.
76 | If MicroHydra was only available as a compiled firmware, you would be unable to combine s3lcd and MicroHydra, without compiling your own MicroPython from source. This could cause new barriers-to-entry, and pontentially prevent some very cool ideas from ever getting started.
77 |
78 | ### - Stability is highly important.
79 |
80 | Another thing that is important for MicroHydra, is it's ability to just work without requiring a ton of technical knowledge, or troubleshooting.
81 | I've abandonded some really cool features for the launcher due to stablity reasons, and will probably do it again in the future. Providing something that is feature-rich and behaves like a real operating system would be very cool, but MicroHydra's primary responsibility is just to start 3rd party apps, and it needs to be good at that.
82 |
83 |
84 |
85 |
86 | ## Code Style
87 |
88 |
89 |
90 | *As mentioned above, MicroHydra was originally created quickly and messily. Not all of these suggestions are fully implemented in existing code, but these are goals for future code in MicroHydra.*
91 |
92 | ### - Tabs or Spaces?
93 | I started MicroHydra with spaces for indentation, as that is the standard method that came with my editor. However, I have come to prefer tabs for [accessibility](https://adamtuttle.codes/blog/2021/tabs-vs-spaces-its-an-accessibility-issue/) reasons.
94 | I was planning on totally replacing all the space-indents with tab characters because of this. However, I stopped myself because there were already contributors working on this project, and I did not want to cause further issues/confusion because of this change. Especially because some people who wish to contribute to MicroHydra are new programmers, and requiring contributors to change their editors settings before working on MH, would just be an additional barrier.
95 | So, for now, I am adopting an intentionally relaxed stance on indentation for MicroHydra. As long as you do not mix tabs/spaces in a single file, you can choose whichever you prefer.
96 |
97 | ### - Naming Conventions
98 | For a quick reference, this is the current (intended) state of MH's variable naming style:
99 | ```
100 | modulename
101 | _NAMED_CONSTANT
102 | GLOBAL_VARIABLE
103 | ClassName
104 | function_name
105 | regular_variable
106 | ```
107 | This is mostly a (simplified) reflection of [PEP 8](https://peps.python.org/pep-0008/) standards. However, with the intentional change of using a leading underscore in the name of a constant. This is because MicroPython supports [*'real'* constants](https://docs.micropython.org/en/latest/develop/optimizations.html#variables) through the use of the `const` keyword + that leading underscore.
108 |
109 |
110 | ### - Comment heavily!
111 | As MicroHydra is intended to be accessible and approachable, I highly recommend using many comments in your code! It takes only a small amount of extra storage, uses no memory at all, and makes your code much more understandable for beginners.
112 | I'm a particular fan of using large, flashy, blocks of comments to split code into sections, and adding comments which explain why one piece of code might be using a different approach than you would first expect.
113 |
--------------------------------------------------------------------------------
/devices/CARDPUTER/definition.yml:
--------------------------------------------------------------------------------
1 | constants:
2 | _MH_BATT_ADC: '10'
3 | _MH_DISPLAY_BACKLIGHT: '38'
4 | _MH_DISPLAY_BAUDRATE: '40_000_000'
5 | _MH_DISPLAY_CS: '37'
6 | _MH_DISPLAY_DC: '34'
7 | _MH_DISPLAY_HEIGHT: '135'
8 | _MH_DISPLAY_MISO: None
9 | _MH_DISPLAY_MOSI: '35'
10 | _MH_DISPLAY_RESET: '33'
11 | _MH_DISPLAY_ROTATION: '1'
12 | _MH_DISPLAY_SCK: '36'
13 | _MH_DISPLAY_SPI_ID: '1'
14 | _MH_DISPLAY_WIDTH: '240'
15 | _MH_I2S_ID: '1'
16 | _MH_I2S_SCK: '41'
17 | _MH_I2S_SD: '42'
18 | _MH_I2S_WS: '43'
19 | _MH_SDCARD_CS: '12'
20 | _MH_SDCARD_MISO: '39'
21 | _MH_SDCARD_MOSI: '14'
22 | _MH_SDCARD_SCK: '40'
23 | _MH_SDCARD_SLOT: '2'
24 |
25 | features:
26 | - keyboard
27 | - display
28 | - i2s_speaker
29 | - pdm_microphone
30 | - ir_blaster
31 | - wifi
32 | - bluetooth
33 |
34 | mpy_arch: xtensawin
35 | source_board: MICROHYDRA_GENERIC_S3
36 |
--------------------------------------------------------------------------------
/devices/CARDPUTER/lib/userinput/_keys.py:
--------------------------------------------------------------------------------
1 | """Read and return keyboard data for the M5Stack Cardputer."""
2 |
3 | from machine import Pin
4 |
5 |
6 | #lookup values for our keyboard
7 | KC_SHIFT = const(61)
8 | KC_FN = const(65)
9 |
10 | KEYMAP = {
11 | 67:'`', 63:'1', 57:'2', 53:'3', 47:'4', 43:'5', 37:'6', 33:'7', 27:'8', 23:'9', 17:'0', 13:'-', 7:'=', 3:'BSPC',
12 |
13 | 66:'TAB',62:'q', 56:'w', 52:'e', 46:'r', 42:'t', 36:'y', 32:'u', 26:'i', 22:'o', 16:'p', 12:'[', 6:']', 2:'\\',
14 |
15 | 65:"FN",61:"SHIFT",55:'a', 51:'s', 45:'d', 41:'f', 35:'g', 31:'h', 25:'j', 21:'k', 15:'l', 11:';', 5:"'", 1:'ENT',
16 |
17 | 64:'CTL',60:'OPT',54:'ALT',50:'z', 44:'x', 40:'c', 34:'v', 30:'b', 24:'n', 20:'m', 14:',', 10:'.', 4:'/', 0:'SPC',
18 | }
19 |
20 | KEYMAP_SHIFT = {
21 | 67:'~', 63:'!', 57:'@', 53:'#', 47:'$', 43:'%', 37:'^', 33:'&', 27:'*', 23:'(', 17:')', 13:'_', 7:'+', 3:'BSPC',
22 |
23 | 66:'TAB',62:'Q', 56:'W', 52:'E', 46:'R', 42:'T', 36:'Y', 32:'U', 26:'I', 22:'O', 16:'P', 12:'{', 6:'}', 2:'|',
24 |
25 | 65:"FN",61:"SHIFT",55:'A', 51:'S', 45:'D', 41:'F', 35:'G', 31:'H', 25:'J', 21:'K', 15:'L', 11:':', 5:'"', 1:'ENT',
26 |
27 | 64:'CTL',60:'OPT',54:'ALT',50:'Z', 44:'X', 40:'C', 34:'V', 30:'B', 24:'N', 20:'M', 14:'<', 10:'>', 4:'?', 0:'SPC',
28 | }
29 |
30 | KEYMAP_FN = {
31 | 67:'ESC',63:'F1', 57:'F2', 53:'F3',47:'F4',43:'F5',37:'F6',33:'F7',27:'F8',23:'F9',17:'F10',13:'_',7:'=', 3:'DEL',
32 |
33 | 66:'TAB',62:'q', 56:'w', 52:'e', 46:'r', 42:'t', 36:'y', 32:'u', 26:'i', 22:'o', 16:'p', 12:'[', 6:']', 2:'\\',
34 |
35 | 65:"FN",61:"SHIFT",55:'a', 51:'s', 45:'d', 41:'f', 35:'g', 31:'h', 25:'j', 21:'k', 15:'l', 11:'UP',5:"'", 1:'ENT',
36 |
37 | 64:'CTL',60:'OPT',54:'ALT',50:'z', 44:'x', 40:'c', 34:'v', 30:'b', 24:'n',20:'m',14:'LEFT',10:'DOWN',4:'RIGHT',0:'SPC', # noqa: E501
38 | }
39 |
40 |
41 | MOD_KEYS = const(('ALT', 'CTL', 'FN', 'SHIFT', 'OPT'))
42 | ALWAYS_NEW_KEYS = const(())
43 |
44 |
45 | class Keys:
46 | """Keys class is responsible for reading and returning currently pressed keys.
47 |
48 | It is intented to be used by the Input module.
49 | """
50 |
51 | # optional values set preferred main/secondary action keys:
52 | main_action = "ENT"
53 | secondary_action = "SPC"
54 | aux_action = "G0"
55 |
56 | ext_dir_dict = {';':'UP', ',':'LEFT', '.':'DOWN', '/':'RIGHT', '`':'ESC'}
57 |
58 | def __init__(self, **kwargs): # noqa: ARG002
59 | self._key_list_buffer = []
60 |
61 | # setup the "G0" button!
62 | self.G0 = Pin(0, Pin.IN, Pin.PULL_UP)
63 |
64 | # setup column pins. These are read as inputs.
65 | c0 = Pin(13, Pin.IN, Pin.PULL_UP)
66 | c1 = Pin(15, Pin.IN, Pin.PULL_UP)
67 | c2 = Pin(3, Pin.IN, Pin.PULL_UP)
68 | c3 = Pin(4, Pin.IN, Pin.PULL_UP)
69 | c4 = Pin(5, Pin.IN, Pin.PULL_UP)
70 | c5 = Pin(6, Pin.IN, Pin.PULL_UP)
71 | c6 = Pin(7, Pin.IN, Pin.PULL_UP)
72 | self.columns = (c6, c5, c4, c3, c2, c1, c0)
73 |
74 | # setup row pins.
75 | # These are given to a 74hc138 "demultiplexer", which lets us turn 3 output pins into 8 outputs (8 rows)
76 | self.a0 = Pin(8, Pin.OUT)
77 | self.a1 = Pin(9, Pin.OUT)
78 | self.a2 = Pin(11, Pin.OUT)
79 |
80 | self.key_state = []
81 |
82 | @micropython.viper
83 | def scan(self): # noqa: ANN202
84 | """Scan through the matrix to see what keys are pressed."""
85 | key_list_buffer = []
86 | self._key_list_buffer = key_list_buffer
87 |
88 | columns = self.columns
89 |
90 | a0 = self.a0
91 | a1 = self.a1
92 | a2 = self.a2
93 |
94 | #this for loop iterates through the 8 rows of our matrix
95 | row_idx = 0
96 | while row_idx < 8:
97 | a0.value(row_idx & 0b001)
98 | a1.value(( row_idx & 0b010 ) >> 1)
99 | a2.value(( row_idx & 0b100 ) >> 2)
100 |
101 | # iterate through each column
102 | col_idx = 0
103 | while col_idx < 7:
104 | if not columns[col_idx].value(): # button pressed
105 | # pack column/row into one integer
106 | key_address = (col_idx * 10) + row_idx
107 | key_list_buffer.append(key_address)
108 |
109 | col_idx += 1
110 |
111 | row_idx += 1
112 |
113 | return key_list_buffer
114 |
115 |
116 | @staticmethod
117 | def ext_dir_keys(keylist) -> list:
118 | """Convert typical (aphanumeric) keys into extended movement-specific keys."""
119 | for idx, key in enumerate(keylist):
120 | if key in Keys.ext_dir_dict:
121 | keylist[idx] = Keys.ext_dir_dict[key]
122 | return keylist
123 |
124 |
125 | def get_pressed_keys(self, *, force_fn=False, force_shift=False) -> list:
126 | """Get a readable list of currently held keys.
127 |
128 | Also, populate self.key_state with current vals.
129 |
130 | Args:
131 | force_fn: (bool)
132 | If true, forces the use of 'FN' key layer
133 | force_shift: (bool)
134 | If True, forces the use of 'SHIFT' key layer
135 | """
136 |
137 | #update our scan results
138 | self.scan()
139 | self.key_state = []
140 |
141 | if self.G0.value() == 0:
142 | self.key_state.append("G0")
143 |
144 | if not self._key_list_buffer and not self.key_state: # if nothing is pressed, we can return an empty list
145 | return self.key_state
146 |
147 | if KC_FN in self._key_list_buffer or force_fn:
148 | for keycode in self._key_list_buffer:
149 | self.key_state.append(KEYMAP_FN[keycode])
150 |
151 | elif KC_SHIFT in self._key_list_buffer or force_shift:
152 | for keycode in self._key_list_buffer:
153 | self.key_state.append(KEYMAP_SHIFT[keycode])
154 |
155 | else:
156 | for keycode in self._key_list_buffer:
157 | self.key_state.append(KEYMAP[keycode])
158 |
159 | return self.key_state
160 |
161 |
--------------------------------------------------------------------------------
/devices/TDECK/definition.yml:
--------------------------------------------------------------------------------
1 | constants:
2 | _MH_BATT_ADC: '4'
3 |
4 | _MH_DISPLAY_BACKLIGHT: '42'
5 | _MH_DISPLAY_BAUDRATE: '80_000_000'
6 | _MH_DISPLAY_CS: '12'
7 | _MH_DISPLAY_DC: '11'
8 | _MH_DISPLAY_HEIGHT: '240'
9 | _MH_DISPLAY_WIDTH: '320'
10 | _MH_DISPLAY_MISO: '38'
11 | _MH_DISPLAY_MOSI: '41'
12 | _MH_DISPLAY_RESET: None
13 | _MH_DISPLAY_ROTATION: '1'
14 | _MH_DISPLAY_SCK: '40'
15 | _MH_DISPLAY_SPI_ID: '1'
16 |
17 | _MH_I2S_ID: '1'
18 | _MH_I2S_SCK: '7'
19 | _MH_I2S_SD: '6'
20 | _MH_I2S_WS: '5'
21 |
22 | _MH_SDCARD_CS: '39'
23 | _MH_SDCARD_MISO: '38'
24 | _MH_SDCARD_MOSI: '41'
25 | _MH_SDCARD_SCK: '40'
26 | _MH_SDCARD_SLOT: '1'
27 |
28 | features:
29 | - keyboard
30 | - display
31 | - i2s_speaker
32 | - pdm_microphone
33 | - touchscreen
34 | - trackball
35 | - wifi
36 | - bluetooth
37 | - kb_light
38 | - spi_ram
39 | - shared_sdcard_spi
40 |
41 | mpy_arch: xtensawin
42 | source_board: MICROHYDRA_GENERIC_S3
43 |
--------------------------------------------------------------------------------
/devices/TDECK/mpconfigboard.cmake:
--------------------------------------------------------------------------------
1 | set(MICROPY_FROZEN_MANIFEST ${MICROPY_BOARD_DIR}/manifest.py)
2 | set(IDF_TARGET esp32s3)
3 |
4 | set(SDKCONFIG_DEFAULTS
5 | boards/sdkconfig.base
6 | ${SDKCONFIG_IDF_VERSION_SPECIFIC}
7 | boards/sdkconfig.usb
8 | boards/sdkconfig.ble
9 | boards/sdkconfig.spiram_sx
10 | boards/MICROHYDRA_GENERIC_S3/sdkconfig.board
11 | )
12 |
13 |
14 | set(SDKCONFIG_DEFAULTS
15 | ${SDKCONFIG_DEFAULTS}
16 | boards/sdkconfig.240mhz
17 | boards/sdkconfig.spiram_oct
18 | )
19 |
20 | list(APPEND MICROPY_DEF_BOARD
21 | MICROPY_HW_BOARD_NAME="MicroHydra on TDeck with Octal-SPIRAM"
22 | )
23 |
24 | if(MICROPY_BOARD_VARIANT STREQUAL "FLASH_4M")
25 | set(SDKCONFIG_DEFAULTS
26 | ${SDKCONFIG_DEFAULTS}
27 | boards/MICROHYDRA_GENERIC_S3/sdkconfig.flash_4m
28 | )
29 | endif()
30 |
--------------------------------------------------------------------------------
/devices/default.yml:
--------------------------------------------------------------------------------
1 | # This file contains MicroHydra defaults that are dynamically generated from
2 | # each DEVICE/definition.yml file.
3 | # This is mainly provided for reference, but also, any values missing from a
4 | # device definition will be loaded from here instead.
5 | #
6 | # 'constants' contains all the existing hydra constants from device definitions,
7 | # plus the most common value.
8 | #
9 | # 'features' contains every single feature that exists in a device definition.
10 |
11 |
12 | constants:
13 | _MH_BATT_ADC: '10'
14 | _MH_DISPLAY_BACKLIGHT: '38'
15 | _MH_DISPLAY_BAUDRATE: '40_000_000'
16 | _MH_DISPLAY_CS: '37'
17 | _MH_DISPLAY_DC: '34'
18 | _MH_DISPLAY_HEIGHT: '135'
19 | _MH_DISPLAY_MISO: None
20 | _MH_DISPLAY_MOSI: '35'
21 | _MH_DISPLAY_RESET: '33'
22 | _MH_DISPLAY_ROTATION: '1'
23 | _MH_DISPLAY_SCK: '36'
24 | _MH_DISPLAY_SPI_ID: '1'
25 | _MH_DISPLAY_WIDTH: '240'
26 | _MH_I2S_ID: '1'
27 | _MH_I2S_SCK: '41'
28 | _MH_I2S_SD: '42'
29 | _MH_I2S_WS: '43'
30 | _MH_SDCARD_CS: '12'
31 | _MH_SDCARD_MISO: '39'
32 | _MH_SDCARD_MOSI: '14'
33 | _MH_SDCARD_SCK: '40'
34 | _MH_SDCARD_SLOT: '2'
35 |
36 | features:
37 | - display
38 | - wifi
39 | - trackball
40 | - pdm_microphone
41 | - touchscreen
42 | - spi_ram
43 | - i2s_speaker
44 | - ir_blaster
45 | - bluetooth
46 | - kb_light
47 | - keyboard
48 | - shared_sdcard_spi
49 |
50 | mpy_arch: xtensawin
51 | source_board: MICROHYDRA_GENERIC_S3
52 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/MH-partitions-8MiB.csv:
--------------------------------------------------------------------------------
1 | # Notes: the offset of the partition table itself is set in
2 | # $IDF_PATH/components/partition_table/Kconfig.projbuild.
3 | # Name, Type, SubType, Offset, Size, Flags
4 | nvs, data, nvs, 0x9000, 0x6000,
5 | phy_init, data, phy, 0xf000, 0x1000,
6 | factory, app, factory, 0x10000, 0x2f0000,
7 | vfs, data, fat, 0x300000,0x500000,
8 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/board.json:
--------------------------------------------------------------------------------
1 | {
2 | "deploy": [
3 | "../deploy_s3.md"
4 | ],
5 | "docs": "",
6 | "features": [
7 | "BLE",
8 | "External Flash",
9 | "External RAM",
10 | "WiFi"
11 | ],
12 | "images": [
13 | "generic_s3.jpg"
14 | ],
15 | "mcu": "esp32s3",
16 | "product": "MicroHydra-ESP32-S3",
17 | "thumbnail": "",
18 | "url": "https://github.com/echo-lalia/MicroHydra",
19 | "vendor": "Espressif",
20 | "variants": {
21 | "SPIRAM_OCT": "Support for Octal-SPIRAM",
22 | "FLASH_4M": "4MiB flash"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/board.md:
--------------------------------------------------------------------------------
1 | The following files are firmware that should work on most ESP32-S3-based
2 | boards with 4/8MiB of flash, including WROOM and MINI modules.
3 |
4 | This firmware supports configurations with and without SPIRAM (also known as
5 | PSRAM) and will auto-detect a connected SPIRAM chip at startup and allocate
6 | the MicroPython heap accordingly. However if your board has Octal SPIRAM, then
7 | use the "spiram-oct" variant.
8 |
9 | If your board has 4MiB flash (including ESP32-S3FH4R2 based ones with embedded flash), then use the "flash-4m" build.
10 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/manifest.py:
--------------------------------------------------------------------------------
1 | include("$(PORT_DIR)/boards/manifest.py")
2 |
3 | package("launcher")
4 | package("font")
5 | package("lib")
6 | module("main.py")
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/mpconfigboard.cmake:
--------------------------------------------------------------------------------
1 | set(MICROPY_FROZEN_MANIFEST ${MICROPY_BOARD_DIR}/manifest.py)
2 | set(IDF_TARGET esp32s3)
3 |
4 | set(SDKCONFIG_DEFAULTS
5 | boards/sdkconfig.base
6 | ${SDKCONFIG_IDF_VERSION_SPECIFIC}
7 | boards/sdkconfig.usb
8 | boards/sdkconfig.ble
9 | boards/sdkconfig.spiram_sx
10 | boards/MICROHYDRA_GENERIC_S3/sdkconfig.board
11 | )
12 |
13 | if(MICROPY_BOARD_VARIANT STREQUAL "SPIRAM_OCT")
14 | set(SDKCONFIG_DEFAULTS
15 | ${SDKCONFIG_DEFAULTS}
16 | boards/sdkconfig.240mhz
17 | boards/sdkconfig.spiram_oct
18 | )
19 |
20 | list(APPEND MICROPY_DEF_BOARD
21 | MICROPY_HW_BOARD_NAME="MicroHydra on ESP32S3 with Octal-SPIRAM"
22 | )
23 | endif()
24 |
25 | if(MICROPY_BOARD_VARIANT STREQUAL "FLASH_4M")
26 | set(SDKCONFIG_DEFAULTS
27 | ${SDKCONFIG_DEFAULTS}
28 | boards/ESP32_GENERIC_S3/sdkconfig.flash_4m
29 | )
30 | endif()
31 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/mpconfigboard.h:
--------------------------------------------------------------------------------
1 | #ifndef MICROPY_HW_BOARD_NAME
2 | // Can be set by mpconfigboard.cmake.
3 | #define MICROPY_HW_BOARD_NAME "MicroHydra on ESP32S3"
4 | #endif
5 | #define MICROPY_HW_MCU_NAME "ESP32S3"
6 |
7 | // Enable UART REPL for modules that have an external USB-UART and don't use native USB.
8 | #define MICROPY_HW_ENABLE_UART_REPL (1)
9 |
10 | #define MICROPY_HW_I2C0_SCL (9)
11 | #define MICROPY_HW_I2C0_SDA (8)
12 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/sdkconfig.board:
--------------------------------------------------------------------------------
1 | CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
2 | CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
3 | CONFIG_ESPTOOLPY_AFTER_NORESET=y
4 |
5 | CONFIG_ESPTOOLPY_FLASHSIZE_4MB=
6 | CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
7 | CONFIG_ESPTOOLPY_FLASHSIZE_16MB=
8 | CONFIG_PARTITION_TABLE_CUSTOM=y
9 | CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="MH-partitions-8MiB.csv"
10 |
--------------------------------------------------------------------------------
/devices/esp32_mpy_build/boards/MICROHYDRA_GENERIC_S3/sdkconfig.flash_4m:
--------------------------------------------------------------------------------
1 | CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
2 | CONFIG_ESPTOOLPY_FLASHSIZE_8MB=
3 |
4 | CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions-4MiB.csv"
5 |
--------------------------------------------------------------------------------
/misc/images/cardputerg0.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/cardputerg0.jpg
--------------------------------------------------------------------------------
/misc/images/releasebin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/releasebin.png
--------------------------------------------------------------------------------
/misc/images/releasecompiled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/releasecompiled.png
--------------------------------------------------------------------------------
/misc/images/releases.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/releases.png
--------------------------------------------------------------------------------
/misc/images/thonnyconfigureinterpreter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyconfigureinterpreter.png
--------------------------------------------------------------------------------
/misc/images/thonnyfiles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyfiles.png
--------------------------------------------------------------------------------
/misc/images/thonnyflashbin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyflashbin.png
--------------------------------------------------------------------------------
/misc/images/thonnyflashsettings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyflashsettings.png
--------------------------------------------------------------------------------
/misc/images/thonnyhamburgermenu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyhamburgermenu.png
--------------------------------------------------------------------------------
/misc/images/thonnyinstallmicropython.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyinstallmicropython.png
--------------------------------------------------------------------------------
/misc/images/thonnyinstallmicropythonwindow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyinstallmicropythonwindow.png
--------------------------------------------------------------------------------
/misc/images/thonnyinterpreteroptions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyinterpreteroptions.png
--------------------------------------------------------------------------------
/misc/images/thonnylocalmicropython.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnylocalmicropython.png
--------------------------------------------------------------------------------
/misc/images/thonnyuploadfiles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyuploadfiles.png
--------------------------------------------------------------------------------
/misc/images/thonnyuploadfiles2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/misc/images/thonnyuploadfiles2.png
--------------------------------------------------------------------------------
/misc/mountSD.py:
--------------------------------------------------------------------------------
1 | from lib.sdcard import SDCard
2 |
3 | """
4 | Use this simple tool to mount your SD card. This is useful for transferring files via USB and for editing apps on the SD card.
5 |
6 | PS: Probably not a good idea to store important files on the SD card because it could easily become corrupted during testing.
7 |
8 | If you're using Thonny to transfer files to and from the device, you probably need to hit "refresh" in the file view to see "/sd" there.
9 |
10 | """
11 |
12 | sd = SDCard()
13 | sd.mount()
14 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.codespell]
2 | count = ""
3 | ignore-regex = '\b[A-Z]{3}\b'
4 | ignore-words-list = "ans,asend,deques,dout,extint,hsi,iput,mis,numer,shft,technic,ure"
5 | quiet-level = 3
6 | skip = """
7 | */build*,\
8 | ./.git,\
9 | ./.gitattributes,\
10 | ./.gitignore,\
11 | ./.gitmodules,\
12 | ./LICENSE,\
13 | ./esp-idf,\
14 | ./MicroHydra,\
15 | ./MicroPython,\
16 | ./src/font,\
17 | """
18 |
19 | [tool.pyright]
20 | # Ruff is handling most of this stuff, so we'll supress some of pyrights warnings.
21 | include = [
22 | "./src",
23 | "./devices",
24 | "./tools",
25 | ]
26 | exclude = [
27 | "./src/font",
28 | ]
29 | ignore = ["*"]
30 |
31 |
32 | [tool.ruff]
33 | # Exclude third-party code from linting and formatting
34 | extend-exclude = ["esp-idf", "./MicroHydra", "MicroPython", "src/font"]
35 | line-length = 120
36 | target-version = "py39"
37 | # Add micropython-specific (and viper-specific) built-in names
38 | builtins = ["const", "micropython", "ptr", "ptr8", "ptr16", "ptr32"]
39 |
40 |
41 | [tool.ruff.lint]
42 | # These linting rules are new and likely to change.
43 | # If you have any suggestions, please feel free to share!
44 | # Currently, linting rules are being selected by just enabling "ALL" and then disabling
45 | # any that don't work well for us. However this might not be the most elegant strategy,
46 | # and might need to be changed in the future.
47 |
48 | # Select all stable linting rules.
49 | select = ["ALL"]
50 | # Ignore every rule that doesn't work for us:
51 | ignore = [
52 | # These exclusions are borrowed from the MicroPython repo:
53 | "E401", # Multiple imports on one line
54 | "E402", # Module level import not at top of cell
55 | "E722", # Do not use bare except
56 | "E731", # Do not assign a lambda expression, use a def
57 | "E741", # Ambiguous variable name
58 | "F401", # imported but unused
59 | "F403", # `from {name} import *` used
60 | "F405", # {name} may be undefined, or defined from star imports
61 |
62 | # These exclusions just arent really relevant (or don't work) in MicroPython:
63 | "PLC1901", # compare-to-empty-string
64 | "FURB101", # open and read should be replaced by Path({filename})
65 | "FURB103", # open and write should be replaced by Path({filename})
66 | "PLW1514", # unspecified-encoding
67 | "FBT003", # boolean-positional-value-in-call
68 | "PIE810", # Call {attr} once with a tuple (str.startswith/endswith)
69 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
70 | "FA", # flake8-future-annotations
71 | "PLW2901", # redefined-loop-name (Limited resources)
72 | "ANN0", # Missing type annotations (No Typing module)
73 | "ANN204", # Missing return type annotation for special method
74 | "PTH", # Use pathlib (No pathlib module)
75 | "INP001", # File is part of an implicit namespace package. (Just a bit unnecessary)
76 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
77 | "RUF005", # collection-literal-concatenation
78 | "FBT001", # A function taking a sinlge bool value is very common.
79 | "PYI", # Mostly irrelivant for MicroPython
80 | "FURB", # Mostly irrelivant for MicroPython
81 |
82 | # MH uses commented out code in the build process:
83 | "ERA", # Found commented-out code
84 |
85 | # This is kinda unstable and also breaks hydra conditionals sometimes.
86 | "I001", # Import block is un-sorted or un-formatted
87 |
88 | # for now, MH takes a soft approach to quote-style.
89 | # You should just try to maintain consistency with surrounding code:
90 | "Q000", # Single quotes found but double quotes preferred
91 |
92 | # Up for debate, but IMO a bit of extra space really helps with dense code blocks:
93 | "E303", # Too many blank lines
94 | "D202", # No blank lines allowed after function docstring
95 |
96 | # MH specifically allows the use of tabs over spaces in its code for accessibility.
97 | # (And spaces are allowed because they are usually the editor default):
98 | "D206", # Docstring should be indented with spaces, not tabs
99 | "W191", # Indentation contains tabs
100 |
101 | # This can sometimes harm performance in MP:
102 | "FURB136", # Replace if expression with {min_max} call
103 | "PLR1730", # ^
104 |
105 | # PEP8 reccomends grouping by order-of-operations
106 | # like: `4*6 + 12*2`
107 | "E226", # Missing whitespace around arithmetic operator
108 |
109 | # This is highly opinionated, but IMO if you are doing this,
110 | # you're probably trying to make the code more readable:
111 | "E702", # Multiple statements on one line (semicolon)
112 | "E701", # Multiple statements on one line (colon)
113 |
114 | # This rule is great for the most part, but there are far too many
115 | # valid exceptions to the rule to this rule in the MH source:
116 | "PLR2004", # Magic value used in comparison
117 |
118 | # These are just kinda overkill:
119 | "D105", # Missing docstring in magic method
120 | "T201", # `print` found
121 |
122 | # This is a good suggestion, but it pops up too often when a trailing comma will actually break something
123 | "COM812", # missing-trailing-comma
124 | ]
125 |
126 |
127 | [tool.ruff.lint.pep8-naming]
128 | # Allow _CONSTANT naming in functions
129 | # ("_" followed by a capital letter, then any other characters, will be ignored)
130 | extend-ignore-names = ["_[ABCDEFGHIJKLMNOPQRSTUVWXYZ]*"]
131 |
132 |
133 | [tool.ruff.lint.mccabe]
134 | # Some MicroPython code unfortunately must be somewhat complex for speed reasons.
135 | max-complexity = 40
136 |
137 |
138 | [tool.ruff.lint.per-file-ignores]
139 | # manifest.py files are evaluated with some global names pre-defined
140 | "**/manifest.py" = ["F821"]
141 |
142 |
143 | [tool.ruff.lint.isort]
144 | # Use 2 lines after imports
145 | lines-after-imports = 2
146 |
147 |
148 | [tool.ruff.lint.pylint]
149 | # for speed reasons, assigning to a var isn't always worth it
150 | max-bool-expr = 20
151 | # Similarly, some MH functions just need lots of args
152 | max-args = 10
153 | # The default value of 6 is too restrictive (It even flags CPython builtins!)
154 | max-returns = 10
155 |
156 | [tool.ruff.lint.flake8-annotations]
157 | suppress-none-returning = true
158 |
159 |
160 | [tool.ruff.lint.pydocstyle]
161 | # Use pep257 docstrings.
162 | convention = "pep257"
163 |
--------------------------------------------------------------------------------
/src/apps/apptemplate.py:
--------------------------------------------------------------------------------
1 | """MicroHydra App Template.
2 |
3 | Version: 1.0
4 |
5 |
6 | This is a basic skeleton for a MicroHydra app, to get you started.
7 |
8 | There is no specific requirement in the way a MicroHydra app must be organized or styled.
9 | The choices made here are based entirely on my own preferences and stylistic whims;
10 | please change anything you'd like to suit your needs
11 | (or ignore this template entirely if you'd rather)
12 |
13 | This template is not intended to enforce a specific style, or to give guidelines on best practices,
14 | it is just intended to provide an easy starting point for learners,
15 | or provide a quick start for anyone that just wants to whip something up.
16 |
17 | Have fun!
18 |
19 | TODO: replace the above description with your own!
20 | """
21 |
22 | import time
23 |
24 | from lib import display, userinput
25 | from lib.hydra import config
26 |
27 |
28 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ _CONSTANTS: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
29 | _MH_DISPLAY_HEIGHT = const(135)
30 | _MH_DISPLAY_WIDTH = const(240)
31 | _DISPLAY_WIDTH_HALF = const(_MH_DISPLAY_WIDTH // 2)
32 |
33 | _CHAR_WIDTH = const(8)
34 | _CHAR_WIDTH_HALF = const(_CHAR_WIDTH // 2)
35 |
36 |
37 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ GLOBAL_OBJECTS: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
38 |
39 | # init object for accessing display
40 | DISPLAY = display.Display()
41 |
42 | # object for accessing microhydra config (Delete if unneeded)
43 | CONFIG = config.Config()
44 |
45 | # object for reading keypresses (or other user input)
46 | INPUT = userinput.UserInput()
47 |
48 |
49 | # --------------------------------------------------------------------------------------------------
50 | # -------------------------------------- function_definitions: -------------------------------------
51 | # --------------------------------------------------------------------------------------------------
52 |
53 | # Add any function definitions you want here
54 | # def hello_world():
55 | # print("Hello world!")
56 |
57 |
58 | # --------------------------------------------------------------------------------------------------
59 | # ---------------------------------------- ClassDefinitions: ---------------------------------------
60 | # --------------------------------------------------------------------------------------------------
61 |
62 | # Add any class definitions you want here
63 | # class Placeholder:
64 | # def __init__(self):
65 | # print("Placeholder")
66 |
67 |
68 | # --------------------------------------------------------------------------------------------------
69 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
70 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Main Loop: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
71 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
72 | def main_loop():
73 | """Run the main loop of the program.
74 |
75 | Runs forever (until program is closed).
76 | """
77 |
78 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ INITIALIZATION: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
80 |
81 | # If you need to do any initial work before starting the loop, this is a decent place to do it.
82 |
83 | # create variable to remember text between loops
84 | current_text = "Hello World!"
85 |
86 |
87 |
88 | while True: # Fill this loop with your program logic! (delete old code you don't need)
89 |
90 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
91 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ INPUT: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
92 |
93 | # put user input logic here
94 |
95 | # get list of newly pressed keys
96 | keys = INPUT.get_new_keys()
97 |
98 | # if there are keys, convert them to a string, and store for display
99 | if keys:
100 | current_text = str(keys)
101 |
102 |
103 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MAIN GRAPHICS: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
105 |
106 | # put graphics rendering logic here
107 |
108 | # clear framebuffer
109 | DISPLAY.fill(CONFIG.palette[2])
110 |
111 | # write current text to framebuffer
112 | DISPLAY.text(
113 | text=current_text,
114 | # center text on x axis:
115 | x=_DISPLAY_WIDTH_HALF - (len(current_text) * _CHAR_WIDTH_HALF),
116 | y=50,
117 | color=CONFIG.palette[8],
118 | )
119 |
120 | # write framebuffer to display
121 | DISPLAY.show()
122 |
123 |
124 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
125 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ HOUSEKEEPING: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
126 |
127 | # anything that needs to be done to prepare for next loop
128 |
129 | # do nothing for 10 milliseconds
130 | time.sleep_ms(10)
131 |
132 |
133 |
134 | # start the main loop
135 | main_loop()
136 |
--------------------------------------------------------------------------------
/src/font/utf8_8x8.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/src/font/utf8_8x8.bin
--------------------------------------------------------------------------------
/src/font/vga1_8x16.py:
--------------------------------------------------------------------------------
1 | """converted from vga_8x16.bin """
2 | WIDTH = 8
3 | HEIGHT = 16
4 | FIRST = 0x20
5 | LAST = 0x7f
6 | _FONT =const(\
7 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
8 | b'\x00\x00\x18\x3c\x3c\x3c\x18\x18\x18\x00\x18\x18\x00\x00\x00\x00'\
9 | b'\x00\x66\x66\x66\x24\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
10 | b'\x00\x00\x00\x6c\x6c\xfe\x6c\x6c\x6c\xfe\x6c\x6c\x00\x00\x00\x00'\
11 | b'\x18\x18\x7c\xc6\xc2\xc0\x7c\x06\x06\x86\xc6\x7c\x18\x18\x00\x00'\
12 | b'\x00\x00\x00\x00\xc2\xc6\x0c\x18\x30\x60\xc6\x86\x00\x00\x00\x00'\
13 | b'\x00\x00\x38\x6c\x6c\x38\x76\xdc\xcc\xcc\xcc\x76\x00\x00\x00\x00'\
14 | b'\x00\x30\x30\x30\x60\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
15 | b'\x00\x00\x0c\x18\x30\x30\x30\x30\x30\x30\x18\x0c\x00\x00\x00\x00'\
16 | b'\x00\x00\x30\x18\x0c\x0c\x0c\x0c\x0c\x0c\x18\x30\x00\x00\x00\x00'\
17 | b'\x00\x00\x00\x00\x00\x66\x3c\xff\x3c\x66\x00\x00\x00\x00\x00\x00'\
18 | b'\x00\x00\x00\x00\x00\x18\x18\x7e\x18\x18\x00\x00\x00\x00\x00\x00'\
19 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x18\x18\x30\x00\x00\x00'\
20 | b'\x00\x00\x00\x00\x00\x00\x00\xfe\x00\x00\x00\x00\x00\x00\x00\x00'\
21 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x18\x00\x00\x00\x00'\
22 | b'\x00\x00\x00\x00\x02\x06\x0c\x18\x30\x60\xc0\x80\x00\x00\x00\x00'\
23 | b'\x00\x00\x38\x6c\xc6\xc6\xd6\xd6\xc6\xc6\x6c\x38\x00\x00\x00\x00'\
24 | b'\x00\x00\x18\x38\x78\x18\x18\x18\x18\x18\x18\x7e\x00\x00\x00\x00'\
25 | b'\x00\x00\x7c\xc6\x06\x0c\x18\x30\x60\xc0\xc6\xfe\x00\x00\x00\x00'\
26 | b'\x00\x00\x7c\xc6\x06\x06\x3c\x06\x06\x06\xc6\x7c\x00\x00\x00\x00'\
27 | b'\x00\x00\x0c\x1c\x3c\x6c\xcc\xfe\x0c\x0c\x0c\x1e\x00\x00\x00\x00'\
28 | b'\x00\x00\xfe\xc0\xc0\xc0\xfc\x06\x06\x06\xc6\x7c\x00\x00\x00\x00'\
29 | b'\x00\x00\x38\x60\xc0\xc0\xfc\xc6\xc6\xc6\xc6\x7c\x00\x00\x00\x00'\
30 | b'\x00\x00\xfe\xc6\x06\x06\x0c\x18\x30\x30\x30\x30\x00\x00\x00\x00'\
31 | b'\x00\x00\x7c\xc6\xc6\xc6\x7c\xc6\xc6\xc6\xc6\x7c\x00\x00\x00\x00'\
32 | b'\x00\x00\x7c\xc6\xc6\xc6\x7e\x06\x06\x06\x0c\x78\x00\x00\x00\x00'\
33 | b'\x00\x00\x00\x00\x18\x18\x00\x00\x00\x18\x18\x00\x00\x00\x00\x00'\
34 | b'\x00\x00\x00\x00\x18\x18\x00\x00\x00\x18\x18\x30\x00\x00\x00\x00'\
35 | b'\x00\x00\x00\x06\x0c\x18\x30\x60\x30\x18\x0c\x06\x00\x00\x00\x00'\
36 | b'\x00\x00\x00\x00\x00\x7e\x00\x00\x7e\x00\x00\x00\x00\x00\x00\x00'\
37 | b'\x00\x00\x00\x60\x30\x18\x0c\x06\x0c\x18\x30\x60\x00\x00\x00\x00'\
38 | b'\x00\x00\x7c\xc6\xc6\x0c\x18\x18\x18\x00\x18\x18\x00\x00\x00\x00'\
39 | b'\x00\x00\x00\x7c\xc6\xc6\xde\xde\xde\xdc\xc0\x7c\x00\x00\x00\x00'\
40 | b'\x00\x00\x10\x38\x6c\xc6\xc6\xfe\xc6\xc6\xc6\xc6\x00\x00\x00\x00'\
41 | b'\x00\x00\xfc\x66\x66\x66\x7c\x66\x66\x66\x66\xfc\x00\x00\x00\x00'\
42 | b'\x00\x00\x3c\x66\xc2\xc0\xc0\xc0\xc0\xc2\x66\x3c\x00\x00\x00\x00'\
43 | b'\x00\x00\xf8\x6c\x66\x66\x66\x66\x66\x66\x6c\xf8\x00\x00\x00\x00'\
44 | b'\x00\x00\xfe\x66\x62\x68\x78\x68\x60\x62\x66\xfe\x00\x00\x00\x00'\
45 | b'\x00\x00\xfe\x66\x62\x68\x78\x68\x60\x60\x60\xf0\x00\x00\x00\x00'\
46 | b'\x00\x00\x3c\x66\xc2\xc0\xc0\xde\xc6\xc6\x66\x3a\x00\x00\x00\x00'\
47 | b'\x00\x00\xc6\xc6\xc6\xc6\xfe\xc6\xc6\xc6\xc6\xc6\x00\x00\x00\x00'\
48 | b'\x00\x00\x3c\x18\x18\x18\x18\x18\x18\x18\x18\x3c\x00\x00\x00\x00'\
49 | b'\x00\x00\x1e\x0c\x0c\x0c\x0c\x0c\xcc\xcc\xcc\x78\x00\x00\x00\x00'\
50 | b'\x00\x00\xe6\x66\x66\x6c\x78\x78\x6c\x66\x66\xe6\x00\x00\x00\x00'\
51 | b'\x00\x00\xf0\x60\x60\x60\x60\x60\x60\x62\x66\xfe\x00\x00\x00\x00'\
52 | b'\x00\x00\xc6\xee\xfe\xfe\xd6\xc6\xc6\xc6\xc6\xc6\x00\x00\x00\x00'\
53 | b'\x00\x00\xc6\xe6\xf6\xfe\xde\xce\xc6\xc6\xc6\xc6\x00\x00\x00\x00'\
54 | b'\x00\x00\x7c\xc6\xc6\xc6\xc6\xc6\xc6\xc6\xc6\x7c\x00\x00\x00\x00'\
55 | b'\x00\x00\xfc\x66\x66\x66\x7c\x60\x60\x60\x60\xf0\x00\x00\x00\x00'\
56 | b'\x00\x00\x7c\xc6\xc6\xc6\xc6\xc6\xc6\xd6\xde\x7c\x0c\x0e\x00\x00'\
57 | b'\x00\x00\xfc\x66\x66\x66\x7c\x6c\x66\x66\x66\xe6\x00\x00\x00\x00'\
58 | b'\x00\x00\x7c\xc6\xc6\x60\x38\x0c\x06\xc6\xc6\x7c\x00\x00\x00\x00'\
59 | b'\x00\x00\x7e\x7e\x5a\x18\x18\x18\x18\x18\x18\x3c\x00\x00\x00\x00'\
60 | b'\x00\x00\xc6\xc6\xc6\xc6\xc6\xc6\xc6\xc6\xc6\x7c\x00\x00\x00\x00'\
61 | b'\x00\x00\xc6\xc6\xc6\xc6\xc6\xc6\xc6\x6c\x38\x10\x00\x00\x00\x00'\
62 | b'\x00\x00\xc6\xc6\xc6\xc6\xd6\xd6\xd6\xfe\xee\x6c\x00\x00\x00\x00'\
63 | b'\x00\x00\xc6\xc6\x6c\x7c\x38\x38\x7c\x6c\xc6\xc6\x00\x00\x00\x00'\
64 | b'\x00\x00\x66\x66\x66\x66\x3c\x18\x18\x18\x18\x3c\x00\x00\x00\x00'\
65 | b'\x00\x00\xfe\xc6\x86\x0c\x18\x30\x60\xc2\xc6\xfe\x00\x00\x00\x00'\
66 | b'\x00\x00\x3c\x30\x30\x30\x30\x30\x30\x30\x30\x3c\x00\x00\x00\x00'\
67 | b'\x00\x00\x00\x80\xc0\xe0\x70\x38\x1c\x0e\x06\x02\x00\x00\x00\x00'\
68 | b'\x00\x00\x3c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x3c\x00\x00\x00\x00'\
69 | b'\x10\x38\x6c\xc6\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
70 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00\x00'\
71 | b'\x00\x30\x18\x0c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
72 | b'\x00\x00\x00\x00\x00\x78\x0c\x7c\xcc\xcc\xcc\x76\x00\x00\x00\x00'\
73 | b'\x00\x00\xe0\x60\x60\x78\x6c\x66\x66\x66\x66\x7c\x00\x00\x00\x00'\
74 | b'\x00\x00\x00\x00\x00\x7c\xc6\xc0\xc0\xc0\xc6\x7c\x00\x00\x00\x00'\
75 | b'\x00\x00\x1c\x0c\x0c\x3c\x6c\xcc\xcc\xcc\xcc\x76\x00\x00\x00\x00'\
76 | b'\x00\x00\x00\x00\x00\x7c\xc6\xfe\xc0\xc0\xc6\x7c\x00\x00\x00\x00'\
77 | b'\x00\x00\x1c\x36\x32\x30\x78\x30\x30\x30\x30\x78\x00\x00\x00\x00'\
78 | b'\x00\x00\x00\x00\x00\x76\xcc\xcc\xcc\xcc\xcc\x7c\x0c\xcc\x78\x00'\
79 | b'\x00\x00\xe0\x60\x60\x6c\x76\x66\x66\x66\x66\xe6\x00\x00\x00\x00'\
80 | b'\x00\x00\x18\x18\x00\x38\x18\x18\x18\x18\x18\x3c\x00\x00\x00\x00'\
81 | b'\x00\x00\x06\x06\x00\x0e\x06\x06\x06\x06\x06\x06\x66\x66\x3c\x00'\
82 | b'\x00\x00\xe0\x60\x60\x66\x6c\x78\x78\x6c\x66\xe6\x00\x00\x00\x00'\
83 | b'\x00\x00\x38\x18\x18\x18\x18\x18\x18\x18\x18\x3c\x00\x00\x00\x00'\
84 | b'\x00\x00\x00\x00\x00\xec\xfe\xd6\xd6\xd6\xd6\xc6\x00\x00\x00\x00'\
85 | b'\x00\x00\x00\x00\x00\xdc\x66\x66\x66\x66\x66\x66\x00\x00\x00\x00'\
86 | b'\x00\x00\x00\x00\x00\x7c\xc6\xc6\xc6\xc6\xc6\x7c\x00\x00\x00\x00'\
87 | b'\x00\x00\x00\x00\x00\xdc\x66\x66\x66\x66\x66\x7c\x60\x60\xf0\x00'\
88 | b'\x00\x00\x00\x00\x00\x76\xcc\xcc\xcc\xcc\xcc\x7c\x0c\x0c\x1e\x00'\
89 | b'\x00\x00\x00\x00\x00\xdc\x76\x66\x60\x60\x60\xf0\x00\x00\x00\x00'\
90 | b'\x00\x00\x00\x00\x00\x7c\xc6\x60\x38\x0c\xc6\x7c\x00\x00\x00\x00'\
91 | b'\x00\x00\x10\x30\x30\xfc\x30\x30\x30\x30\x36\x1c\x00\x00\x00\x00'\
92 | b'\x00\x00\x00\x00\x00\xcc\xcc\xcc\xcc\xcc\xcc\x76\x00\x00\x00\x00'\
93 | b'\x00\x00\x00\x00\x00\xc6\xc6\xc6\xc6\xc6\x6c\x38\x00\x00\x00\x00'\
94 | b'\x00\x00\x00\x00\x00\xc6\xc6\xd6\xd6\xd6\xfe\x6c\x00\x00\x00\x00'\
95 | b'\x00\x00\x00\x00\x00\xc6\x6c\x38\x38\x38\x6c\xc6\x00\x00\x00\x00'\
96 | b'\x00\x00\x00\x00\x00\xc6\xc6\xc6\xc6\xc6\xc6\x7e\x06\x0c\xf8\x00'\
97 | b'\x00\x00\x00\x00\x00\xfe\xcc\x18\x30\x60\xc6\xfe\x00\x00\x00\x00'\
98 | b'\x00\x00\x0e\x18\x18\x18\x70\x18\x18\x18\x18\x0e\x00\x00\x00\x00'\
99 | b'\x00\x00\x18\x18\x18\x18\x18\x18\x18\x18\x18\x18\x00\x00\x00\x00'\
100 | b'\x00\x00\x70\x18\x18\x18\x0e\x18\x18\x18\x18\x70\x00\x00\x00\x00'\
101 | b'\x00\x76\xdc\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
102 | b'\x00\x00\x00\x00\x10\x38\x6c\xc6\xc6\xc6\xfe\x00\x00\x00\x00\x00')
103 |
104 | FONT = memoryview(_FONT)
105 |
--------------------------------------------------------------------------------
/src/launcher/editor/__init__.py:
--------------------------------------------------------------------------------
1 | """Launch the text editor."""
2 | from .editor import Editor
3 |
--------------------------------------------------------------------------------
/src/launcher/editor/displayline.py:
--------------------------------------------------------------------------------
1 | """DisplayLine holds pre-styled tokens for fast redrawing of text."""
2 | if __name__ == '__main__': from launcher import editor # relative import for testing
3 |
4 |
5 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Constants: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6 | _MH_DISPLAY_HEIGHT = const(135)
7 | _MH_DISPLAY_WIDTH = const(240)
8 |
9 | _FONT_HEIGHT = const(8)
10 | _FONT_WIDTH = const(8)
11 |
12 | _LINE_PADDING = const(2)
13 | _FULL_LINE_HEIGHT = const(_LINE_PADDING + _FONT_HEIGHT)
14 | _LINE_BG_HEIGHT = const(_FULL_LINE_HEIGHT - 1)
15 | _LINE_TEXT_OFFSET = const(_LINE_PADDING // 2)
16 |
17 |
18 | _LEFT_PADDING = const(4)
19 | _LEFT_MARGIN = const(_LEFT_PADDING - 2)
20 |
21 | _UNDERLINE_PADDING_L = const(_LEFT_MARGIN)
22 | _UNDERLINE_PADDING_R = const(18)
23 | _UNDERLINE_WIDTH = const(_MH_DISPLAY_WIDTH - _UNDERLINE_PADDING_L - _UNDERLINE_PADDING_R)
24 |
25 |
26 | # rare whitespace char is repurposed here to denote converted tab/space indents
27 | _INDENT_SYM = const(' ') # noqa: RUF001
28 |
29 |
30 |
31 | class DisplayLine:
32 | """Holds tokenized lines for display."""
33 |
34 | tokenizer = None
35 |
36 | def __init__(self, text: str):
37 | """Tokenize and store the given text."""
38 | self.tokens = DisplayLine.tokenizer.tokenize(text)
39 | # Store indentation x-offsets for quick drawing
40 | self.indents = self._get_indents(text)
41 |
42 |
43 | @staticmethod
44 | def _get_indents(text: str) -> list[int]:
45 | return [idx * _FONT_WIDTH + _LEFT_MARGIN for idx, char in enumerate(text) if char == _INDENT_SYM]
46 |
47 |
48 | def draw(self, display, x:int, y:int, *, selected: bool = False, highlight: None|tuple = None):
49 | """Draw all the tokens in this line.
50 |
51 | Args:
52 | - display (Display):
53 | The display object to draw to.
54 | - x (int):
55 | The x position to start the line at.
56 | - y (int):
57 | The y position to start the line at.
58 | - selected (bool):
59 | Whether or not this is the currently active line.
60 | - highlight (tuple[int, int] | None):
61 | If provided, should be a 2-tuple of the start/end text indicies to highlight.
62 | """
63 | # Blackout line
64 | if selected:
65 | display.rect(0, y, _MH_DISPLAY_WIDTH, _FULL_LINE_HEIGHT, display.palette[1], fill=True)
66 | else:
67 | display.rect(0, y, _MH_DISPLAY_WIDTH, _FULL_LINE_HEIGHT, display.palette[2], fill=True)
68 |
69 | # Draw highlights
70 | if highlight:
71 | highlight_x = x + highlight[0] * _FONT_WIDTH
72 | highlight_width = (x + highlight[1] * _FONT_WIDTH) - highlight_x
73 | display.rect(
74 | highlight_x + _LEFT_PADDING,
75 | y,
76 | highlight_width,
77 | _FULL_LINE_HEIGHT,
78 | display.palette[13],
79 | fill=True,
80 | )
81 |
82 |
83 | # Draw indentation
84 | for x_offset in self.indents:
85 | display.vline(
86 | x_offset + x,
87 | y,
88 | _FULL_LINE_HEIGHT,
89 | display.palette[3 if selected else 0],
90 | )
91 |
92 |
93 | y += _LINE_TEXT_OFFSET
94 | x += _LEFT_PADDING
95 |
96 | # Draw each token
97 | for token in self.tokens:
98 | display.text(token.text, x, y, token.color)
99 | x += len(token.text) * _FONT_WIDTH
100 |
--------------------------------------------------------------------------------
/src/launcher/editor/tokenizers/common.py:
--------------------------------------------------------------------------------
1 | """Common items for tokenizer modules."""
2 | from collections import namedtuple
3 |
4 |
5 |
6 | token = namedtuple('token', ('text', 'color'))
7 |
--------------------------------------------------------------------------------
/src/launcher/editor/tokenizers/plaintext.py:
--------------------------------------------------------------------------------
1 | """A plain tokenizer that simply returns all text as a default token."""
2 | from .common import *
3 |
4 |
5 |
6 | _FONT_WIDTH = const(8)
7 |
8 |
9 | COLORS = {}
10 |
11 |
12 | def init(config):
13 | """Initialize tokenizer."""
14 | COLORS['default'] = config.palette[8]
15 |
16 |
17 | def tokenize(line: str) -> tuple[int, str]:
18 | """Split/style text into a list of styled tokens."""
19 | return [token(line, COLORS['default'])]
20 |
--------------------------------------------------------------------------------
/src/launcher/editor/undomanager.py:
--------------------------------------------------------------------------------
1 | """Class for recording and undoing/redoing editor actions."""
2 | from collections import namedtuple
3 |
4 |
5 | _UNDO_STEPS = const(5)
6 |
7 |
8 | # Container for detailing undo/redo steps to be replayed.
9 | Step = namedtuple("Step", ("action", "value", "cursor_x", "cursor_y"))
10 |
11 |
12 |
13 | class UndoManager:
14 | """Record and replay editor actions."""
15 |
16 | def __init__(self, editor, cursor):
17 | """Initialize the undo manager with the editor and main cursor."""
18 | self.editor = editor
19 | self.cursor = cursor
20 | self.undo_steps = []
21 | self.redo_steps = []
22 |
23 |
24 | def record(self, action: str, value: str, cursor=None):
25 | """Record an undo step."""
26 | last_undo_step = self.undo_steps[-1] if self.undo_steps else None
27 | if cursor is None:
28 | cursor = self.cursor
29 |
30 | # If this action is the same as the last action, we may be able to combine them.
31 | if (last_undo_step is not None and action == last_undo_step.action
32 | # But only if the cursor has not moved:
33 | and last_undo_step.cursor_y == cursor.y
34 | and ((action == "insert" and last_undo_step.cursor_x == cursor.x + 1)
35 | or (action == "backspace" and last_undo_step.cursor_x == cursor.x - 1))
36 | # And only if there are no line breaks in either step:
37 | and "\n" not in value and "\n" not in last_undo_step.value):
38 |
39 | self.undo_steps[-1] = Step(
40 | action,
41 | # append or prepend depending on the action we are doing:
42 | last_undo_step.value + value if action == "backspace" else value + last_undo_step.value,
43 | cursor.x,
44 | cursor.y,
45 | )
46 |
47 | # Otherwise, just add a new undo step like normal:
48 | else:
49 | self.undo_steps.append(
50 | Step(action, value, cursor.x, cursor.y),
51 | )
52 |
53 | # Maintain undo step max length
54 | if len(self.undo_steps) > _UNDO_STEPS:
55 | self.undo_steps.pop(0)
56 | # Don't keep outdated redo-steps
57 | if self.redo_steps:
58 | self.redo_steps = []
59 |
60 |
61 | def _undo_redo(self, source_record: list, dest_record: list):
62 | """Do both undo and redo actions.
63 |
64 | source_record is the list with the action to replay,
65 | dest_record is the other list, where the replayed action will be moved.
66 | (ex: when undoing, source_record = self.undo_steps, and dest_record = self.redo_steps).
67 | """
68 | if not source_record:
69 | # Do nothing if there are no undo/redo steps to replay.
70 | return
71 |
72 | # Get the next undo/redo step to perform
73 | recorded_step = source_record.pop(-1)
74 |
75 | # Move cursor to correct/recorded location
76 | self.cursor.x = recorded_step.cursor_x
77 | self.cursor.y = recorded_step.cursor_y
78 | self.cursor.clamp_to_text(self.editor.lines)
79 |
80 | # perform recorded action,
81 | # inverting it into a new undo/redo step in the dest_record.
82 | if recorded_step.action == "insert":
83 | # Insert each character
84 | for char in recorded_step.value:
85 | self.editor.lines.insert(char, self.cursor)
86 | # Create a new redo action that reverses this change
87 | dest_record.append(
88 | Step(
89 | "backspace",
90 | recorded_step.value,
91 | self.cursor.x,
92 | self.cursor.y,
93 | ),
94 | )
95 |
96 | else: # action == "backspace"
97 | # Backspace each character
98 | for _ in recorded_step.value:
99 | self.editor.lines.backspace(self.cursor)
100 | # Create a new redo action that reverses this change
101 | dest_record.append(
102 | Step(
103 | "insert",
104 | recorded_step.value,
105 | self.cursor.x,
106 | self.cursor.y,
107 | ),
108 | )
109 |
110 |
111 | def undo(self):
112 | """Undo the previous action, converting the undo step into a redo step."""
113 | self._undo_redo(self.undo_steps, self.redo_steps)
114 |
115 |
116 | def redo(self):
117 | """Redo the last undo, converting the redo into an undo."""
118 | self._undo_redo(self.redo_steps, self.undo_steps)
119 |
--------------------------------------------------------------------------------
/src/launcher/icons/appicons.py:
--------------------------------------------------------------------------------
1 | BITMAPS = 7
2 | HEIGHT = 32
3 | WIDTH = 32
4 | COLORS = 2
5 | BITS = 7168
6 | BPP = 1
7 | PALETTE = [0xffdf,0x0000]
8 | _bitmap =\
9 | b'\x0f\xff\xff\xf0\x3f\xff\xff\xfc\x7f\xff\xff\xfe\x7f\xff\xff\xfe'\
10 | b'\xfc\x00\x00\x3f\xf8\x00\x00\x1f\xf0\x00\x00\x0f\xf1\xff\xff\x8f'\
11 | b'\xf1\x00\x00\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f'\
12 | b'\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f'\
13 | b'\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f'\
14 | b'\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x7f\xfe\x8f\xf1\x00\x00\x8f'\
15 | b'\xf1\xff\xff\x8f\xf0\x00\x00\x0f\xf8\x00\x00\x1f\xfc\x00\x00\x3f'\
16 | b'\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x3f\xff\xff\xfc\x0f\xff\xff\xf0'\
17 | b'\x03\xff\xff\xf0\x07\xff\x3c\xf8\x06\x49\x24\x98\x06\x49\x24\x98'\
18 | b'\x06\x49\x24\x98\x06\x49\x24\x98\x06\x49\x24\x98\x07\xff\xff\xf8'\
19 | b'\x0f\xff\xff\xf8\x1f\xff\xff\xf8\x3f\xff\xff\xf8\x3f\xff\xff\xf8'\
20 | b'\x3f\xff\xff\xf8\x3f\xff\xff\xf8\x1f\xff\xff\xf8\x0f\xff\xff\xf8'\
21 | b'\x0f\xff\xff\xf8\x1f\xff\xff\xf8\x3f\xff\xff\xf8\x3f\xff\xff\xf8'\
22 | b'\x3f\xff\xff\xf8\x3f\xff\xff\xf8\x3f\xff\xff\xf8\x3f\xff\xff\xf8'\
23 | b'\x3e\x23\xff\xf8\x3d\xe9\xff\xf8\x3c\x2d\xff\xf8\x3f\xa9\xff\xf8'\
24 | b'\x3c\x63\xff\xf8\x3f\xff\xff\xf8\x3f\xff\xff\xf8\x1f\xff\xff\xf0'\
25 | b'\x00\x01\x80\x00\x00\x03\xc0\x00\x00\xc3\xc3\x00\x01\xe7\xe7\x80'\
26 | b'\x01\xff\xff\x80\x00\xff\xff\x00\x00\xff\xff\x00\x19\xff\xff\x98'\
27 | b'\x3f\xfc\x3f\xfc\x3f\xf8\x0f\xfc\x1f\xdc\x03\xf8\x0f\xcc\x03\xf0'\
28 | b'\x0f\x86\x01\xf0\x1f\x87\x01\xf8\x7f\x03\x80\xfe\xff\x01\xff\xff'\
29 | b'\xff\x01\xff\xff\x7f\x03\x80\xfe\x1f\x87\x01\xf8\x0f\x86\x01\xf0'\
30 | b'\x0f\xcc\x03\xf0\x1f\xdc\x03\xf8\x3f\xf8\x0f\xfc\x3f\xfc\x3f\xfc'\
31 | b'\x19\xff\xff\x98\x00\xff\xff\x00\x00\xff\xff\x00\x01\xff\xff\x80'\
32 | b'\x01\xe7\xe7\x80\x00\xc3\xc3\x00\x00\x03\xc0\x00\x00\x01\x80\x00'\
33 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\xe0\x00\x00\x3f\xfc\x00'\
34 | b'\x00\xff\xff\x00\x01\xf8\x1f\x80\x03\xc0\x03\xc0\x07\x80\x01\xe0'\
35 | b'\x0f\x00\x00\xf0\x0e\x00\x00\x70\x1e\x00\x00\x78\x1c\x00\x00\x38'\
36 | b'\x1c\x00\x00\x38\x38\x00\x00\x1c\x38\x00\x00\x1c\x38\x00\x00\x1c'\
37 | b'\x38\x00\x10\x1c\x38\x00\x18\x3c\x38\x00\x1c\x3c\x1c\x00\x1e\x78'\
38 | b'\x1c\x00\x1f\xf8\x1e\x00\x1f\xf8\x0e\x00\x1f\xf0\x0f\x00\x1f\xf0'\
39 | b'\x07\x80\x1f\xf0\x03\xe0\x1f\xf8\x01\xf8\x1f\xfc\x00\xf8\x1f\xfe'\
40 | b'\x00\x38\x1f\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
41 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x3f\xc0\x00\x00\x7f\xe0\x00\x00'\
42 | b'\x7f\xf0\x00\x00\x7f\xff\xff\xf8\x7f\xff\xff\xfc\x7f\xff\xff\xfc'\
43 | b'\x7f\xff\xff\xfc\x78\x00\x00\x00\x77\xff\xff\xff\x77\xff\xff\xff'\
44 | b'\x77\xff\xff\xff\x77\xff\xff\xff\x6f\xff\xff\xff\x6f\xff\xff\xfe'\
45 | b'\x6f\xff\xff\xfe\x6f\xff\xff\xfe\x6f\xff\xff\xfe\x6f\xff\xff\xfe'\
46 | b'\x5f\xff\xff\xfc\x5f\xff\xff\xfc\x5f\xff\xff\xfc\x5f\xff\xff\xfc'\
47 | b'\x7f\xff\xff\xfc\x7f\xff\xff\xfc\x7f\xff\xff\xf8\x7f\xff\xff\xf8'\
48 | b'\x7f\xff\xff\xf8\x3f\xff\xff\xf0\x00\x00\x00\x00\x00\x00\x00\x00'\
49 | b'\x0f\xff\xff\xf0\x3f\xff\xff\xfc\x7f\xff\xff\xfe\x7f\xff\xff\xfe'\
50 | b'\xf8\x00\x00\x1f\xf0\x00\x00\x0f\xf1\x9d\x98\x0f\xf3\xfd\xdf\x0f'\
51 | b'\xf0\x00\x00\x0f\xf3\xf6\x7b\x8f\xf3\xff\xfb\xcf\xf0\x00\x00\x0f'\
52 | b'\xf3\xb0\x90\x0f\xf3\xbf\xfc\x0f\xf0\x00\x00\x0f\xf1\xb0\x00\x0f'\
53 | b'\xf3\xfc\x00\x0f\xf0\x00\x00\x0f\xff\xff\xff\xff\xff\xff\xff\xff'\
54 | b'\xfd\xff\xff\xff\xf8\x7f\xff\xff\xf7\xff\xff\xff\xf8\xff\xff\xff'\
55 | b'\xff\x7f\xff\xff\xf0\xff\xff\xff\xfd\xe0\xff\xff\xff\xff\xff\xff'\
56 | b'\x7f\xff\xff\xfe\x7f\xff\xff\xfe\x3f\xff\xff\xfc\x0f\xff\xff\xf0'\
57 | b'\x00\x03\xc0\x00\x00\x0f\xf0\x00\x00\x0f\xf8\x00\x00\x07\xfc\x00'\
58 | b'\x00\x63\xfe\x00\x00\xf0\x3f\x00\x01\xf8\x1f\x80\x03\xf8\x1f\xc0'\
59 | b'\x07\xf8\x1f\xe0\x0f\xf8\x1f\xf0\x1f\xfc\x0f\xf8\x3f\xfe\x47\xfc'\
60 | b'\x7f\xfe\x63\xfe\x7f\xfe\x70\x3e\xff\xfe\x78\x1f\xff\xfe\x78\x1f'\
61 | b'\xff\xfe\x78\x1f\xff\xfe\x78\x1f\x7f\xfe\x7c\x3e\x7f\xfe\x7f\xfe'\
62 | b'\x3f\xfe\x7f\xfc\x1f\xfe\x7f\xf8\x0f\xfe\x7f\xf0\x07\xe6\x67\xe0'\
63 | b'\x03\xf0\x0f\xc0\x01\xf8\x1f\x80\x00\xfc\x3f\x00\x00\x7e\x7e\x00'\
64 | b'\x00\x3f\xfc\x00\x00\x1f\xf8\x00\x00\x0f\xf0\x00\x00\x03\xc0\x00'
65 | BITMAP = memoryview(_bitmap)
66 |
--------------------------------------------------------------------------------
/src/launcher/icons/battery.py:
--------------------------------------------------------------------------------
1 | BITMAPS = 4
2 | HEIGHT = 10
3 | WIDTH = 20
4 | COLORS = 2
5 | BITS = 800
6 | BPP = 1
7 | PALETTE = [0xffff,0x0000]
8 | _bitmap =\
9 | b'\xff\xff\xc8\x00\x04\x80\x00\x48\x00\x07\x80\x00\x18\x00\x01\x80'\
10 | b'\x00\x78\x00\x04\x80\x00\x4f\xff\xfc\xff\xff\xcf\xc0\x04\xfc\x00'\
11 | b'\x4f\xc0\x07\xfc\x00\x1f\xc0\x01\xfc\x00\x7f\xc0\x04\xfc\x00\x4f'\
12 | b'\xff\xfc\xff\xff\xcf\xff\x84\xff\xf8\x4f\xff\x87\xff\xf8\x1f\xff'\
13 | b'\x81\xff\xf8\x7f\xff\x84\xff\xf8\x4f\xff\xfc\xff\xff\xcf\xff\xfc'\
14 | b'\xff\xff\xcf\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfc\xff'\
15 | b'\xff\xcf\xff\xfc'
16 | BITMAP = memoryview(_bitmap)
--------------------------------------------------------------------------------
/src/launcher/settings.py:
--------------------------------------------------------------------------------
1 | """Settings app for MicroHydra.
2 |
3 | This app provides a useful GUI for changing the values in /config.json
4 | """
5 |
6 | import json
7 | import os
8 | import time
9 |
10 | import machine
11 |
12 | from lib import userinput
13 | from lib.display import Display
14 | from lib.hydra import config
15 | from lib.hydra import menu as hydramenu
16 | from lib.hydra.i18n import I18n
17 | from lib.hydra.popup import UIOverlay
18 | from lib.sdcard import SDCard
19 |
20 |
21 | # make the animations smooth :)
22 | machine.freq(240_000_000)
23 |
24 | # this defines the translations passed to hydra.menu and hydra.popup
25 | _TRANS = const("""[
26 | {"en": "language", "zh": "语言/Lang", "ja": "言語/Lang"},
27 | {"en": "volume", "zh": "音量", "ja": "音量"},
28 | {"en": "ui_color", "zh": "UI颜色", "ja": "UIの色"},
29 | {"en": "bg_color", "zh": "背景颜色", "ja": "背景色"},
30 | {"en": "wifi_ssid", "zh": "WiFi名称", "ja": "WiFi名前"},
31 | {"en": "wifi_pass", "zh": "WiFi密码", "ja": "WiFiパスワード"},
32 | {"en": "sync_clock", "zh": "同步时钟", "ja": "時計同期"},
33 | {"en": "24h_clock", "zh": "24小时制", "ja": "24時間制"},
34 | {"en": "timezone", "zh": "时区", "ja": "タイムゾーン"},
35 | {"en": "Confirm", "zh": "确认", "ja": "確認"}
36 | ]""")
37 |
38 |
39 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Globals: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
40 |
41 | display = Display()
42 | kb = userinput.UserInput()
43 | config = config.Config()
44 | I18N = I18n(_TRANS)
45 | overlay = UIOverlay(i18n=I18N)
46 |
47 | LANGS = ['en', 'zh', 'ja']
48 | LANGS.sort()
49 |
50 | # try mounting SDCard for settings import/export
51 | try:
52 | sd = SDCard()
53 | sd.mount()
54 | except:
55 | sd = None
56 |
57 |
58 |
59 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Functions: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
60 |
61 | def update_config(caller, value):
62 | """Update the config using given value.
63 |
64 | (This is a callback for HydraMenu)
65 | """
66 | config[caller.text] = value
67 |
68 | # regen palette and translations based on new vals
69 | config.generate_palette()
70 | I18N.__init__(_TRANS)
71 |
72 | print(f"config['{caller.text}'] = {value}")
73 |
74 |
75 | def discard_conf(caller): # noqa: ARG001
76 | """Close Settings and discard changes."""
77 | print("Discard config.")
78 | display.fill(0)
79 | display.show()
80 | time.sleep_ms(10)
81 | machine.reset()
82 |
83 |
84 | def save_conf(caller): # noqa: ARG001
85 | """Close Settings and write new config."""
86 | config.save()
87 | print("Save config: ", config.config)
88 | display.fill(0)
89 | display.show()
90 | time.sleep_ms(10)
91 | machine.reset()
92 |
93 |
94 | def export_config(caller): # noqa: ARG001
95 | """Try saving config to SDCard."""
96 | # try making hydra directory
97 | try:
98 | os.mkdir('sd/Hydra')
99 | except OSError: pass
100 |
101 | # try exporting config file
102 | try:
103 | with open("sd/Hydra/config.json", "w") as file:
104 | file.write(json.dumps(config.config))
105 | print(json.dumps(config.config))
106 | overlay.popup("Config exported to 'sd/Hydra/config.json'")
107 | except OSError as e:
108 | overlay.error(e)
109 |
110 |
111 | def import_config(caller): # noqa: ARG001
112 | """Try importing a config from the SDCard."""
113 | global menu # noqa: PLW0603
114 | try:
115 | with open("sd/Hydra/config.json") as file:
116 | config.config.update(json.loads(file.read()))
117 |
118 | # update config and lang
119 | config.generate_palette()
120 | I18N.__init__(_TRANS)
121 |
122 | overlay.popup("Config loaded from 'sd/Hydra/config.json'")
123 | # update menu
124 | menu.exit()
125 | menu = build_menu()
126 |
127 | except Exception as e: # noqa: BLE001
128 | overlay.error(e)
129 |
130 |
131 | def import_export(caller):
132 | """Bring up the menu for importing/exporting the config."""
133 | choice = overlay.popup_options(
134 | ("Back...", "Export to SD", "Import from SD"),
135 | title="Import/Export config",
136 | depth=1,
137 | )
138 | if choice == "Export to SD":
139 | export_config(caller)
140 | elif choice == "Import from SD":
141 | import_config(caller)
142 |
143 |
144 |
145 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Create the menu: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
146 | def build_menu() -> hydramenu.Menu:
147 | """Create and return a manu for the config."""
148 | menu = hydramenu.Menu(
149 | esc_callback=discard_conf,
150 | i18n=I18N,
151 | )
152 |
153 | menu_def = [
154 | (hydramenu.ChoiceItem, 'language', {'choices': LANGS, 'instant_callback': update_config}),
155 | (hydramenu.IntItem, 'volume', {'min_int': 0, 'max_int': 10, 'instant_callback': update_config}),
156 | (hydramenu.RGBItem, 'ui_color', {'instant_callback': update_config}),
157 | (hydramenu.RGBItem, 'bg_color', {'instant_callback': update_config}),
158 | (hydramenu.WriteItem, 'wifi_ssid', {}),
159 | (hydramenu.WriteItem, 'wifi_pass', {'hide': True}),
160 | (hydramenu.BoolItem, 'sync_clock', {}),
161 | (hydramenu.BoolItem, '24h_clock', {}),
162 | (hydramenu.IntItem, 'timezone', {'min_int': -13, 'max_int': 13}),
163 | ]
164 |
165 | # build menu from def
166 | for i_class, name, kwargs in menu_def:
167 | menu.append(
168 | i_class(
169 | menu,
170 | name,
171 | config[name],
172 | callback=update_config,
173 | **kwargs,
174 | ))
175 |
176 | menu.append(hydramenu.DoItem(menu, "Import/Export", callback=import_export))
177 | menu.append(hydramenu.DoItem(menu, "Confirm", callback=save_conf))
178 |
179 | return menu
180 |
181 |
182 |
183 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Main body: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
184 | # Thanks to HydraMenu, the settings app is now pretty small.
185 | # So, not much point in overcomplicating things:
186 |
187 | menu = build_menu()
188 |
189 | # this loop lets us restart the new menu if it is stopped/recreated by the callbacks above
190 | while True:
191 | menu.main()
192 |
--------------------------------------------------------------------------------
/src/launcher/terminal/commands.py:
--------------------------------------------------------------------------------
1 | """The commands used by the Terminal."""
2 | import os, machine
3 |
4 | bcolors = {
5 | 'DIM':'\033[35m',
6 | 'MID':'\033[36m',
7 | 'LIGHT': '\033[96m',
8 | 'OKBLUE': '\033[94m',
9 | 'OKGREEN': '\033[92m',
10 | 'RED': '\033[91m',
11 | 'BOLD': '\033[1m',
12 | }
13 |
14 | def ctext(text:str, color:str) -> str:
15 | """Apply a named color to the text."""
16 | return f"{bcolors[color]}{text}\033[0m"
17 |
18 | def list_dir(*args) -> str:
19 | """List the given directory."""
20 | dirs = []
21 | files = []
22 | for name_type__ in os.ilistdir(*args):
23 | if name_type__[1] == 0x4000:
24 | dirs.append(name_type__[0])
25 | else:
26 | files.append(name_type__[0])
27 |
28 | # style output
29 | return f"{ctext(' '.join(dirs), 'OKBLUE')}\n{ctext(' '.join(files), 'OKGREEN')}"
30 |
31 | def cat(*args) -> str:
32 | """Read text from one or more files."""
33 | txt = ""
34 | for arg in args:
35 | with open(arg) as f:
36 | txt += f.read()
37 | return txt
38 |
39 | def touch(*args):
40 | """Create (or touch) the given files."""
41 | for arg in args:
42 | with open(arg, 'a') as f:
43 | f.write('')
44 |
45 | def del_from_str(string:str, delstrings:str) -> str:
46 | """Remove multiple substrings from given string."""
47 | string = str(string)
48 | for s in delstrings:
49 | string = string.replace(s, '')
50 | return string
51 |
52 | def _help(*args) -> str:
53 | """Get usage info."""
54 | global commands # noqa: PLW0602
55 | global usr_commands # noqa: PLW0602
56 | if "commands" in args:
57 | return (
58 | ctext("Commands:\n", 'DIM')
59 | + ctext(del_from_str(list(commands.keys()), ('[',']',"'")), 'OKBLUE')
60 | + (" " + ctext(del_from_str(usr_commands, ('{','}',"'")), 'OKGREEN') if usr_commands else '')
61 | )
62 | return (
63 | ctext("MicroHydra Terminal:\n", 'DIM')
64 | + ctext("Type a command, the name of a Python script, or Python code to execute.\n", 'LIGHT')
65 | + ctext("For a list of valid commands, type ", "DIM") + ctext("help commands", "OKBLUE")
66 | )
67 |
68 | def get_commands(term) -> dict:
69 | """Get the terminal command functions."""
70 | global commands # noqa: PLW0603
71 | commands = {
72 | "ls": list_dir,
73 | "cat": cat,
74 | "cd": lambda arg: os.chdir(arg),
75 | "rm": lambda arg: os.remove(arg),
76 | "touch": touch,
77 | "mv": lambda *args: os.rename(*args),
78 | "cwd": os.getcwd,
79 | "mkdir": lambda arg: os.mkdir(arg),
80 | "rmdir": lambda arg: os.rmdir(arg),
81 | "uname": lambda: os.uname().machine,
82 | "clear": term.clear,
83 | "reboot": lambda: (term.print("Goodbye!"), machine.reset()),
84 | "help": _help,
85 | }
86 | # add alternate aliases for commands
87 | commands.update({
88 | "chdir":commands['cd'],
89 | "exit":commands['reboot'],
90 | })
91 | return commands
92 |
93 |
94 | def get_usr_commands() -> set:
95 | """Get a set of user-defined commands."""
96 | global usr_commands # noqa: PLW0603
97 | usr_commands = set()
98 | if 'usr' in os.listdir('/'):
99 | for name in os.listdir('/usr'):
100 | if name.endswith('.py'):
101 | usr_commands.add(name[:-3])
102 | return usr_commands
103 |
--------------------------------------------------------------------------------
/src/launcher/terminal/termline.py:
--------------------------------------------------------------------------------
1 | """Lines class for the Terminal."""
2 | from lib.display import Display
3 |
4 |
5 | txt_clrs = {
6 | '30': 0,
7 | '31': 11,
8 | '32': 12,
9 | '33': 14,
10 | '34': 13,
11 | '35': 4,
12 | '36': 5,
13 | '37': 9,
14 | '90': 3,
15 | '91': 11,
16 | '92': 12,
17 | '93': 8,
18 | '94': 13,
19 | '95': 14,
20 | '96': 6,
21 | '97': 10,
22 | }
23 | # bg colors are txt colors + 10
24 |
25 |
26 | class _StyleStr:
27 | """Color/style and string, for the TermLine class."""
28 |
29 | # class level style helps us remember styling between strings/lines
30 | text_color = None
31 | bg_color = None
32 | bold = False
33 | underline = False
34 |
35 | def __init__(self, text):
36 | if text.startswith('\033[') and 'm' in text:
37 | # Get style attributes
38 | text = text[2:]
39 | style, text = text.split('m', 1)
40 |
41 |
42 | for styl in style.split(';'):
43 |
44 | # Style resetters:
45 | if styl == '0': # Reset all
46 | _StyleStr.text_color = None
47 | _StyleStr.bg_color = None
48 | _StyleStr.bold = False
49 | _StyleStr.underline = False
50 | elif styl == '39': # Default foreground color
51 | _StyleStr.text_color = None
52 | elif styl == '49': # Default background color
53 | _StyleStr.bg_color = None
54 |
55 | elif styl == '1': # Bold
56 | _StyleStr.bold = True
57 | elif styl == '22': # Normal intensity
58 | _StyleStr.bold = False
59 |
60 | elif styl == '4': # Underline
61 | _StyleStr.underline = True
62 | elif styl == '24': # Underline off
63 | _StyleStr.underline = False
64 |
65 | # text color
66 | elif styl in txt_clrs:
67 | _StyleStr.text_color = txt_clrs[styl]
68 |
69 | # BG color
70 | elif styl.isdigit() and str(int(styl) - 10) in txt_clrs:
71 | _StyleStr.bg_color = txt_clrs[str(int(styl) - 10)]
72 |
73 | self.text = text
74 | self.txt_clr = _StyleStr.text_color
75 | self.bg_clr = _StyleStr.bg_color
76 | self.bld = _StyleStr.bold
77 | self.undrln = _StyleStr.underline
78 | self.width = Display.get_total_width(text)
79 |
80 |
81 | def draw(self, x, y, display):
82 | txt_clr = 7 if self.txt_clr is None else self.txt_clr
83 | bg_clr = 2 if self.bg_clr is None else self.bg_clr
84 | display.rect(x, y-1, self.width, 10, display.palette[bg_clr], fill=True)
85 | if self.bld:
86 | display.text(self.text, x+1, y, display.palette[txt_clr])
87 | if self.undrln:
88 | display.hline(x, y+9, self.width, display.palette[txt_clr])
89 | display.text(self.text, x, y, display.palette[txt_clr])
90 |
91 |
92 | class TermLine:
93 | """Single line, with color support."""
94 |
95 | def __init__(self, text):
96 | """Create a line with the given text."""
97 | self.strings = self._get_strings(text)
98 |
99 | @staticmethod
100 | def _get_strings(string) -> list[_StyleStr]:
101 | """Get style strings from text."""
102 | strings = []
103 | current_str = ''
104 | while string:
105 | if string.startswith('\033[') \
106 | and current_str:
107 | strings.append(_StyleStr(current_str))
108 | current_str = ''
109 | current_str += string[0]
110 | string = string[1:]
111 | strings.append(_StyleStr(current_str))
112 | return strings
113 |
114 | def draw(self, x: int, y: int, display: Display):
115 | """Draw the line."""
116 | for string in self.strings:
117 | string.draw(x, y, display)
118 | x += string.width
119 |
120 |
--------------------------------------------------------------------------------
/src/lib/audio/__init__.py:
--------------------------------------------------------------------------------
1 | """MicroHydra audio module.
2 |
3 | This module provides a simple API for accessing audio features in MicroHydra.
4 | """
5 |
6 | from .i2ssound import I2SSound
7 |
8 |
9 | _MH_I2S_SCK = const(7)
10 | _MH_I2S_WS = const(5)
11 | _MH_I2S_SD = const(6)
12 |
13 |
14 |
15 | class Audio(I2SSound):
16 | """General wrapper for specific sound module."""
17 |
18 | def __new__(cls, **kwargs): # noqa: ARG003, D102
19 | if not hasattr(cls, 'instance'):
20 | cls.instance = super().__new__(cls)
21 | return cls.instance
22 |
23 | def __init__(self, buf_size=2048, rate=11025, channels=4):
24 | """Create the Audio object.
25 |
26 | Args: (Passed to driver module)
27 | buf_size: The buffer size to use
28 | rate: The sample rate for the audio
29 | channels: The number of simultaneous audio channels
30 | """
31 | super().__init__(
32 | buf_size=buf_size,
33 | rate=rate,
34 | channels=channels,
35 | sck=_MH_I2S_SCK,
36 | ws=_MH_I2S_WS,
37 | sd=_MH_I2S_SD,
38 | )
39 |
--------------------------------------------------------------------------------
/src/lib/battlevel.py:
--------------------------------------------------------------------------------
1 | """This very basic module reads the battery ADC values and converts it into a percentage or 0-3 level."""
2 | import machine
3 |
4 |
5 |
6 | # CONSTANTS:
7 | # vbat has a voltage divider of 1/2
8 | _MIN_VALUE = const(1575000) # 3.15v
9 | _MAX_VALUE = const(2100000) # 4.2v
10 |
11 | _LOW_THRESH = const(_MIN_VALUE + ((_MAX_VALUE - _MIN_VALUE) // 3))
12 | _HIGH_THRESH = const(_LOW_THRESH + ((_MAX_VALUE - _MIN_VALUE) // 3))
13 |
14 | _MH_BATT_ADC = const(10)
15 |
16 |
17 | # CLASS Battery:
18 | class Battery:
19 | """Battery info reader."""
20 |
21 | def __init__(self):
22 | """Create the Battery object."""
23 | #init the ADC for the battery
24 | self.adc = machine.ADC(_MH_BATT_ADC)
25 | self.adc.atten(machine.ADC.ATTN_11DB) # needed to get appropriate range
26 |
27 | def read_pct(self) -> int:
28 | """Return an approximate battery level as a percentage."""
29 | raw_value = self.adc.read_uv()
30 |
31 | if raw_value <= _MIN_VALUE:
32 | return 0
33 | if raw_value >= _MAX_VALUE:
34 | return 100
35 |
36 | delta_value = raw_value - _MIN_VALUE # shift range down
37 | delta_max = _MAX_VALUE - _MIN_VALUE # shift range down
38 | return int((delta_value / delta_max) * 100)
39 |
40 | def read_level(self) -> int:
41 | """Read approx battery level on the adc and return as int range 0 (low) to 3 (high)."""
42 | raw_value = self.adc.read_uv()
43 | if raw_value < _MIN_VALUE:
44 | return 0
45 | if raw_value < _LOW_THRESH:
46 | return 1
47 | if raw_value < _HIGH_THRESH:
48 | return 2
49 | return 3
50 |
--------------------------------------------------------------------------------
/src/lib/display/__init__.py:
--------------------------------------------------------------------------------
1 | """This Module provides an easy to use Display object for creating graphics in MicroHydra."""
2 |
3 | from .display import Display
4 |
--------------------------------------------------------------------------------
/src/lib/display/display.py:
--------------------------------------------------------------------------------
1 | """This Module provides an easy to use Display object for creating graphics in MicroHydra."""
2 |
3 | import machine
4 |
5 | from . import st7789
6 |
7 |
8 | # ~~~~~ Magic constants:
9 | _MH_DISPLAY_HEIGHT = const(135)
10 | _MH_DISPLAY_WIDTH = const(240)
11 |
12 | _MH_DISPLAY_SPI_ID = const(1)
13 | _MH_DISPLAY_BAUDRATE = const(40_000_000)
14 | _MH_DISPLAY_SCK = const(36)
15 | _MH_DISPLAY_MOSI = const(35)
16 | _MH_DISPLAY_MISO = const(None)
17 | _MH_DISPLAY_RESET = const(33)
18 | _MH_DISPLAY_CS = const(37)
19 | _MH_DISPLAY_DC = const(34)
20 | _MH_DISPLAY_BACKLIGHT = const(38)
21 | _MH_DISPLAY_ROTATION = const(1)
22 |
23 |
24 | class Display(st7789.ST7789):
25 | """Main graphics class for MicroHydra.
26 |
27 | Subclasses the device-specific display driver.
28 | """
29 |
30 | # Set to True to redraw all overlays next time show is called
31 | draw_overlays = False
32 | # A public list of overlay functions, to be called in order.
33 | overlay_callbacks = []
34 |
35 | def __new__(cls, **kwargs): # noqa: ARG003, D102
36 | if not hasattr(cls, 'instance'):
37 | Display.instance = super().__new__(cls)
38 | return cls.instance
39 |
40 |
41 | def __init__(
42 | self,
43 | *,
44 | use_tiny_buf=False,
45 | **kwargs):
46 | """Initialize the Display."""
47 | # mh_if TDECK:
48 | # # Enable Peripherals:
49 | # machine.Pin(10, machine.Pin.OUT, value=1)
50 | # mh_end_if
51 |
52 | if hasattr(self, 'fbuf'):
53 | print("WARNING: Display re-initialized.")
54 | super().__init__(
55 | machine.SPI(
56 | _MH_DISPLAY_SPI_ID,
57 | baudrate=_MH_DISPLAY_BAUDRATE,
58 | sck=machine.Pin(_MH_DISPLAY_SCK),
59 | mosi=machine.Pin(_MH_DISPLAY_MOSI),
60 | miso=self._init_pin(_MH_DISPLAY_MISO),
61 | ),
62 | _MH_DISPLAY_HEIGHT,
63 | _MH_DISPLAY_WIDTH,
64 | reset=self._init_pin(_MH_DISPLAY_RESET, machine.Pin.OUT),
65 | cs=machine.Pin(_MH_DISPLAY_CS, machine.Pin.OUT),
66 | dc=machine.Pin(_MH_DISPLAY_DC, machine.Pin.OUT),
67 | backlight=machine.Pin(_MH_DISPLAY_BACKLIGHT, machine.Pin.OUT),
68 | rotation=_MH_DISPLAY_ROTATION,
69 | color_order="BGR",
70 | use_tiny_buf=use_tiny_buf,
71 | **kwargs,
72 | )
73 | Display.draw_overlays = True # Draw all overlays once on the first show()
74 |
75 |
76 | @staticmethod
77 | def _init_pin(target_pin, *args) -> machine.Pin|None:
78 | """For __init__: return a pin if an integer is given, or return None."""
79 | if target_pin is None:
80 | return None
81 | return machine.Pin(target_pin, *args)
82 |
83 |
84 | def _draw_overlays(self):
85 | """Call each overlay callback in Display.overlay_callbacks."""
86 | for callback in Display.overlay_callbacks:
87 | callback(self)
88 |
89 |
90 | def show(self):
91 | """Write changes to display."""
92 | if Display.draw_overlays:
93 | self._draw_overlays()
94 | Display.draw_overlays = False
95 | super().show()
96 |
--------------------------------------------------------------------------------
/src/lib/display/namedpalette.py:
--------------------------------------------------------------------------------
1 | """An alternative to lib.display.palette.
2 |
3 | This module is intended to be a helpful, and optional, companion for display.palette,
4 | for reference and convenience.
5 | """
6 |
7 | from .palette import Palette
8 | from lib.hydra.utils import get_instance
9 |
10 |
11 | # Palette class
12 | class NamedPalette:
13 | """Store colors in a Palette, accessible by name."""
14 |
15 | names = {
16 | 'black':0,
17 | 'bg_dark':1,
18 | 'bg_color':2,
19 | 'mid_color':5,
20 | 'ui_color':8,
21 | 'ui_light':9,
22 | 'white':10,
23 | 'red':11,
24 | 'green':12,
25 | 'blue':13,
26 | 'bg_complement':14,
27 | 'ui_complement':15,
28 | }
29 | def __init__(self):
30 | """Initialize the Palette."""
31 | self.palette = get_instance(Palette)
32 |
33 | @staticmethod
34 | def _str_to_idx(val:str|int) -> int:
35 | if isinstance(val, str):
36 | return NamedPalette.names(
37 | val.lower()
38 | )
39 | return val
40 |
41 | def __len__(self) -> int:
42 | return len(self.palette)
43 |
44 | def __setitem__(self, key:int|str, new_val:int):
45 | self.palette[self._str_to_idx(key)] = new_val
46 |
47 | def __getitem__(self, key:int|str) -> int:
48 | return self.palette[self._str_to_idx(key)]
49 |
50 | def __iter__(self):
51 | for i in range(len(self)):
52 | yield self[i]
53 |
--------------------------------------------------------------------------------
/src/lib/display/palette.py:
--------------------------------------------------------------------------------
1 | """Provides the Palette class for storing a list of RGB565 colors.
2 |
3 | The Palette class is designed to be used for storing a list of RGB565 colors,
4 | and for returning the appropriate colors by index to be used with MicroHydra's display module.
5 |
6 | Key notes on Palette:
7 | - uses a bytearray to store color information,
8 | this is intended for fast/easy use with Viper's ptr16.
9 |
10 | - Returns an RGB565 color when using normal framebuffer, or an index of the color if use_tiny_buf.
11 | (This makes it so that you can pass a `Palette[i]` to the Display class in either mode.)
12 |
13 | - Palette is a singleton, which is important so that different MH classes can modify and share it's data
14 | (without initializing the Display).
15 | """
16 |
17 | # Palette class
18 | class Palette:
19 | """Stores the user color palette and converts the values for lib.Display."""
20 |
21 | use_tiny_buf = False
22 | buf = bytearray(32)
23 | def __new__(cls): # noqa: D102
24 | if not hasattr(cls, 'instance'):
25 | cls.instance = super().__new__(cls)
26 | return cls.instance
27 |
28 | def __len__(self) -> int:
29 | return len(self.buf) // 2
30 |
31 | @micropython.viper
32 | def __setitem__(self, key:int, new_val:int):
33 | buf_ptr = ptr16(self.buf)
34 | buf_ptr[key] = new_val
35 |
36 | @micropython.viper
37 | def __getitem__(self, key:int) -> int:
38 | # if using tiny buf, the color should be the index for the color
39 | if self.use_tiny_buf:
40 | return key
41 |
42 | buf_ptr = ptr16(self.buf)
43 | return buf_ptr[key]
44 |
45 | def __iter__(self):
46 | for i in range(len(self)):
47 | yield self[i]
48 |
--------------------------------------------------------------------------------
/src/lib/display/rawbitmap.py:
--------------------------------------------------------------------------------
1 | """Object class for loading/structuring a raw bitmap file for use with the Display driver."""
2 | import os
3 |
4 |
5 | class RawBitmap:
6 | """Open a raw bitmap file for use with the Display core."""
7 |
8 | cached_path = None
9 | cache = None
10 |
11 | def __init__(self, file_path: str, width: int, height: int, palette: list[int, ...]):
12 | """Construct the bitmap from given file."""
13 | self.WIDTH = width
14 | self.HEIGHT = height
15 | self.PALETTE = palette
16 |
17 | # This assumes that the bits per pixel is always the minimum possible:
18 | self.BPP = 1
19 | while len(palette) > (1 << self.BPP):
20 | self.BPP += 1
21 |
22 | if RawBitmap.cached_path == file_path:
23 | # Use the cached buffer rather than reloading
24 | self.size = len(RawBitmap.cache)
25 | self.BITMAP = RawBitmap.cache
26 | else:
27 | # Load and cache a new buffer
28 | self.size = os.stat(file_path)[6]
29 | with open(file_path, 'rb') as f:
30 | buf = bytearray(self.size)
31 | f.readinto(buf)
32 | self.BITMAP = memoryview(buf)
33 |
34 | RawBitmap.cached_path = file_path
35 | RawBitmap.cache = self.BITMAP
36 |
37 | @classmethod
38 | def clean(cls):
39 | """Clear the bitmap cache."""
40 | cls.cached_path = None
41 | cls.cache = None
42 |
--------------------------------------------------------------------------------
/src/lib/easing/back.py:
--------------------------------------------------------------------------------
1 | """Back easing functions.
2 |
3 | All of these functions take and return a float from 0.0 to 1.0
4 | and are based on Penner's easing functions,
5 | and easings.net
6 | """
7 |
8 | _C1 = const(1.70158)
9 | _C2 = const(2.594909)
10 | _C3 = const(2.70158)
11 |
12 |
13 | def ease_in_back(x: float) -> float:
14 | """Apply ease in function to the given float."""
15 | return _C3*x*x*x - _C1*x*x
16 |
17 |
18 | def ease_out_back(x: float) -> float:
19 | """Apply ease out function to the given float."""
20 | return 1 + _C3 * (x - 1)**3 + _C1 * (x - 1)**2
21 |
22 |
23 | def ease_in_out_back(x: float) -> float:
24 | """Apply ease in/out function to the given float."""
25 | return (
26 | ((2 * x)**2 * ((_C2 + 1)*2*x - _C2)) / 2
27 | if x < 0.5 else
28 | ((2*x - 2)**2 * ((_C2 + 1) * (x*2 - 2) + _C2) + 2) / 2
29 | )
30 |
--------------------------------------------------------------------------------
/src/lib/easing/circ.py:
--------------------------------------------------------------------------------
1 | """Circ easing functions.
2 |
3 | All of these functions take and return a float from 0.0 to 1.0
4 | and are based on Penner's easing functions,
5 | and easings.net
6 | """
7 | import math
8 |
9 |
10 | def ease_in_circ(x: float) -> float:
11 | """Apply ease in function to the given float."""
12 | return 1 - math.sqrt(1 - x**2)
13 |
14 |
15 | def ease_out_circ(x: float) -> float:
16 | """Apply ease out function to the given float."""
17 | return math.sqrt(1 - (x - 1)**2)
18 |
19 |
20 | def ease_in_out_circ(x: float) -> float:
21 | """Apply ease in/out function to the given float."""
22 | return (
23 | (1 - math.sqrt(1 - (2*x)**2)) / 2
24 | if x < 0.5 else
25 | (math.sqrt(1 - (-2*x + 2)**2) + 1) / 2
26 | )
27 |
--------------------------------------------------------------------------------
/src/lib/easing/cubic.py:
--------------------------------------------------------------------------------
1 | """Cubic easing functions.
2 |
3 | All of these functions take and return a float from 0.0 to 1.0
4 | and are based on Penner's easing functions,
5 | and easings.net
6 | """
7 |
8 |
9 | def ease_in_cubic(x: float) -> float:
10 | """Apply ease in function to the given float."""
11 | return x * x * x
12 |
13 |
14 | def ease_out_cubic(x: float) -> float:
15 | """Apply ease out function to the given float."""
16 | return 1 - (1 - x)**3
17 |
18 |
19 | def ease_in_out_cubic(x: float) -> float:
20 | """Apply ease in/out function to the given float."""
21 | return (
22 | 4 * x * x * x
23 | if x < 0.5 else
24 | 1 - (-2*x + 2)**3 / 2
25 | )
26 |
27 |
--------------------------------------------------------------------------------
/src/lib/easing/quad.py:
--------------------------------------------------------------------------------
1 | """Quad easing functions.
2 |
3 | All of these functions take and return a float from 0.0 to 1.0
4 | and are based on Penner's easing functions,
5 | and easings.net
6 | """
7 |
8 |
9 | def ease_in_quad(x: float) -> float:
10 | """Apply ease in function to the given float."""
11 | return x * x
12 |
13 |
14 | def ease_out_quad(x: float) -> float:
15 | """Apply ease out function to the given float."""
16 | return 1 - (1 - x) * (1 - x)
17 |
18 |
19 | def ease_in_out_quad(x: float) -> float:
20 | """Apply ease in/out function to the given float."""
21 | return (
22 | 2 * x * x
23 | if x < 0.5 else
24 | 1 - (-2*x + 2)**2 / 2
25 | )
26 |
--------------------------------------------------------------------------------
/src/lib/easing/sine.py:
--------------------------------------------------------------------------------
1 | """Sine easing functions.
2 |
3 | All of these functions take and return a float from 0.0 to 1.0
4 | and are based on Penner's easing functions,
5 | and easings.net
6 | """
7 | import math
8 |
9 |
10 | def ease_in_sine(x: float) -> float:
11 | """Apply ease in function to the given float."""
12 | return 1 - math.cos((x * math.pi) / 2)
13 |
14 |
15 | def ease_out_sine(x: float) -> float:
16 | """Apply ease out function to the given float."""
17 | return math.sin((x * math.pi) / 2)
18 |
19 |
20 | def ease_in_out_sine(x: float) -> float:
21 | """Apply ease in/out function to the given float."""
22 | return -(math.cos(math.pi * x) - 1) / 2
23 |
--------------------------------------------------------------------------------
/src/lib/hydra/beeper.py:
--------------------------------------------------------------------------------
1 | """This module wraps lib/audio with a simple API for making square wave beeps.
2 |
3 | Known issue:
4 | The audio produced in this version of beeper.py is much higher quality than
5 | it was in previous versions (thanks to the precision of mavica's I2SSound module),
6 | but there is a noticeable delay due to the somewhat infrequent updating of the I2S IRQ.
7 | At the time of writing I am not sure how I might fix that.
8 |
9 | I have already tried:
10 | - Instantly calling the IRQ handler function after playing a sound. This does make the
11 | sound happen faster, but also makes the timing very inconsistent and strange sounding.
12 | - Calling the IRQ handler AND setting a timer to stop the audio, but this sounds horrible
13 | when multiple sounds happen rapidly.
14 | - Unregistering/reregistering the IRQ function with I2S. This seems to cause a silent
15 | crash of MicroPython for some reason.
16 | """
17 |
18 | from machine import Timer
19 |
20 | from lib.audio import Audio
21 | from .config import Config
22 | from .utils import get_instance
23 |
24 |
25 | _SQUARE = const(\
26 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'
27 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'
28 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'
29 | b'\x00\x80\x00\x80\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'
30 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'
31 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'
32 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\x00\x80'
33 | )
34 | SQUARE = memoryview(_SQUARE)
35 |
36 |
37 |
38 | def note_to_int(note:str) -> int:
39 | """Convert a note string into an integer for I2SSound.
40 |
41 | Note should be a string containing:
42 | - A letter from A-G,
43 | - Optionally, a 'S' or '#' selecting a 'sharp' note,
44 | - An octave (as an integer).
45 |
46 | Examples: 'C4', 'CS5', 'G3'
47 | """
48 | note = note.upper()
49 |
50 | # Extract the pitch and the octave from the note string
51 | pitch = note[0]
52 | octave = int(note[-1])
53 |
54 | # Define the base values for each pitch
55 | base_values = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11}
56 |
57 | # Calculate the base value for the note
58 | value = base_values[pitch]
59 |
60 | # Adjust for sharps
61 | if 'S' in note \
62 | or '#' in note:
63 | value += 1
64 |
65 | # Calculate the final integer value
66 | # C4 is the reference point with a value of 0
67 | return value + (octave - 4) * 12
68 |
69 |
70 |
71 |
72 | class Beeper:
73 | """A class for playing simple UI beeps."""
74 |
75 | def __init__(self):
76 | """Initialize the Beeper (and I2SSound)."""
77 | self.audio = get_instance(Audio)
78 | self.config = get_instance(Config)
79 | self.note_buf = []
80 | self.timer = Timer(-1)
81 |
82 |
83 | def stop(self):
84 | """Stop all channels."""
85 | for i in range(self.audio.channels):
86 | self.audio.stop(channel=i)
87 |
88 |
89 | def play_next(self, tim=None): # noqa: ARG002
90 | """Play the next note (on callback)."""
91 | self.stop()
92 |
93 | if not self.note_buf:
94 | self.timer.deinit()
95 | return
96 |
97 | notes, volume, time_ms = self.note_buf.pop(0)
98 |
99 | for idx, note in enumerate(notes):
100 | self.audio.play(
101 | sample=SQUARE,
102 | note=note_to_int(note),
103 | volume=volume,
104 | channel=idx,
105 | loop=True,
106 | )
107 |
108 | self.timer.init(mode=Timer.ONE_SHOT, period=time_ms, callback=self.play_next)
109 |
110 |
111 | def play(self, notes, time_ms=100, volume=None):
112 | """Play the given note.
113 |
114 | This is the main outward-facing method of Beeper.
115 | Use this to play a simple square wave over the I2C speaker.
116 |
117 | "notes" should be:
118 | - a string containing a note's name
119 | notes="C4"
120 | - an iterable containing a sequence of notes to play
121 | notes=("C4", "C5")
122 | - an iterable containing iterables, each with notes that are playes together.
123 | notes=(("C4", "C5"), ("C6", "C7"))
124 |
125 | "time_ms" is the time in milliseconds to play each note for.
126 |
127 | "volume" is an integer between 0 and 10 (inclusive).
128 | """
129 | if not self.config['ui_sound']:
130 | return
131 | if volume is None:
132 | volume = self.config['volume'] + 5
133 |
134 | if isinstance(notes, str):
135 | notes = [notes]
136 |
137 | for note in notes:
138 | if isinstance(note, str):
139 | note = (note,)
140 | self.note_buf.append((note, volume, time_ms))
141 |
142 | self.play_next()
143 |
--------------------------------------------------------------------------------
/src/lib/hydra/color.py:
--------------------------------------------------------------------------------
1 | """This module contains some color logic used by MicroHydra.
2 |
3 | Previously these functions lived in lib/mhconfig, and lib/microhydra before that.
4 |
5 | Several of these functions could almost certainly be significantly sped up by
6 | converting the float math to integer math, and using Viper.
7 | """
8 | from lib.easing.circ import ease_in_out_circ
9 |
10 |
11 | @micropython.viper
12 | def color565(r:int, g:int, b:int) -> int:
13 | """Convert red, green and blue values (0-255) into a 16-bit 565 encoding."""
14 | r = (r * 31) // 255
15 | g = (g * 63) // 255
16 | b = (b * 31) // 255
17 | return (r << 11) | (g << 5) | b
18 |
19 |
20 | @micropython.viper
21 | def swap_bytes(color:int) -> int:
22 | """Flip the left and right byte in the 16 bit color."""
23 | return ((color & 255) << 8) | (color >> 8)
24 |
25 |
26 | def mix(val2, val1, fac:float = 0.5) -> float:
27 | """Mix two values to the weight of fac."""
28 | return (val1 * fac) + (val2 * (1.0 - fac))
29 |
30 |
31 | def mix_angle_float(angle1: float, angle2: float, factor: float = 0.5) -> float:
32 | """Take two angles as floats (range 0.0 to 1.0) and average them to the weight of factor.
33 |
34 | Mainly for blending hue angles.
35 | """
36 | # Ensure hue values are in the range [0, 1)
37 | angle1 = angle1 % 1
38 | angle2 = angle2 % 1
39 |
40 | # Calculate the angular distance between hue1 and hue2
41 | angular_distance = (angle2 - angle1 + 0.5) % 1 - 0.5
42 | # Calculate the middle hue value
43 | return (angle1 + angular_distance * factor) % 1
44 |
45 |
46 |
47 | def separate_color565(color: int) -> tuple[int, int, int]:
48 | """Separate a 16-bit 565 encoding into red, green, and blue components."""
49 | red = (color >> 11) & 0x1F
50 | green = (color >> 5) & 0x3F
51 | blue = color & 0x1F
52 | return red, green, blue
53 |
54 |
55 | def combine_color565(red: int, green: int, blue: int) -> int:
56 | """Combine red, green, and blue components into a 16-bit 565 encoding."""
57 | # Ensure color values are within the valid range
58 | red = max(0, min(red, 31))
59 | green = max(0, min(green, 63))
60 | blue = max(0, min(blue, 31))
61 |
62 | # Pack the color values into a 16-bit integer
63 | return (red << 11) | (green << 5) | blue
64 |
65 |
66 | def rgb_to_hsv(r: float, g: float, b: float) -> tuple[float, float, float]:
67 | """Convert an RGB float to an HSV float.
68 |
69 | From: cpython/Lib/colorsys.py.
70 | """
71 | maxc = max(r, g, b)
72 | minc = min(r, g, b)
73 | rangec = (maxc-minc)
74 | v = maxc
75 | if minc == maxc:
76 | return 0.0, 0.0, v
77 | s = rangec / maxc
78 | rc = (maxc-r) / rangec
79 | gc = (maxc-g) / rangec
80 | bc = (maxc-b) / rangec
81 | if r == maxc:
82 | h = bc-gc
83 | elif g == maxc:
84 | h = 2.0+rc-bc
85 | else:
86 | h = 4.0+gc-rc
87 | h = (h/6.0) % 1.0
88 | return h, s, v
89 |
90 |
91 | def hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
92 | """Convert an RGB float to an HSV float.
93 |
94 | From: cpython/Lib/colorsys.py
95 | """
96 | if s == 0.0:
97 | return v, v, v
98 | i = int(h*6.0)
99 | f = (h*6.0) - i
100 | p = v*(1.0 - s)
101 | q = v*(1.0 - s*f)
102 | t = v*(1.0 - s*(1.0-f))
103 | i = i % 6
104 | if i == 0:
105 | return v, t, p
106 | if i == 1:
107 | return q, v, p
108 | if i == 2:
109 | return p, v, t
110 | if i == 3:
111 | return p, q, v
112 | if i == 4:
113 | return t, p, v
114 | # i == 5:
115 | return v, p, q
116 |
117 |
118 | def mix_color565(
119 | color1: int,
120 | color2: int,
121 | mix_factor: float = 0.5,
122 | hue_mix_fac: float|None = None,
123 | sat_mix_fac: float|None = None,
124 | ) -> int:
125 | """Mix two rgb565 colors, by converting through HSV color space.
126 |
127 | This function is probably too slow for running constantly in a loop, but should be good for occasional usage.
128 | """
129 | if hue_mix_fac is None:
130 | hue_mix_fac = mix_factor
131 | if sat_mix_fac is None:
132 | sat_mix_fac = mix_factor
133 |
134 | # separate to components
135 | r1, g1, b1 = separate_color565(color1)
136 | r2, g2, b2 = separate_color565(color2)
137 | # convert to float 0.0 to 1.0
138 | r1 /= 31
139 | r2 /= 31
140 | g1 /= 63
141 | g2 /= 63
142 | b1 /= 31
143 | b2 /= 31
144 | # convert to hsv 0.0 to 1.0
145 | h1, s1, v1 = rgb_to_hsv(r1, g1, b1)
146 | h2, s2, v2 = rgb_to_hsv(r2, g2, b2)
147 |
148 | # mix the hue angle
149 | hue = mix_angle_float(h1, h2, factor=hue_mix_fac)
150 | # mix the rest
151 | sat = mix(s1, s2, sat_mix_fac)
152 | val = mix(v1, v2, mix_factor)
153 |
154 | # convert back to rgb floats
155 | red, green, blue = hsv_to_rgb(hue, sat, val)
156 | # convert back to 565 range
157 | red = int(red * 31)
158 | green = int(green * 63)
159 | blue = int(blue * 31)
160 |
161 | return combine_color565(red, green, blue)
162 |
163 |
164 | def darker_color565(color: int, mix_factor: float = 0.5) -> int:
165 | """Get the darker version of a 565 color."""
166 | # separate to components
167 | r, g, b = separate_color565(color)
168 | # convert to float 0.0 to 1.0
169 | r /= 31
170 | g /= 63
171 | b /= 31
172 | # convert to hsv 0.0 to 1.0
173 | h, s, v = rgb_to_hsv(r, g, b)
174 |
175 | # higher sat value is perceived as darker
176 | s *= 1 + mix_factor
177 | v *= 1 - mix_factor
178 |
179 | # convert back to rgb floats
180 | r, g, b = hsv_to_rgb(h, s, v)
181 | # convert back to 565 range
182 | r = int(r * 31)
183 | g = int(g * 63)
184 | b = int(b * 31)
185 |
186 | return combine_color565(r, g, b)
187 |
188 |
189 | def lighter_color565(color: int, mix_factor: float = 0.2) -> int:
190 | """Get the lighter version of a 565 color."""
191 | # separate to components
192 | r, g, b = separate_color565(color)
193 | # convert to float 0.0 to 1.0
194 | r /= 31
195 | g /= 63
196 | b /= 31
197 | # convert to hsv 0.0 to 1.0
198 | h, s, v = rgb_to_hsv(r, g, b)
199 |
200 | # higher sat value is perceived as darker
201 | s *= 1 - (mix_factor / 2)
202 | v *= 1 + mix_factor
203 |
204 | # convert back to rgb floats
205 | r, g, b = hsv_to_rgb(h, s, v)
206 | # convert back to 565 range
207 | r = int(r * 31)
208 | g = int(g * 63)
209 | b = int(b * 31)
210 |
211 | return combine_color565(r, g, b)
212 |
213 |
214 | def color565_shift_to_hue(color: int, h_mid: float, h_radius: float, min_sat=0.33, min_val=0.8) -> int:
215 | """Shift the given RGB565 into the specified hue range."""
216 | r,g,b = separate_color565(color)
217 | r /= 31
218 | g /= 63
219 | b /= 31
220 | h,s,v = rgb_to_hsv(r,g,b)
221 |
222 | h_min = h_mid - h_radius
223 |
224 | # distance factor is between 0 and 1 (where 0.5 means both hues are the same)
225 | angular_distance_fac = (h - h_mid + 0.5) % 1
226 | # apply easing, and rescale so that fac is between h_min and h_max
227 | angular_distance_fac = ease_in_out_circ(angular_distance_fac)*h_radius*2 + h_min
228 | # apply distance factor to hue
229 |
230 | h = angular_distance_fac % 1
231 | if s < min_sat:
232 | s = min_sat
233 |
234 | r,g,b = hsv_to_rgb(angular_distance_fac%1,s, max(v, min_val))
235 | return combine_color565(int(r*31), int(g*63), int(b*31))
236 |
--------------------------------------------------------------------------------
/src/lib/hydra/config.py:
--------------------------------------------------------------------------------
1 | """User config access for all of MicroHydra."""
2 |
3 | import json
4 |
5 | from lib.display.palette import Palette
6 | from lib.hydra.color import *
7 |
8 |
9 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ CONSTANT ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
10 | DEFAULT_CONFIG = const(
11 | """{
12 | """
13 | # mh_if kb_light:
14 | '"kb_light": false,'
15 | # mh_end_if
16 | """
17 | "24h_clock": false,
18 | "wifi_ssid": "",
19 | "bg_color": 2051,
20 | "volume": 2,
21 | "wifi_pass": "",
22 | "ui_color": 65430,
23 | "ui_sound": true,
24 | "timezone": 0,
25 | "sync_clock": true,
26 | "language": "en",
27 | "brightness": 8
28 | }""")
29 |
30 |
31 |
32 |
33 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Config Class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34 |
35 | class Config:
36 | """Config provides an abstraction of the MicroHydra 'config.json' file.
37 |
38 | This class aims to provide a convenient way for all modules and apps in MicroHydra to access a shared config.
39 | The goal of this class is to prevent internal-MicroHydra scripts from reimplementing the same code repeatedly,
40 | and to provide easy to read methods for apps to access MicroHydra config values.
41 |
42 | Config is also responsible for setting a full UI color palette from the user's 2 chosen system colors.
43 | """
44 |
45 | def __init__(self):
46 | """Initialize the Config with values from 'config.json'."""
47 | self.config = json.loads(DEFAULT_CONFIG)
48 | # initialize the config object with the values from config.json
49 | try:
50 | with open("config.json") as conf:
51 | self.config.update(
52 | json.loads(conf.read())
53 | )
54 | except:
55 | print("could not load settings from config.json. reloading default values.")
56 | with open("config.json", "w") as conf:
57 | conf.write(json.dumps(self.config))
58 |
59 | self._modified = False
60 | # generate an extended color palette
61 | self.generate_palette()
62 |
63 |
64 | def __new__(cls):
65 | """Config is singleton; only one needs to exist."""
66 | if not hasattr(cls, 'instance'):
67 | cls.instance = super().__new__(cls)
68 | return cls.instance
69 |
70 |
71 | def save(self):
72 | """If the config has been modified, save it to 'config.json'."""
73 | if self._modified:
74 | with open("config.json", "w") as conf:
75 | conf.write(json.dumps(self.config))
76 |
77 |
78 | def generate_palette(self):
79 | """Generate an expanded palette based on user-set UI/BG colors."""
80 | ui_color = self.config['ui_color']
81 | bg_color = self.config['bg_color']
82 | mid_color = mix_color565(bg_color, ui_color, 0.5)
83 |
84 | self.palette = Palette()
85 |
86 | # self.palette[0] = 0 # black
87 | self.palette[1] = darker_color565(bg_color) # darker bg color
88 |
89 | # user colors
90 | for i in range(2, 9):
91 | fac = (i - 2) / 6
92 | self.palette[i] = mix_color565(bg_color, ui_color, fac)
93 |
94 | self.palette[9] = lighter_color565(ui_color)
95 | self.palette[10] = 65535 # white
96 |
97 | # Generate a further expanded palette, based on UI colors, shifted towards primary display colors.
98 | self.palette[11] = color565_shift_to_hue(mid_color, 0.0, 0.15, min_sat=0.95)
99 | self.palette[12] = color565_shift_to_hue(mid_color, 0.34, 0.15, min_sat=0.4)
100 | self.palette[13] = color565_shift_to_hue(mid_color, 0.63, 0.1, min_sat=0.5, min_val=0.9)
101 | # Finally, generate swapped bg/ui colors as compliments to the main palette.
102 | self.palette[14] = mix_color565(bg_color, ui_color, 0.95, 0.25, 0.9)
103 | self.palette[15] = mix_color565(bg_color, ui_color, 0.95, 0.75, 0.8)
104 |
105 |
106 | def __getitem__(self, key):
107 | # get item passthrough
108 | return self.config[key]
109 |
110 |
111 | def __setitem__(self, key, new_val):
112 | self._modified = True
113 | # item assignment passthrough
114 | self.config[key] = new_val
115 |
--------------------------------------------------------------------------------
/src/lib/hydra/i18n.py:
--------------------------------------------------------------------------------
1 | """Internationalization.
2 |
3 | This module implements a lightweight internationalization class,
4 | which can be used to implement translations in MicroHydra.
5 | """
6 |
7 | import json
8 |
9 | from .config import Config
10 | from .utils import get_instance
11 |
12 |
13 | class I18n:
14 | """Internationalization class.
15 |
16 | args:
17 | - translations:
18 | A json string defining a list of dicts,
19 | where each dict is formatted like `{'lang':'translation', ...}`.
20 | Example:
21 | '''[
22 | {"en": "Loading...", "zh": "加载中...", "ja": "読み込み中..."},
23 | {"en": "Files", "zh": "文件", "ja": "ファイル"}
24 | ]'''
25 | """
26 |
27 | def __init__(self, translations, key='en'):
28 | """Initialize the I18n class.
29 |
30 | Translations are provided with the 'translations' parameter,
31 | and will be processed into a single dictionary.
32 | 'key' selects the language to use as the dictionary key,
33 | and the values of that dictionary are based on the language set in 'config.json'.
34 | """
35 | # extract lang from config
36 | config = get_instance(Config)
37 | self.lang = config['language']
38 |
39 | # extract and prune target translations into one dict
40 | self.translations = {item[key]:item.get(self.lang, item[key]) for item in json.loads(translations)}
41 |
42 | def __getitem__(self, text):
43 | """Get the translation for the given text, defaulting to the given text."""
44 | return self.translations.get(text, text)
45 |
--------------------------------------------------------------------------------
/src/lib/hydra/loader.py:
--------------------------------------------------------------------------------
1 | """Communicate with MicroHydras `main.py`.
2 |
3 | Values are stored in the RTC, so that information can be retained on soft reset.
4 | """
5 | from machine import RTC, reset
6 |
7 | _PATH_SEP = const("|//|")
8 |
9 | def launch_app(*args: str):
10 | """Set args and reboot."""
11 | set_args(*args)
12 | reset()
13 |
14 | def set_args(*args: str):
15 | """Store given args in RTC.
16 |
17 | First arg should typically be an import path.
18 | """
19 | RTC().memory(_PATH_SEP.join(args))
20 |
21 | def get_args() -> list[str]:
22 | """Get the args stored in the RTC."""
23 | return RTC().memory().decode().split(_PATH_SEP)
24 |
--------------------------------------------------------------------------------
/src/lib/hydra/simpleterminal.py:
--------------------------------------------------------------------------------
1 | """A simple (graphical) terminal.
2 |
3 | This module provides a simple and lightweight scrolling terminal display.
4 | It can be used to simply print status information to the device.
5 |
6 | When `immediate` is set to `True` (the default value), it draws and shows itself every time you print to it.
7 | When `False`, only draw when you call the draw method. You must call `Display.show` manually.
8 | """
9 |
10 | from lib.display import Display
11 | from .config import Config
12 | from .utils import get_instance
13 |
14 |
15 | _MH_DISPLAY_HEIGHT = const(135)
16 | _MH_DISPLAY_WIDTH = const(240)
17 | _MAX_H_CHARS = const(_MH_DISPLAY_WIDTH // 8)
18 | _MAX_V_LINES = const(_MH_DISPLAY_HEIGHT // 9)
19 |
20 | class SimpleTerminal:
21 | """A simple scrolling terminal view."""
22 |
23 | lines = []
24 | immediate=True
25 | def __init__(self, *, immediate: bool = True):
26 | """Create the SimpleTerminal.
27 |
28 | If immediate == True, updates to the terminal are instantly drawn to the display.
29 | (Otherwise you must manually call `SimpleTerminal.draw` and `Display.show`)
30 | """
31 | self.display = get_instance(Display)
32 | self.config = get_instance(Config)
33 | self.immediate = immediate
34 |
35 |
36 | def print(self, text: str): # noqa: D102
37 | text = str(text)
38 | print(text)
39 |
40 | new_lines = []
41 |
42 | # cut up line when it's too long
43 | while len(text) > _MAX_H_CHARS:
44 | new_lines.append(text[:_MAX_H_CHARS])
45 | text = text[_MAX_H_CHARS:]
46 | new_lines.append(text)
47 |
48 | # add new lines, trim to correct length
49 | self.lines += new_lines
50 | if len(self.lines) > _MAX_V_LINES:
51 | self.lines = self.lines[-_MAX_V_LINES:]
52 |
53 | if self.immediate:
54 | self.display.fill(self.config.palette[2])
55 | self.draw()
56 | self.display.show()
57 |
58 |
59 | def draw(self):
60 | """Draw the terminal to the display."""
61 | for idx, line in enumerate(self.lines):
62 | self.display.text(
63 | line,
64 | 0, idx * 9,
65 | self.config.palette[8 if idx == len(self.lines) - 1 else 7]
66 | )
67 |
--------------------------------------------------------------------------------
/src/lib/hydra/statusbar.py:
--------------------------------------------------------------------------------
1 | """A reusable statusbar for MicroHydra apps."""
2 |
3 | import time
4 |
5 | from machine import Timer
6 | from lib.display import Display
7 | from lib.hydra.config import Config
8 | from lib.hydra.utils import get_instance
9 |
10 |
11 | _MH_DISPLAY_WIDTH = const(240)
12 | _MH_DISPLAY_HEIGHT = const(135)
13 |
14 | _STATUSBAR_HEIGHT = const(18)
15 |
16 | _SMALL_FONT_HEIGHT = const(8)
17 | _SMALL_FONT_WIDTH = const(8)
18 |
19 | _CLOCK_X = const(6)
20 | _CLOCK_Y = const((_STATUSBAR_HEIGHT - _SMALL_FONT_HEIGHT) // 2)
21 | _CLOCK_AMPM_Y = const(_CLOCK_Y - 1)
22 | _CLOCK_AMPM_PADDING = const(2)
23 | _CLOCK_AMPM_X_OFFSET = const(_CLOCK_AMPM_PADDING + _CLOCK_X)
24 |
25 | _BATTERY_HEIGHT = const(10)
26 | _BATTERY_X = const(_MH_DISPLAY_WIDTH - 28)
27 | _BATTERY_Y = const((_STATUSBAR_HEIGHT - 10) // 2)
28 |
29 |
30 |
31 | class StatusBar:
32 | """The MicroHydra statusbar."""
33 |
34 | def __init__(self, *, enable_battery: bool = True, register_overlay: bool = True):
35 | """Initialize the statusbar."""
36 | global battery # noqa: PLW0603
37 |
38 | if enable_battery:
39 | # If drawing battery status, import battlevel and icons
40 | from lib import battlevel
41 | from launcher.icons import battery
42 | self.batt = battlevel.Battery()
43 |
44 | self.enable_battery = enable_battery
45 |
46 | self.config = get_instance(Config)
47 |
48 | if register_overlay:
49 | Display.overlay_callbacks.append(self.draw)
50 | # Set a timer to periodically redraw the clock
51 | self.timer = Timer(2, mode=Timer.PERIODIC, period=60_000, callback=self._update_overlay)
52 |
53 |
54 | @staticmethod
55 | def _update_overlay(_):
56 | Display.draw_overlays = True
57 |
58 |
59 | @staticmethod
60 | def _time_24_to_12(hour_24: int, minute: int) -> tuple[str, str]:
61 | """Convert the given 24 hour time to 12 hour."""
62 | ampm = 'am'
63 | if hour_24 >= 12:
64 | ampm = 'pm'
65 |
66 | hour_12 = hour_24 % 12
67 | if hour_12 == 0:
68 | hour_12 = 12
69 |
70 | time_string = f"{hour_12}:{minute:02d}"
71 | return time_string, ampm
72 |
73 |
74 | def draw(self, display: Display):
75 | """Draw the status bar."""
76 |
77 | # Draw statusbar base
78 | display.fill_rect(
79 | 0, 0,
80 | _MH_DISPLAY_WIDTH,
81 | _STATUSBAR_HEIGHT,
82 | self.config.palette[4],
83 | )
84 | display.hline(
85 | 0, _STATUSBAR_HEIGHT, _MH_DISPLAY_WIDTH,
86 | self.config.palette[1],
87 | )
88 |
89 | # clock
90 | _, _, _, hour_24, minute, _, _, _ = time.localtime()
91 |
92 | if self.config['24h_clock']:
93 | formatted_time = f"{hour_24}:{minute:02d}"
94 | else:
95 | formatted_time, ampm = self._time_24_to_12(hour_24, minute)
96 | display.text(
97 | ampm,
98 | _CLOCK_AMPM_X_OFFSET
99 | + (len(formatted_time)
100 | * _SMALL_FONT_WIDTH),
101 | _CLOCK_AMPM_Y + 1,
102 | self.config.palette[5],
103 | )
104 | display.text(
105 | ampm,
106 | _CLOCK_AMPM_X_OFFSET
107 | + (len(formatted_time)
108 | * _SMALL_FONT_WIDTH),
109 | _CLOCK_AMPM_Y,
110 | self.config.palette[2],
111 | )
112 |
113 | display.text(
114 | formatted_time,
115 | _CLOCK_X, _CLOCK_Y+1,
116 | self.config.palette[2],
117 | )
118 | display.text(
119 | formatted_time,
120 | _CLOCK_X, _CLOCK_Y,
121 | self.config.palette[7],
122 | )
123 |
124 | # battery
125 | if self.enable_battery:
126 | batt_lvl = self.batt.read_level()
127 | display.bitmap(
128 | battery,
129 | _BATTERY_X,
130 | _BATTERY_Y,
131 | index=batt_lvl,
132 | palette=[self.config.palette[4], self.config.palette[7]],
133 | )
134 |
--------------------------------------------------------------------------------
/src/lib/hydra/utils.py:
--------------------------------------------------------------------------------
1 | """Common utilities for MicroHydra core modules."""
2 |
3 |
4 | def clamp(x: float, minimum: float, maximum: float) -> int|float:
5 | """Clamp the given value to the range `minimum` - `maximum`.
6 |
7 | This function is faster than using `min(max(val, val), val)`
8 | (in my testing, at least), and a bit more readable, too.
9 | """
10 | if x < minimum:
11 | return minimum
12 | if x > maximum:
13 | return maximum
14 | return x
15 |
16 |
17 | def get_instance(cls, *, allow_init: bool = True) -> object:
18 | """Get the active instance of the given class.
19 |
20 | If an instance doesn't exist and `allow_init` is `True`, one will be created and returned.
21 | Otherwise, if there is no instance, raises `AttributeError`.
22 | """
23 | if hasattr(cls, 'instance'):
24 | return cls.instance
25 | if allow_init:
26 | return cls()
27 | msg = f"{cls.__name__} has no instance. (You must initialize it first)"
28 | raise AttributeError(msg)
29 |
--------------------------------------------------------------------------------
/src/lib/sdcard/__init__.py:
--------------------------------------------------------------------------------
1 | """This simple module configures and mounts an SDCard."""
2 |
3 | from .mhsdcard import SDCard
4 |
--------------------------------------------------------------------------------
/src/lib/sdcard/mhsdcard.py:
--------------------------------------------------------------------------------
1 | """This simple module configures and mounts an SDCard."""
2 |
3 | # mh_if shared_sdcard_spi:
4 | from .sdcard import _SDCard
5 | # mh_end_if
6 |
7 | import machine
8 | import os
9 |
10 |
11 |
12 | _MH_SDCARD_SLOT = const(1)
13 | _MH_SDCARD_SCK = const(40)
14 | _MH_SDCARD_MISO = const(38)
15 | _MH_SDCARD_MOSI = const(41)
16 | _MH_SDCARD_CS = const(39)
17 |
18 |
19 |
20 | class SDCard:
21 | """SDCard control."""
22 |
23 | def __init__(self):
24 | """Initialize the SDCard."""
25 | # mh_if shared_sdcard_spi:
26 | self.sd = _SDCard(
27 | machine.SPI(
28 | _MH_SDCARD_SLOT, # actually SPI id
29 | sck=machine.Pin(_MH_SDCARD_SCK),
30 | miso=machine.Pin(_MH_SDCARD_MISO),
31 | mosi=machine.Pin(_MH_SDCARD_MOSI),
32 | ),
33 | cs=machine.Pin(_MH_SDCARD_CS),
34 | )
35 | # mh_else:
36 | # self.sd = machine.SDCard(
37 | # slot=_MH_SDCARD_SLOT,
38 | # sck=machine.Pin(_MH_SDCARD_SCK),
39 | # miso=machine.Pin(_MH_SDCARD_MISO),
40 | # mosi=machine.Pin(_MH_SDCARD_MOSI),
41 | # cs=machine.Pin(_MH_SDCARD_CS)
42 | # )
43 | # mh_end_if
44 |
45 |
46 | def mount(self):
47 | """Mount the SDCard."""
48 | if "sd" in os.listdir("/"):
49 | return
50 | try:
51 | os.mount(self.sd, '/sd')
52 | except (OSError, NameError, AttributeError) as e:
53 | print(f"Could not mount SDCard: {e}")
54 |
55 |
56 | def deinit(self):
57 | """Unmount and deinit the SDCard."""
58 | os.umount('/sd')
59 | # mh_if not shared_sdcard_spi:
60 | # self.sd.deinit()
61 | # mh_end_if
62 |
--------------------------------------------------------------------------------
/src/lib/userinput/__init__.py:
--------------------------------------------------------------------------------
1 | """UserInput provides access to the device input peripherals."""
2 | from .userinput import UserInput
3 |
--------------------------------------------------------------------------------
/src/lib/zipextractor.py:
--------------------------------------------------------------------------------
1 | """
2 | Naive tool for extracting zip files.
3 |
4 | Current limitations:
5 | - Only supports the DEFLATE compression method
6 | - ZIP must include directories (not just imply the directory from the file name)
7 | - ZIP64 not supported
8 | - Central directory is completely ignored
9 | (therefore all the required data must be in the local headers)
10 |
11 | """
12 |
13 | import deflate
14 | import os
15 |
16 | _LOCAL_HEADER_SIGN = const(b'PK\x03\x04')
17 | _CENTRAL_HEADER_SIGN = const(b'PK\x01\x02')
18 |
19 | class ZipExtractor:
20 | """Class that extracts zip files."""
21 |
22 | def __init__(self, zip_path):
23 | """Create an extractor for given path."""
24 | # small arrays of various sizes are used to easily read
25 | # attributes of various lengths
26 | self.array_4 = bytearray(4)
27 | self.array_2 = bytearray(2)
28 | self.array_1 = bytearray(1)
29 |
30 | self.zip_path = zip_path
31 |
32 |
33 | @staticmethod
34 | def _arr2int(array) -> int:
35 | return int.from_bytes(bytes(array), 'little')
36 |
37 |
38 | @staticmethod
39 | def _arr2str(array) -> str:
40 | return bytes(array).decode("utf-8")
41 |
42 |
43 | def _extract_next_file(self, file, out_path, wbits):
44 | """Naively extract files from zip one-by-one, ignoring the "central directory"."""
45 | # Start by scanning local header for needed info
46 |
47 | # First bytes should mark a local header
48 | file.readinto(self.array_4) # (byte 4)
49 | if self.array_4 != _LOCAL_HEADER_SIGN:
50 | if self.array_4 == _CENTRAL_HEADER_SIGN:
51 | return # no more local file data
52 | print("WARNING: ZipExtractor didn't find expected file header!")
53 |
54 |
55 | # seek to compression method
56 | file.seek(4, 1) # (byte 8)
57 | file.readinto(self.array_2)
58 | compression_method = self._arr2int(self.array_2) # (byte 10)
59 |
60 | # skip to uncompressed size
61 | file.seek(12, 1) # (byte 22)
62 | file.readinto(self.array_4) # (byte 26)
63 | uncompressed_size = self._arr2int(self.array_4)
64 |
65 | # read name and extra data length
66 | file.readinto(self.array_2) # (byte 28)
67 | name_len = self._arr2int(self.array_2)
68 |
69 | file.readinto(self.array_2) # (byte 30)
70 | ext_len = self._arr2int(self.array_2)
71 |
72 | # read name (variable size) into new array
73 | name_arr = bytearray(name_len)
74 | file.readinto(name_arr) # (byte 30 + n)
75 | name = self._arr2str(name_arr)
76 |
77 | # seek past extra data
78 | file.seek(ext_len, 1) # (byte 30+n+m)
79 |
80 | # find full output file path
81 | full_path = f'{out_path}/{name}'
82 |
83 | # Make directory if that's what this is
84 | if uncompressed_size == 0 and name.endswith('/'):
85 | try:
86 | # trailing slash must be removed
87 | print(f"Making {full_path}")
88 | os.mkdir(full_path[:-1])
89 | except: # noqa: S110
90 | pass # directory might exist already
91 |
92 | # if this has a name and a valid compression method,
93 | # it should be a valid file
94 | elif name and (compression_method in (0, 8)):
95 | print(f"Writing {full_path}")
96 | # deflate and write file
97 | with deflate.DeflateIO(file, deflate.RAW, wbits) as d, \
98 | open(full_path, 'wb') as output_file:
99 | remaining_bytes = uncompressed_size + 1
100 |
101 | while remaining_bytes > 0:
102 | to_read = min(128, remaining_bytes)
103 |
104 | output_file.write(d.read(to_read))
105 |
106 | remaining_bytes -= to_read
107 |
108 | # get next file:
109 | self._extract_next_file(file, out_path, wbits)
110 |
111 |
112 | def extract(self, out_path, wbits=14):
113 | """Extract the zip to the given path using given wbits."""
114 | with open(self.zip_path, "rb") as f:
115 | self._extract_next_file(f, out_path, wbits)
116 |
--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------
1 | """Base 'apploader' for MicroHydra."""
2 | import machine
3 | from lib.hydra import loader
4 | from lib import sdcard
5 | import sys
6 |
7 |
8 | # mh_if frozen:
9 | # _LAUNCHER = const(".frozen/launcher/launcher")
10 | # mh_else:
11 | _LAUNCHER = const("/launcher/launcher")
12 | # mh_end_if
13 | sys.path = ['', '/lib', '.frozen', '.frozen/lib']
14 |
15 |
16 | #default app path is the path to the launcher
17 | app = _LAUNCHER
18 |
19 | # mh_if TDECK:
20 | # # T-Deck must manually power on its peripherals
21 | # machine.Pin(10, machine.Pin.OUT, value=True)
22 | # mh_end_if
23 |
24 |
25 | # if this was not a power reset, we are probably launching an app:
26 | if machine.reset_cause() != machine.PWRON_RESET:
27 | args = loader.get_args()
28 | if args:
29 | # pop the import path to prevent infinite boot loop
30 | app = args.pop(0)
31 | loader.set_args(*args)
32 |
33 | # only mount the sd card if the app is on the sd card.
34 | if app.startswith("/sd"):
35 | sdcard.SDCard().mount()
36 |
37 | # import the requested app!
38 | try:
39 | __import__(app)
40 | except Exception as e: # noqa: BLE001
41 | with open('log.txt', 'a') as log:
42 | log.write(f"[{app}]\n")
43 | sys.print_exception(e, log)
44 | # reboot into launcher
45 | loader.launch_app(_LAUNCHER)
46 |
--------------------------------------------------------------------------------
/tools/bitmaps/iconsprites.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/bitmaps/iconsprites.png
--------------------------------------------------------------------------------
/tools/bitmaps/image_converter.py:
--------------------------------------------------------------------------------
1 | """Convert a single image to a module compatible with MicroHydra.
2 |
3 | Convert an image file to a python module for use with the bitmap method. Use redirection to save the
4 | output to a file. The image is converted to a bitmap using the number of bits per pixel you specify.
5 | The bitmap is saved as a python module that can be imported and used with the bitmap method.
6 |
7 |
8 |
9 | This script was originally written by Russ Hughes as part of the 'st7789py_mpy' repo.
10 |
11 |
12 |
13 | MIT License
14 |
15 | Copyright (c) 2020-2023 Russ Hughes
16 |
17 | Permission is hereby granted, free of charge, to any person obtaining a copy
18 | of this software and associated documentation files (the "Software"), to deal
19 | in the Software without restriction, including without limitation the rights
20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21 | copies of the Software, and to permit persons to whom the Software is
22 | furnished to do so, subject to the following conditions:
23 |
24 | The above copyright notice and this permission notice shall be included in all
25 | copies or substantial portions of the Software.
26 |
27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33 | SOFTWARE.
34 | """
35 |
36 | import sys
37 | import argparse
38 | from PIL import Image
39 |
40 |
41 | def rgb_to_color565(r: int, g: int, b: int) -> int:
42 | """Convert RGB color to the 16-bit color format (565).
43 |
44 | Args:
45 | r (int): Red component of the RGB color (0-255).
46 | g (int): Green component of the RGB color (0-255).
47 | b (int): Blue component of the RGB color (0-255).
48 |
49 | Returns:
50 | int: Converted color value in the 16-bit color format (565).
51 | """
52 | r = ((r * 31) // (255))
53 | g = ((g * 63) // (255))
54 | b = ((b * 31) // (255))
55 | return (r << 11) | (g << 5) | b
56 |
57 |
58 | def convert_to_bitmap(image_file: str, bits_requested: int):
59 | """Convert image file to python module for use with bitmap method.
60 |
61 | Args:
62 | image_file (str): Name of file containing image to convert.
63 | bits_requested (int): The number of bits to use per pixel (1..8).
64 | """
65 |
66 | colors_requested = 1 << bits_requested
67 | img = Image.open(image_file).convert("RGB")
68 | img = img.convert("P", palette=Image.Palette.ADAPTIVE, colors=colors_requested)
69 | palette = img.getpalette()
70 | palette_colors = len(palette) // 3
71 | actual_colors = min(palette_colors, colors_requested)
72 | bits_required = actual_colors.bit_length()
73 | if bits_required < bits_requested:
74 | print(
75 | f"\nNOTE: Quantization reduced colors to {palette_colors} from the {bits_requested} "
76 | f"requested, reconverting using {bits_required} bit per pixel could save memory.\n",
77 | file=sys.stderr,
78 | )
79 |
80 | colors = [
81 | f"{rgb_to_color565(palette[color * 3], palette[color * 3 + 1], palette[color * 3 + 2]):04x}"
82 | for color in range(actual_colors)
83 | ]
84 |
85 | image_bitstring = "".join(
86 | "".join(
87 | "1" if (img.getpixel((x, y)) & (1 << bit - 1)) else "0"
88 | for bit in range(bits_required, 0, -1)
89 | )
90 | for y in range(img.height)
91 | for x in range(img.width)
92 | )
93 |
94 | bitmap_bits = len(image_bitstring)
95 |
96 | print(f"HEIGHT = {img.height}")
97 | print(f"WIDTH = {img.width}")
98 | print(f"COLORS = {actual_colors}")
99 | print(f"BITS = {bitmap_bits}")
100 | print(f"BPP = {bits_required}")
101 | print("PALETTE = [", end="")
102 |
103 | for i, rgb in enumerate(colors):
104 | if i > 0:
105 | print(",", end="")
106 | print(f"0x{rgb}", end="")
107 |
108 | print("]")
109 |
110 | print("_bitmap =\\\nb'", end="")
111 |
112 | for i in range(0, bitmap_bits, 8):
113 | if i and i % (16 * 8) == 0:
114 | print("'\\\nb'", end="")
115 | value = image_bitstring[i : i + 8]
116 | color = int(value, 2)
117 | print(f"\\x{color:02x}", end="")
118 |
119 | print("'\nBITMAP = memoryview(_bitmap)")
120 |
121 |
122 | def main():
123 | """Convert image file to python module for use with bitmap method."""
124 |
125 | parser = argparse.ArgumentParser(
126 | description="Convert image file to python module for use with bitmap method.",
127 | )
128 |
129 | parser.add_argument("image_file", help="Name of file containing image to convert")
130 |
131 | parser.add_argument(
132 | "bits_per_pixel",
133 | type=int,
134 | choices=range(1, 9),
135 | default=1,
136 | metavar="bits_per_pixel",
137 | help="The number of bits to use per pixel (1..8)",
138 | )
139 |
140 | args = parser.parse_args()
141 | bits = args.bits_per_pixel
142 | convert_to_bitmap(args.image_file, bits)
143 |
144 |
145 | if __name__ == "__main__":
146 | main()
147 |
--------------------------------------------------------------------------------
/tools/bitmaps/sprites_converter.py:
--------------------------------------------------------------------------------
1 | """Convert a sprite sheet into a module compatible with MicroHydra.
2 |
3 | Convert a sprite sheet image to python a module for use with indexed bitmap method. The Sprite sheet
4 | width and height should be a multiple of sprite width and height. There should be no extra pixels
5 | between sprites. All sprites will share the same palette.
6 |
7 |
8 |
9 | This script was originally written by Russ Hughes as part of the 'st7789py_mpy' repo.
10 |
11 |
12 |
13 | MIT License
14 |
15 | Copyright (c) 2020-2023 Russ Hughes
16 |
17 | Permission is hereby granted, free of charge, to any person obtaining a copy
18 | of this software and associated documentation files (the "Software"), to deal
19 | in the Software without restriction, including without limitation the rights
20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21 | copies of the Software, and to permit persons to whom the Software is
22 | furnished to do so, subject to the following conditions:
23 |
24 | The above copyright notice and this permission notice shall be included in all
25 | copies or substantial portions of the Software.
26 |
27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33 | SOFTWARE.
34 | """
35 |
36 | import sys
37 | import argparse
38 | from PIL import Image
39 |
40 |
41 | def rgb_to_color565(r: int, g: int, b: int) -> int:
42 | """Convert RGB color to the 16-bit color format (565).
43 |
44 | Args:
45 | r (int): Red component of the RGB color (0-255).
46 | g (int): Green component of the RGB color (0-255).
47 | b (int): Blue component of the RGB color (0-255).
48 |
49 | Returns:
50 | int: Converted color value in the 16-bit color format (565).
51 | """
52 | r = ((r * 31) // (255))
53 | g = ((g * 63) // (255))
54 | b = ((b * 31) // (255))
55 | return (r << 11) | (g << 5) | b
56 |
57 |
58 | def convert_image_to_bitmap(image_file: str, bits: int, sprite_width: int, sprite_height: int):
59 | """Convert image to bitmap representation."""
60 | colors_requested = 1 << bits
61 | img = Image.open(image_file).convert("RGB")
62 | img = img.convert(mode="P", palette=Image.Palette.ADAPTIVE, colors=colors_requested)
63 |
64 | palette = img.getpalette()
65 | palette_colors = len(palette) // 3
66 | actual_colors = min(palette_colors, colors_requested)
67 |
68 | colors = []
69 | for color in range(actual_colors):
70 | color565 = (
71 | ((palette[color * 3] & 0xF8) << 8)
72 | | ((palette[color * 3 + 1] & 0xFC) << 3)
73 | | ((palette[color * 3 + 2] & 0xF8) >> 3)
74 | )
75 | colors.append(f"{color565:04x}")
76 |
77 | image_bitstring = ""
78 | bitmaps = 0
79 |
80 | sprite_cols = img.width // sprite_width
81 | width_of_sprites = sprite_cols * sprite_width
82 | sprite_rows = img.height // sprite_height
83 | height_of_sprites = sprite_rows * sprite_height
84 |
85 | for y in range(0, height_of_sprites, sprite_height):
86 | for x in range(0, width_of_sprites, sprite_width):
87 | bitmaps += 1
88 | for yy in range(y, y + sprite_height):
89 | for xx in range(x, x + sprite_width):
90 | try:
91 | pixel = img.getpixel((xx, yy))
92 | except IndexError:
93 | print(
94 | f"IndexError: xx={xx}, yy={yy} check your sprite width and height",
95 | file=sys.stderr,
96 | )
97 | pixel = 0
98 | color = pixel
99 | image_bitstring += "".join(
100 | "1" if (color & (1 << bit - 1)) else "0"
101 | for bit in range(bits, 0, -1)
102 | )
103 |
104 | bitmap_bits = len(image_bitstring)
105 |
106 | # Create python source with image parameters
107 | print(f"BITMAPS = {bitmaps}")
108 | print(f"HEIGHT = {sprite_height}")
109 | print(f"WIDTH = {sprite_width}")
110 | print(f"COLORS = {actual_colors}")
111 | print(f"BITS = {bitmap_bits}")
112 | print(f"BPP = {bits}")
113 | print("PALETTE = [", end="")
114 |
115 | for color, rgb in enumerate(colors):
116 | if color:
117 | print(",", end="")
118 | print(f"0x{rgb}", end="")
119 | print("]")
120 |
121 | # Run though image bit string 8 bits at a time
122 | # and create python array source for memoryview
123 |
124 | print("_bitmap =\\")
125 | print("b'", end="")
126 |
127 | for i in range(0, bitmap_bits, 8):
128 | if i and i % (16 * 8) == 0:
129 | print("'\\\nb'", end="")
130 |
131 | value = image_bitstring[i : i + 8]
132 | color = int(value, 2)
133 | print(f"\\x{color:02x}", end="")
134 |
135 | print("'\nBITMAP = memoryview(_bitmap)")
136 |
137 |
138 | def main():
139 | """Convert images to python modules for use with indexed bitmap method.
140 |
141 | Args:
142 | input (str): image file to convert.
143 | sprite_width (int): Width of sprites in pixels.
144 | sprite_height (int): Height of sprites in pixels.
145 | bits_per_pixel (int): The number of color bits to use per pixel (1..8).
146 |
147 | """
148 |
149 | parser = argparse.ArgumentParser(
150 | description="Convert image file to python module for use with bitmap method.",
151 | )
152 |
153 | parser.add_argument("image_file", help="Name of file containing image to convert")
154 | parser.add_argument("sprite_width", type=int, help="Width of sprites in pixels")
155 | parser.add_argument("sprite_height", type=int, help="Height of sprites in pixels")
156 |
157 | parser.add_argument(
158 | "bits_per_pixel",
159 | type=int,
160 | choices=range(1, 9),
161 | default=1,
162 | metavar="bits_per_pixel",
163 | help="The number of bits to use per pixel (1..8)",
164 | )
165 |
166 | args = parser.parse_args()
167 |
168 | convert_image_to_bitmap(
169 | args.image_file, args.bits_per_pixel, args.sprite_width, args.sprite_height
170 | )
171 |
172 |
173 | main()
174 |
--------------------------------------------------------------------------------
/tools/build_device_bin.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if at least one board name is provided
4 | if [ $# -eq 0 ]; then
5 | echo "Usage: $0 ... "
6 | exit 1
7 | fi
8 |
9 | # Navigate to the esp-idf directory
10 | cd esp-idf || { echo "Failed to enter esp-idf directory"; exit 1; }
11 |
12 | # Source the export.sh script
13 | source export.sh || { echo "Failed to source export.sh"; exit 1; }
14 |
15 | echo "esp-idf setup done."
16 |
17 | # Navigate to the MicroPython esp32 port directory
18 | cd ../MicroPython/ports/esp32 || { echo "Failed to enter MicroPython/ports/esp32 directory"; exit 1; }
19 |
20 | # Loop through all provided board names and build MicroPython for each
21 | for BOARD_NAME in "$@"
22 | do
23 | echo "Building MicroPython for board: ${BOARD_NAME}"
24 | make BOARD=${BOARD_NAME} submodules || { echo "Failed to initialize submodules for ${BOARD_NAME}"; exit 1; }
25 | make BOARD=${BOARD_NAME} || { echo "Failed to build MicroPython for ${BOARD_NAME}"; exit 1; }
26 | echo "Build complete for board: ${BOARD_NAME}"
27 | done
28 |
29 | echo "All builds completed."
30 |
--------------------------------------------------------------------------------
/tools/build_mpy_cross.py:
--------------------------------------------------------------------------------
1 | """
2 | Script tries to build mpy-cross for MicroHydra.
3 | """
4 |
5 | import subprocess
6 | import os
7 |
8 |
9 |
10 |
11 | def make_mpy_cross():
12 | subprocess.call(["make", "-C", "MicroPython/mpy-cross"])
13 |
14 |
15 | def launch_wsl():
16 | """Attempt to use WSL if run from Windows"""
17 | subprocess.call('wsl -e sh -c "python3 tools/build_mpy_cross.py"')
18 |
19 |
20 | # build process is convoluted on Windows (and not supported by this script)
21 | # so if we are on Windows, try launching WSL instead:
22 | is_windows = os.name == 'nt'
23 |
24 | if is_windows:
25 | print("Running in Windows, attempting to use WSL...")
26 | launch_wsl()
27 | else:
28 | print("Building mpy_cross...")
29 | make_mpy_cross()
30 |
--------------------------------------------------------------------------------
/tools/clean_all.py:
--------------------------------------------------------------------------------
1 | """
2 | Clean MicroHydra build folders for a clean run.
3 | """
4 |
5 | import os
6 | import shutil
7 | from parse_files import NON_DEVICE_FILES
8 |
9 |
10 |
11 | CWD = os.getcwd()
12 | OG_DIRECTORY = CWD
13 |
14 | PARSE_PATH = os.path.join(CWD, 'MicroHydra')
15 | DEVICE_PATH = os.path.join(CWD, 'devices')
16 | ESP32_PATH = os.path.join(CWD, 'MicroPython', 'ports', 'esp32')
17 |
18 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MAIN ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
19 | def main():
20 | """
21 | Main script body.
22 |
23 | This file is organized such that the "main" logic lives near the top,
24 | and all of the functions/classes used here are defined below.
25 | """
26 |
27 | # parse devices into list of Device objects
28 | devices = []
29 | for filepath in os.listdir(DEVICE_PATH):
30 | if filepath not in NON_DEVICE_FILES:
31 | devices.append(Device(filepath))
32 |
33 |
34 | # remove everything in ./MicroHydra
35 | print(f"{bcolors.OKBLUE}Cleaning ./MicroHydra...{bcolors.ENDC}")
36 | shutil.rmtree(PARSE_PATH)
37 |
38 | # remove each device build folder, and board folder
39 | for device in devices:
40 | print(f"{bcolors.OKBLUE}Cleaning files for {device.name.title()}...{bcolors.ENDC}")
41 | device_build_path = os.path.join(ESP32_PATH, f"build-{device.name}")
42 | shutil.rmtree(device_build_path)
43 |
44 | device_board_path = os.path.join(ESP32_PATH, 'boards', device.name)
45 | shutil.rmtree(device_board_path)
46 |
47 |
48 | print(f"{bcolors.OKGREEN}Finished cleaning MicroPython build files.{bcolors.ENDC}")
49 | os.chdir(OG_DIRECTORY)
50 |
51 |
52 |
53 |
54 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
55 |
56 | class bcolors:
57 | """Small helper for print output coloring."""
58 | HEADER = '\033[95m'
59 | OKBLUE = '\033[94m'
60 | OKCYAN = '\033[96m'
61 | OKGREEN = '\033[92m'
62 | WARNING = '\033[93m'
63 | FAIL = '\033[91m'
64 | ENDC = '\033[0m'
65 | BOLD = '\033[1m'
66 | UNDERLINE = '\033[4m'
67 |
68 |
69 | class Device:
70 | """Store/parse device/platform details."""
71 | def __init__(self, name):
72 | self.name = name
73 |
74 | def __repr__(self):
75 | return f"Device({self.name})"
76 |
77 |
78 |
79 | class FileCopier:
80 | """Class contains methods for reading and parsing a given file."""
81 | def __init__(self, dir_entry, file_path):
82 | self.relative_path = file_path.removeprefix('/')
83 | self.dir_entry = dir_entry
84 | self.name = dir_entry.name
85 | self.path = dir_entry.path
86 |
87 |
88 | def __repr__(self):
89 | return f"FileParser({self.name})"
90 |
91 |
92 | def copy_to(self, dest_path):
93 | """For file types that shouldn't be modified, just copy instead."""
94 | dest_path = os.path.join(dest_path, self.relative_path, self.name)
95 | # make target directory:
96 | os.makedirs(os.path.dirname(dest_path), exist_ok=True)
97 | # write our original file data:
98 | with open(self.path, 'rb') as source_file:
99 | with open(dest_path, 'wb') as new_file:
100 | new_file.write(source_file.read())
101 |
102 |
103 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Functions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104 |
105 | def extract_file_data(dir_entry, path_dir):
106 | """Recursively extract DirEntry objects and relative paths for each file in directory."""
107 | if dir_entry.is_dir():
108 | output = []
109 | for r_entry in os.scandir(dir_entry):
110 | output += extract_file_data(r_entry, f"{path_dir}/{dir_entry.name}")
111 | return output
112 | else:
113 | return [(dir_entry, path_dir)]
114 |
115 |
116 |
117 | main()
118 |
--------------------------------------------------------------------------------
/tools/compile_firmwares.py:
--------------------------------------------------------------------------------
1 | """
2 | This script mainly calls another build script, for building MicroPython firmware.
3 | """
4 |
5 | import os
6 | import yaml
7 | import argparse
8 | import subprocess
9 | import shutil
10 | from parse_files import NON_DEVICE_FILES
11 |
12 |
13 | # argparser stuff:
14 | PARSER = argparse.ArgumentParser(
15 | prog='compile_hydra_mpy',
16 | description="""\
17 | Parse MicroHydra device files into .mpy files.
18 | """
19 | )
20 |
21 | PARSER.add_argument('-s', '--source', help='Path to MicroPython port folder.')
22 | PARSER.add_argument('-i', '--idf', help='Path to esp-idf folder.')
23 | PARSER.add_argument('-D', '--devices', help='Path to device definition folder.')
24 | PARSER.add_argument('-v', '--verbose', action='store_true')
25 | SCRIPT_ARGS = PARSER.parse_args()
26 |
27 | SOURCE_PATH = SCRIPT_ARGS.source
28 | DEVICE_PATH = SCRIPT_ARGS.devices
29 | IDF_PATH = SCRIPT_ARGS.idf
30 | VERBOSE = SCRIPT_ARGS.verbose
31 |
32 | # set defaults for args not given:
33 | CWD = os.getcwd()
34 | OG_DIRECTORY = CWD
35 |
36 | print(CWD)
37 |
38 | if SOURCE_PATH is None:
39 | SOURCE_PATH = os.path.join(CWD, 'MicroPython', 'ports', 'esp32')
40 | if DEVICE_PATH is None:
41 | DEVICE_PATH = os.path.join(CWD, 'devices')
42 | if IDF_PATH is None:
43 | IDF_PATH = os.path.join(CWD, 'esp-idf')
44 |
45 |
46 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MAIN ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
47 | def main():
48 | """
49 | Main script body.
50 |
51 | This file is organized such that the "main" logic lives near the top,
52 | and all of the functions/classes used here are defined below.
53 | """
54 |
55 | # parse devices into list of Device objects
56 | devices = []
57 | for filepath in os.listdir(DEVICE_PATH):
58 | if filepath not in NON_DEVICE_FILES:
59 | devices.append(Device(filepath))
60 |
61 | # Run build script, passing each target device name.
62 | print(f"{bcolors.OKBLUE}Running builds for {', '.join([device.name.title() for device in devices])}...{bcolors.ENDC}")
63 | subprocess.call([os.path.join('tools', 'build_device_bin.sh')] + [device.name for device in devices])
64 |
65 | # Rename/move firmware bins for each device.
66 | for device in devices:
67 | os.chdir(OG_DIRECTORY)
68 |
69 | print(f'{bcolors.OKBLUE}Extracting "{device.name}.bin"...{bcolors.ENDC}')
70 | os.rename(
71 | os.path.join(SOURCE_PATH, f'build-{device.name}', 'firmware.bin'),
72 | os.path.join(OG_DIRECTORY, 'MicroHydra', f'{device.name}.bin'),
73 | )
74 |
75 |
76 | print(f"{bcolors.OKGREEN}Finished making compiled bins.{bcolors.ENDC}")
77 | os.chdir(OG_DIRECTORY)
78 |
79 |
80 |
81 |
82 |
83 |
84 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
85 |
86 | class bcolors:
87 | """Small helper for print output coloring."""
88 | HEADER = '\033[95m'
89 | OKBLUE = '\033[94m'
90 | OKCYAN = '\033[96m'
91 | OKGREEN = '\033[92m'
92 | WARNING = '\033[93m'
93 | FAIL = '\033[91m'
94 | ENDC = '\033[0m'
95 | BOLD = '\033[1m'
96 | UNDERLINE = '\033[4m'
97 |
98 |
99 | class Device:
100 | """Store/parse device/platform details."""
101 | def __init__(self, name):
102 | self.name = name
103 |
104 | def __repr__(self):
105 | return f"Device({self.name})"
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | def launch_wsl():
117 | """Attempt to use WSL if run from Windows"""
118 | subprocess.call('wsl -e sh -c "python3 tools/compile_firmwares.py"')
119 |
120 | # build process is convoluted on Windows (and not supported by this script)
121 | # so if we are on Windows, try launching WSL instead:
122 | is_windows = os.name == 'nt'
123 |
124 | if is_windows:
125 | print("Running in Windows, attempting to use WSL...")
126 | launch_wsl()
127 | else:
128 | main()
129 |
--------------------------------------------------------------------------------
/tools/compile_hydra_mpy.py:
--------------------------------------------------------------------------------
1 | """
2 | Compile .mpy version of MicroHydra for each device.
3 | """
4 |
5 | import os
6 | import yaml
7 | import argparse
8 | import subprocess
9 | import shutil
10 | from parse_files import NON_DEVICE_FILES
11 |
12 |
13 | # argparser stuff:
14 | PARSER = argparse.ArgumentParser(
15 | prog='compile_hydra_mpy',
16 | description="""\
17 | Parse MicroHydra device files into .mpy files.
18 | """
19 | )
20 |
21 | PARSER.add_argument('-s', '--source', help='Path to MicroHydra source to be parsed.')
22 | PARSER.add_argument('-D', '--devices', help='Path to device definition folder.')
23 | PARSER.add_argument('-M', '--mpy', help='Path to mpy-cross.')
24 | PARSER.add_argument('-v', '--verbose', action='store_true')
25 | SCRIPT_ARGS = PARSER.parse_args()
26 |
27 | SOURCE_PATH = SCRIPT_ARGS.source
28 | DEVICE_PATH = SCRIPT_ARGS.devices
29 | MPY_PATH = SCRIPT_ARGS.mpy
30 | VERBOSE = SCRIPT_ARGS.verbose
31 |
32 | # files that shouldn't be compiled
33 | NO_COMPILE = ('main.py', 'apptemplate.py')
34 |
35 | # set defaults for args not given:
36 | CWD = os.getcwd()
37 | OG_DIRECTORY = CWD
38 |
39 | if SOURCE_PATH is None:
40 | SOURCE_PATH = os.path.join(CWD, 'MicroHydra')
41 | if DEVICE_PATH is None:
42 | DEVICE_PATH = os.path.join(CWD, 'devices')
43 | if MPY_PATH is None:
44 | MPY_PATH = os.path.join(CWD, 'MicroPython', 'mpy-cross', 'build')
45 |
46 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MAIN ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
47 | def main():
48 | """
49 | Main script body.
50 |
51 | This file is organized such that the "main" logic lives near the top,
52 | and all of the functions/classes used here are defined below.
53 | """
54 | os.chdir(MPY_PATH)
55 |
56 | # parse devices into list of Device objects
57 | devices = []
58 | for filepath in os.listdir(DEVICE_PATH):
59 | if filepath not in NON_DEVICE_FILES:
60 | devices.append(Device(filepath))
61 |
62 |
63 | for device in devices:
64 | print(f"{bcolors.OKBLUE}Compiling .mpy files for {device.name.title()}...{bcolors.ENDC}")
65 |
66 | source_path = os.path.join(SOURCE_PATH, device.name)
67 | dest_path = os.path.join(SOURCE_PATH, f"{device.name}_compiled")
68 |
69 | source_files = []
70 | for dir_entry in os.scandir(source_path):
71 | source_files += extract_file_data(dir_entry, '')
72 |
73 | for dir_entry, file_path in source_files:
74 | file_compiler = FileCompiler(dir_entry, file_path)
75 |
76 | if file_compiler.can_compile():
77 | file_compiler.compile(dest_path, device.march)
78 | else:
79 | file_compiler.copy_to(dest_path)
80 |
81 | shutil.make_archive(dest_path, 'zip', dest_path)
82 |
83 | print(f"{bcolors.OKGREEN}Finished making compiled archives.{bcolors.ENDC}")
84 | os.chdir(OG_DIRECTORY)
85 |
86 |
87 |
88 |
89 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
90 |
91 | class bcolors:
92 | """Small helper for print output coloring."""
93 | HEADER = '\033[95m'
94 | OKBLUE = '\033[94m'
95 | OKCYAN = '\033[96m'
96 | OKGREEN = '\033[92m'
97 | WARNING = '\033[93m'
98 | FAIL = '\033[91m'
99 | ENDC = '\033[0m'
100 | BOLD = '\033[1m'
101 | UNDERLINE = '\033[4m'
102 |
103 |
104 | class Device:
105 | """Store/parse device/platform details."""
106 | def __init__(self, name):
107 | with open(os.path.join(DEVICE_PATH, name, "definition.yml"), 'r', encoding="utf-8") as device_file:
108 | device_def = yaml.safe_load(device_file.read())
109 | self.march = device_def['mpy_arch']
110 | self.name = name
111 |
112 | def __repr__(self):
113 | return f"Device({self.name})"
114 |
115 |
116 |
117 | class FileCompiler:
118 | """Class contains methods for reading and parsing a given file."""
119 | def __init__(self, dir_entry, file_path):
120 | self.relative_path = file_path.removeprefix('/')
121 | self.dir_entry = dir_entry
122 | self.name = dir_entry.name
123 | self.path = dir_entry.path
124 |
125 |
126 | def can_compile(self) -> bool:
127 | """Check if we can actually parse this file (don't parse non-python data files.)"""
128 | if self.name.endswith('.py') and self.name not in NO_COMPILE:
129 | return True
130 | return False
131 |
132 |
133 | def __repr__(self):
134 | return f"FileParser({self.name})"
135 |
136 |
137 | def copy_to(self, dest_path):
138 | """For file types that shouldn't be modified, just copy instead."""
139 | dest_path = os.path.join(dest_path, self.relative_path, self.name)
140 | # make target directory:
141 | os.makedirs(os.path.dirname(dest_path), exist_ok=True)
142 | # write our original file data:
143 | with open(self.path, 'rb') as source_file:
144 | with open(dest_path, 'wb') as new_file:
145 | new_file.write(source_file.read())
146 |
147 |
148 | def compile(self, dest_path, mpy_arch):
149 | """Compile using mpy-cross."""
150 | dest_name = self.name.removesuffix('.py') + '.mpy'
151 | dest_path = os.path.join(dest_path, self.relative_path, dest_name)
152 | # make target directory:
153 | os.makedirs(os.path.dirname(dest_path), exist_ok=True)
154 | # compile with mpy-cross
155 | os.system(f'./mpy-cross "{self.path}" -o "{dest_path}" -march={mpy_arch}')
156 |
157 |
158 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Functions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
159 |
160 | def extract_file_data(dir_entry, path_dir):
161 | """Recursively extract DirEntry objects and relative paths for each file in directory."""
162 | if dir_entry.is_dir():
163 | output = []
164 | for r_entry in os.scandir(dir_entry):
165 | output += extract_file_data(r_entry, f"{path_dir}/{dir_entry.name}")
166 | return output
167 | else:
168 | return [(dir_entry, path_dir)]
169 |
170 |
171 |
172 | def launch_wsl():
173 | """Attempt to use WSL if run from Windows"""
174 | subprocess.call('wsl -e sh -c "python3 tools/compile_hydra_mpy.py"')
175 |
176 | # build process is convoluted on Windows (and not supported by this script)
177 | # so if we are on Windows, try launching WSL instead:
178 | is_windows = os.name == 'nt'
179 |
180 | if is_windows:
181 | print("Running in Windows, attempting to use WSL...")
182 | launch_wsl()
183 | else:
184 | main()
185 |
--------------------------------------------------------------------------------
/tools/create_frozen_folders.py:
--------------------------------------------------------------------------------
1 | """
2 | Copy device files to MicroPython boards folder for building firmwares.
3 | """
4 |
5 | import os
6 | import yaml
7 | import argparse
8 | import subprocess
9 | import shutil
10 | from parse_files import NON_DEVICE_FILES
11 |
12 |
13 | # argparser stuff:
14 | PARSER = argparse.ArgumentParser(
15 | prog='create_frozen_folders',
16 | description="""\
17 | Copy device files to MicroPython boards folder.
18 | """
19 | )
20 |
21 | PARSER.add_argument('-d', '--dest', help='Path to MicroPython boards folder.')
22 | PARSER.add_argument('-D', '--devices', help='Path to device definition folder.')
23 | PARSER.add_argument('-M', '--micropython', help='Path to MicroPython source.')
24 | PARSER.add_argument('-v', '--verbose', action='store_true')
25 | SCRIPT_ARGS = PARSER.parse_args()
26 |
27 | DEST_PATH = SCRIPT_ARGS.dest
28 | DEVICE_PATH = SCRIPT_ARGS.devices
29 | VERBOSE = SCRIPT_ARGS.verbose
30 | MP_PATH = SCRIPT_ARGS.micropython
31 |
32 |
33 | # set defaults for args not given:
34 | CWD = os.getcwd()
35 | OG_DIRECTORY = CWD
36 |
37 | if MP_PATH is None:
38 | MP_PATH = os.path.join(CWD, 'MicroPython')
39 | if DEST_PATH is None:
40 | DEST_PATH = os.path.join(MP_PATH, 'ports', 'esp32', 'boards')
41 | if DEVICE_PATH is None:
42 | DEVICE_PATH = os.path.join(CWD, 'devices')
43 |
44 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MAIN ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
45 | def main():
46 | """
47 | Main script body.
48 |
49 | This file is organized such that the "main" logic lives near the top,
50 | and all of the functions/classes used here are defined below.
51 | """
52 |
53 | # start by copying over custom MicroHydra build files
54 | custom_build_path = os.path.join(DEVICE_PATH, 'esp32_mpy_build')
55 | mpy_esp32_path = os.path.join(MP_PATH, 'ports', 'esp32')
56 | shutil.copytree(custom_build_path, mpy_esp32_path, dirs_exist_ok=True)
57 |
58 | # parse devices into list of Device objects
59 | devices = []
60 | for filepath in os.listdir(DEVICE_PATH):
61 | if filepath not in NON_DEVICE_FILES:
62 | devices.append(Device(filepath))
63 |
64 |
65 | for device in devices:
66 | print(f"Copying board files for {device.name.title()}...")
67 |
68 | device_source_path = os.path.join(DEVICE_PATH, device.name)
69 | board_source_path = os.path.join(DEST_PATH, device.source_board)
70 | dest_path = os.path.join(DEST_PATH, device.name)
71 |
72 | # copy 'source board' as a baseline
73 | source_files = []
74 | for dir_entry in os.scandir(board_source_path):
75 | source_files += extract_file_data(dir_entry, '')
76 |
77 | for dir_entry, file_path in source_files:
78 | file_copier = FileCopier(dir_entry, file_path)
79 | file_copier.copy_to(dest_path)
80 |
81 | # copy device-specific board files
82 | source_files = []
83 | for dir_entry in os.scandir(device_source_path):
84 | source_files += extract_file_data(dir_entry, '')
85 |
86 | for dir_entry, file_path in source_files:
87 | file_copier = FileCopier(dir_entry, file_path)
88 | file_copier.copy_to(dest_path)
89 |
90 |
91 | print("Finished copying MicroPython board files.")
92 | os.chdir(OG_DIRECTORY)
93 |
94 |
95 |
96 |
97 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Classes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
98 |
99 | class bcolors:
100 | """Small helper for print output coloring."""
101 | HEADER = '\033[95m'
102 | OKBLUE = '\033[94m'
103 | OKCYAN = '\033[96m'
104 | OKGREEN = '\033[92m'
105 | WARNING = '\033[93m'
106 | FAIL = '\033[91m'
107 | ENDC = '\033[0m'
108 | BOLD = '\033[1m'
109 | UNDERLINE = '\033[4m'
110 |
111 |
112 | class Device:
113 | """Store/parse device/platform details."""
114 | def __init__(self, name):
115 | with open(os.path.join(DEVICE_PATH, name, "definition.yml"), 'r', encoding="utf-8") as device_file:
116 | device_def = yaml.safe_load(device_file.read())
117 | self.source_board = device_def['source_board']
118 | self.name = name
119 |
120 | def __repr__(self):
121 | return f"Device({self.name})"
122 |
123 |
124 |
125 | class FileCopier:
126 | """Class contains methods for reading and parsing a given file."""
127 | def __init__(self, dir_entry, file_path):
128 | self.relative_path = file_path.removeprefix('/')
129 | self.dir_entry = dir_entry
130 | self.name = dir_entry.name
131 | self.path = dir_entry.path
132 |
133 |
134 | def __repr__(self):
135 | return f"FileParser({self.name})"
136 |
137 |
138 | def copy_to(self, dest_path):
139 | """For file types that shouldn't be modified, just copy instead."""
140 | dest_path = os.path.join(dest_path, self.relative_path, self.name)
141 | # make target directory:
142 | os.makedirs(os.path.dirname(dest_path), exist_ok=True)
143 | # write our original file data:
144 | with open(self.path, 'rb') as source_file:
145 | with open(dest_path, 'wb') as new_file:
146 | new_file.write(source_file.read())
147 |
148 |
149 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Functions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150 |
151 | def extract_file_data(dir_entry, path_dir):
152 | """Recursively extract DirEntry objects and relative paths for each file in directory."""
153 | if dir_entry.is_dir():
154 | output = []
155 | for r_entry in os.scandir(dir_entry):
156 | output += extract_file_data(r_entry, f"{path_dir}/{dir_entry.name}")
157 | return output
158 | else:
159 | return [(dir_entry, path_dir)]
160 |
161 |
162 |
163 | main()
164 |
--------------------------------------------------------------------------------
/tools/generate_default_device.py:
--------------------------------------------------------------------------------
1 | """
2 | This simple script populates the default.yml file with defaults based on the content of all device definition.yml files.
3 | """
4 |
5 | import yaml
6 | import os
7 | from collections import Counter
8 | from parse_files import NON_DEVICE_FILES
9 |
10 |
11 | DEVICE_PATH = "devices"
12 |
13 |
14 |
15 | def extract_file_data(dir_entry, path_dir):
16 | """Recursively extract DirEntry objects and relative paths for each file in directory."""
17 | if dir_entry.is_dir():
18 | output = []
19 | for r_entry in os.scandir(dir_entry):
20 | output += extract_file_data(r_entry, f"{path_dir}/{dir_entry.name}")
21 | return output
22 | else:
23 | return [(dir_entry, path_dir)]
24 |
25 | def fill_device_data():
26 | """Get each device definition"""
27 | all_file_data = []
28 |
29 | for dir_entry in os.scandir(DEVICE_PATH):
30 | if dir_entry.is_dir() and dir_entry.name not in NON_DEVICE_FILES:
31 |
32 | for subdir_entry in os.scandir(dir_entry):
33 | if subdir_entry.name == "definition.yml":
34 | all_file_data.append(subdir_entry)
35 |
36 | return all_file_data
37 |
38 |
39 | def combine_constants(dict_list):
40 | """Combine list of dicts of device constants into one most common dict."""
41 | combined_dict = {}
42 |
43 | # Collect all keys
44 | all_keys = set()
45 | for d in dict_list:
46 | all_keys.update(d.keys())
47 |
48 | # For each key, find the most common value
49 | for key in all_keys:
50 | # list of all vals for this key:
51 | values = [each_dict[key] for each_dict in dict_list if key in each_dict]
52 |
53 | most_common_value = Counter(values).most_common(1)[0][0]
54 | combined_dict[key] = most_common_value
55 |
56 | return combined_dict
57 |
58 | def add_line_break(string, breakpoint):
59 | return string.replace(breakpoint, f'\n{breakpoint}')
60 |
61 | def add_line_breaks(string, breaks):
62 | for breakpoint in breaks:
63 | string = add_line_break(string, breakpoint)
64 | return string
65 |
66 |
67 | def main():
68 | device_data = fill_device_data()
69 | all_features = set()
70 | all_constants = []
71 |
72 | for dir_entry in device_data:
73 | with open(dir_entry, 'r') as def_file:
74 | device_def = yaml.safe_load(def_file.read())
75 | # add features
76 | for feat in device_def['features']:
77 | all_features.add(feat)
78 |
79 | all_constants.append(device_def['constants'])
80 |
81 | default_def = {
82 | "constants": combine_constants(all_constants),
83 | "features": list(all_features),
84 | "mpy_arch": 'xtensawin',
85 | "source_board": "ESP32_GENERIC_S3",
86 | }
87 |
88 | default_file_text = """\
89 | # This file contains MicroHydra defaults that are dynamically generated from
90 | # each DEVICE/definition.yml file.
91 | # This is mainly provided for reference, but also, any values missing from a
92 | # device definition will be loaded from here instead.
93 | #
94 | # 'constants' contains all the existing hydra constants from device definitions,
95 | # plus the most common value.
96 | #
97 | # 'features' contains every single feature that exists in a device definition.
98 |
99 | """ + add_line_breaks(
100 | yaml.dump(default_def),
101 | ("features:", "mpy_arch:", "constants:", "source_board:")
102 | )
103 |
104 | with open(os.path.join(DEVICE_PATH, "default.yml"), "w") as default_file:
105 | default_file.write(default_file_text)
106 |
107 | if __name__ == "__main__":
108 | main()
109 |
--------------------------------------------------------------------------------
/tools/icons/image_to_icon.py:
--------------------------------------------------------------------------------
1 | """
2 | This tool converts an image (any that can be opened by PIL) into a MicroHydra icon,
3 | in the form of a 32*32 raw bitmap
4 | """
5 |
6 | from PIL import Image, ImageOps
7 |
8 | import os
9 | import argparse
10 |
11 |
12 | # argparser stuff:
13 | PARSER = argparse.ArgumentParser(
14 | prog='image_to_icon',
15 | description="""\
16 | Convert an image into a MicroHydra icon file.
17 | """
18 | )
19 |
20 |
21 | PARSER.add_argument('input_image', help='Path to the image file.')
22 | PARSER.add_argument('-o', '--output_file', help='Output file name (defaults to "icon.raw")')
23 | PARSER.add_argument('-i', '--invert', help='Invert the image.', action='store_true')
24 | PARSER.add_argument('-d', '--dither', help='Dither the converted image.', action='store_true')
25 | PARSER.add_argument('-c', '--crop', help='Crop image before scaling.', action='store_true')
26 | PARSER.add_argument('-p', '--preview', help='Open a preview of the converted image.', action='store_true')
27 | SCRIPT_ARGS = PARSER.parse_args()
28 |
29 |
30 | INPUT_IMAGE = SCRIPT_ARGS.input_image
31 | OUTPUT_FILE = SCRIPT_ARGS.output_file
32 |
33 |
34 | # set defaults for args not given:
35 | CWD = os.getcwd()
36 |
37 | if OUTPUT_FILE is None:
38 | OUTPUT_FILE = os.path.join(CWD, 'icon.raw')
39 |
40 |
41 |
42 | WIDTH = 32
43 | HEIGHT = 32
44 |
45 | IMAGE = Image.open(INPUT_IMAGE)
46 |
47 |
48 | if SCRIPT_ARGS.crop:
49 | target_size = min(IMAGE.width, IMAGE.height)
50 | w_crop = (IMAGE.width - target_size) // 2
51 | h_crop = (IMAGE.height - target_size) // 2
52 | IMAGE = IMAGE.crop((w_crop, h_crop, IMAGE.width - w_crop, IMAGE.height - h_crop))
53 |
54 | IMAGE = IMAGE.resize((32, 32))
55 |
56 |
57 | OUTPUT_IMAGE = IMAGE.convert('1', dither=SCRIPT_ARGS.dither)
58 |
59 | if SCRIPT_ARGS.invert:
60 | OUTPUT_IMAGE = ImageOps.invert(OUTPUT_IMAGE)
61 |
62 |
63 |
64 |
65 | # write output file
66 | with open(OUTPUT_FILE, 'wb') as f:
67 | f.write(OUTPUT_IMAGE.tobytes())
68 | print(len(OUTPUT_IMAGE.tobytes()))
69 |
70 | if SCRIPT_ARGS.preview:
71 | PREVIEW = OUTPUT_IMAGE.resize((64,64))
72 | PREVIEW.show()
73 |
--------------------------------------------------------------------------------
/tools/icons/polygon_to_raw_bmp.py:
--------------------------------------------------------------------------------
1 | """
2 | This tool converts the MicroHydra 1.0's depreciated "packed" polygon icon definitions,
3 | to a simple raw bitmap (used by MicroHydra 2.0)
4 | """
5 |
6 | from PIL import Image, ImageDraw
7 |
8 | import os
9 | import argparse
10 |
11 |
12 | # argparser stuff:
13 | PARSER = argparse.ArgumentParser(
14 | prog='polygon_to_raw_bmp',
15 | description="""\
16 | Convert depreciated MicroHydra polygon defs into raw bitmaps.
17 | """,
18 | epilog='''MicroHydra 1.0 used packed polygons for custom launcher icons.
19 | MicroHydra 2.0 uses raw bitmaps for this, and this tool is meant to make that transition simpler.'''
20 | )
21 |
22 | PARSER.add_argument('-f', '--input_file', help='Path to __icon__.txt (or similar) polygon file.')
23 | PARSER.add_argument('-s', '--input_string', help='String containing packed polygon.')
24 | PARSER.add_argument('-o', '--output_file', help='Output file name.')
25 | SCRIPT_ARGS = PARSER.parse_args()
26 |
27 | INPUT_FILE = SCRIPT_ARGS.input_file
28 | INPUT_STRING = SCRIPT_ARGS.input_string
29 | OUTPUT_FILE = SCRIPT_ARGS.output_file
30 |
31 |
32 | # set defaults for args not given:
33 | CWD = os.getcwd()
34 |
35 | if INPUT_FILE is None:
36 | INPUT_FILE = os.path.join(CWD, '__icon__.txt')
37 | if INPUT_STRING is None:
38 | with open(INPUT_FILE, 'r') as f:
39 | INPUT_STRING = f.read()
40 |
41 | if OUTPUT_FILE is None:
42 | OUTPUT_FILE = os.path.join(CWD, 'icon.raw')
43 |
44 |
45 | # initial config:
46 |
47 | WIDTH = 32
48 | HEIGHT = 32
49 |
50 | CONFIG = {'ui_color':1, 'bg_color':0}
51 |
52 | IMAGE = Image.new(mode='1', size=(WIDTH,HEIGHT), color=1)
53 |
54 | DRAW = ImageDraw.Draw(IMAGE)
55 |
56 |
57 |
58 | def unpack_shape(string):
59 | # this weird little function takes the memory-efficient 'packed' shape definition, and unpacks it to a valid arg tuple for DISPLAY.polygon
60 | unpacked = (
61 | "shape="
62 | + string.replace(
63 | 'u', "),CONFIG['ui_color']"
64 | ).replace(
65 | 'b', "),CONFIG['bg_color']"
66 | ).replace(
67 | 'a', "(("
68 | ).replace(
69 | 't', ',True)'
70 | ).replace(
71 | 'f', ',False)'
72 | )
73 | )
74 | loc = {}
75 | exec(unpacked, globals(), loc)
76 | return loc['shape']
77 |
78 |
79 |
80 | def polygon(shape):
81 | coords, clr, fill = shape
82 |
83 | # convert for PIL
84 | xy = []
85 | for i in range(0, len(coords), 2):
86 | x = coords[i]
87 | y = coords[i+1]
88 | xy.append((x,y))
89 |
90 | if fill:
91 | fill=clr
92 | outline=None
93 | else:
94 | fill=None
95 | outline=clr
96 | DRAW.polygon(xy, fill=fill, outline=outline)
97 |
98 | # fill bg
99 | DRAW.rectangle((0,0,32,32), fill=CONFIG['bg_color'])
100 |
101 | # unpack and draw polygons
102 | shapes = unpack_shape(INPUT_STRING)
103 | for shape in shapes:
104 | polygon(shape)
105 |
106 | # write output file
107 | with open(OUTPUT_FILE, 'wb') as f:
108 | f.write(IMAGE.tobytes())
109 |
--------------------------------------------------------------------------------
/tools/microhydra_build_all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Run file parsing script to create device-specific python files
4 | python3 tools/parse_files.py --zip --verbose
5 |
6 | # build mpy-cross so we can compile .mpy files
7 | python3 tools/build_mpy_cross.py
8 |
9 | # compile .mpy files for each device
10 | python3 tools/compile_hydra_mpy.py
11 |
12 |
13 | # now get ready to build .bin files
14 | # first, ensure esp-idf is set up
15 | python3 tools/setup_esp_idf.py
16 |
17 | # now create device folders under esp32/boards
18 | python3 tools/create_frozen_folders.py
19 |
20 | # Run file parsing script for frozen device folders
21 | python3 tools/parse_files.py --frozen --verbose
22 |
23 | # now run script to build each device
24 | python3 tools/compile_firmwares.py
25 |
26 | echo "microhydra_build_all.sh has completed it's work. "
27 |
--------------------------------------------------------------------------------
/tools/quick_format_const.py:
--------------------------------------------------------------------------------
1 | """
2 | TODO: update this script to use yaml instead of json
3 |
4 | This simple script is just designed to extract and print formatted constant declarations
5 | from the device definition files.
6 |
7 | example from default.json:
8 | `"_MH_DISPLAY_WIDTH": "240",`
9 |
10 | example output:
11 | `_MH_DISPLAY_WIDTH = const(240)`
12 |
13 | """
14 | import yaml
15 | import os
16 |
17 |
18 | # set a device name here to use that device
19 | # (otherwise use default)
20 | target_device = None
21 |
22 |
23 | # ~~~~~~~~~ script: ~~~~~~~~~
24 |
25 | # get path based on target device
26 | if target_device is None:
27 | target_file = os.path.join("devices", "default.yml")
28 | else:
29 | target_file = os.path.join("devices", target_device.upper(), "definition.yml")
30 |
31 | # read lines from file
32 | with open(target_file, "rb") as yml_file:
33 | data = yml_file.readlines()
34 |
35 |
36 | for line in data:
37 | line = line.decode()
38 |
39 | # preserve spaces for readability
40 | if line.isspace():
41 | print()
42 | else:
43 | # try formatting into a valid json string
44 | line = line.strip()
45 | line = line.removesuffix("\n")
46 | line = line.removesuffix(",")
47 |
48 | # if json can be read, format it into a micropython const
49 | # else skip
50 | try:
51 | line_data = json.loads("{" + line + "}")
52 | for key, val in line_data.items():
53 | print(f"{key} = const({val})")
54 | except json.decoder.JSONDecodeError:
55 | pass
--------------------------------------------------------------------------------
/tools/setup_esp_idf.py:
--------------------------------------------------------------------------------
1 | """
2 | Script tries to setup esp-idf for building MicroPython.
3 | """
4 |
5 | import subprocess
6 | import os
7 |
8 |
9 |
10 |
11 | def setup_idf():
12 | subprocess.call(["sudo", "./esp-idf/install.sh"])
13 |
14 |
15 | def launch_wsl():
16 | """Attempt to use WSL if run from Windows"""
17 | subprocess.call('wsl -e sh -c "python3 tools/setup_esp_idf.py"')
18 |
19 |
20 | # build process is convoluted on Windows (and not supported by this script)
21 | # so if we are on Windows, try launching WSL instead:
22 | is_windows = os.name == 'nt'
23 |
24 | if is_windows:
25 | print("Running in Windows, attempting to use WSL...")
26 | launch_wsl()
27 | else:
28 | print("Building mpy_cross...")
29 | setup_idf()
30 |
--------------------------------------------------------------------------------
/tools/utf8_util/generate_utf8_font.py:
--------------------------------------------------------------------------------
1 | """Convert a font into a utf8 bin, compatible with MicroHydra."""
2 |
3 | from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFilter, ImageEnhance
4 | import math
5 |
6 |
7 | in_file = "tools/utf8_util/unifont-16.0.01.otf"
8 | out_file = "tools/utf8_util/unifont8x8.bin"
9 |
10 | threshold = 200
11 | text_draw_size = 32
12 |
13 | # Display test images:
14 | testing_chars = {'ಠ', 'ツ', '✿'}
15 |
16 | # how to handle rescaling:
17 | rescale = True
18 | sharpen = True
19 | brightness = 2.0
20 | contrast = 2.0
21 | sharpen = 500
22 |
23 |
24 |
25 |
26 | TEST_IMG = None
27 | class TestImg:
28 | """Stores several test images for comparison."""
29 |
30 | img = None
31 | height = 32
32 | def __init__(self, img: Image.Image):
33 | """Make a new test image."""
34 | TestImg.img = img.resize((int(self.height/img.width*img.width), self.height), resample=Image.Resampling.NEAREST)
35 |
36 | @classmethod
37 | def append(cls, img: Image.Image):
38 | """Append a new image onto the test image."""
39 | img = img.resize((int(cls.height/img.width*img.width), cls.height), resample=Image.Resampling.NEAREST)
40 | new_img = Image.new(mode='L', size=(cls.img.width + img.width, cls.height))
41 | new_img.paste(cls.img, (0,0))
42 | new_img.paste(img, (cls.img.width, 0))
43 | cls.img = new_img
44 |
45 |
46 | class PILFont:
47 | """Outputs font glyphs as images."""
48 |
49 | def __init__(self, font_path: str, font_size: int) -> None:
50 | """Load given font with given size."""
51 | self.__font = ImageFont.FreeTypeFont(font_path, font_size)
52 |
53 | def render_text(self, text: str, offset: tuple[int, int] = (0, 0)) -> Image.Image:
54 | """绘制文本图片.
55 |
56 | > text: 待绘制文本
57 | > offset: 偏移量
58 | """
59 | global TEST_IMG # noqa: PLW0603
60 |
61 | __left, __top, right, bottom = self.__font.getbbox(text)
62 | img = Image.new("1", (right, bottom), color=255)
63 | img_draw = ImageDraw.Draw(img)
64 | img_draw.text(offset, text, fill=0, font=self.__font, spacing=0)
65 |
66 | if text in testing_chars:
67 | TEST_IMG = TestImg(img)
68 |
69 | return img
70 |
71 |
72 | f = PILFont(in_file, text_draw_size)
73 |
74 |
75 | # Rescale helpers:
76 | def mix(val2, val1, fac:float = 0.5) -> float:
77 | """Mix two values to the weight of fac."""
78 | return (val1 * fac) + (val2 * (1.0 - fac))
79 |
80 | def rescale_by_factor(bbox, im) -> tuple[int, int, int, int]:
81 | """Crop all edges by some fraction of the bbox."""
82 | left, top, right, bottom = bbox
83 | return (
84 | int(mix(0, left, 0.8)),
85 | int(mix(0, top, 0.5)),
86 | math.ceil(mix(im.width, right, 0.5)),
87 | math.ceil(mix(im.height, bottom, 0.5)),
88 | )
89 |
90 | def rescale_by_threshold(bbox, im) -> tuple[int, int, int, int]:
91 | """Crop exactly to bbox, except for when it's obvious that this is incorrect."""
92 | left, top, right, bottom = bbox
93 | if left > im.width*3//2:
94 | left = 0
95 | if top > im.height*3//2:
96 | top = 0
97 | if right < im.width//3:
98 | right = im.width
99 | if bottom < im.height//3:
100 | bottom = im.height
101 | return left, top, right, bottom
102 |
103 |
104 | def rescale_glyph(im: Image.Image, text: str) -> Image.Image|None:
105 | """Rescale the given glyph image.
106 |
107 | Uses values from the top of the script.
108 | """
109 | bbox = ImageOps.invert(im).getbbox(alpha_only=False)
110 | # We need gray values for the rescale
111 | im = im.convert('L')
112 |
113 | # guard against blank images:
114 | if bbox is None:
115 | return None
116 |
117 | left, top, right, bottom = bbox
118 |
119 | code = ord(text)
120 | # glyph is large, just add a bit of spacing and crop tight
121 | if right-left > im.width//3 and bottom-top > im.height//3:
122 | right += 1
123 | # glyph is a symbol, crop tight unless threshold reached
124 | elif 0x2010 <= code <= 0x2bff:
125 | left, top, right, bottom = rescale_by_threshold(bbox, im)
126 | # other glyphs are cropped by some factor of the original size
127 | else:
128 | left, top, right, bottom = rescale_by_factor(bbox, im)
129 |
130 | if text in testing_chars:
131 | print(f"Showing glyph for {text}:")
132 | print(f" source size {im.width, im.height}, bbox {bbox} -> {left, top, right, bottom}\n")
133 | TEST_IMG.append(im.crop(bbox))
134 |
135 | im = im.crop((left, top, right, bottom))
136 | return im.resize((8, 8), resample=Image.Resampling.LANCZOS)
137 |
138 |
139 | def i18s_encode(text: str) -> int:
140 | """将文本编码为整数.
141 |
142 | > text: 待编码文本
143 | """
144 | im = f.render_text(text)
145 | width, height = im.size
146 |
147 | if rescale:
148 | im = rescale_glyph(im, text)
149 | if im is None:
150 | return 0
151 | if text in testing_chars:
152 | TEST_IMG.append(im)
153 |
154 | # rarely, glyphs can be too large to encode
155 | elif (width > 8 or height > 8) \
156 | and (width and height):
157 | width = min(width, 8)
158 | height = min(height, 8)
159 | im = im.resize((width, height))
160 |
161 | # These filters dont work on 1-bit images
162 | if im.mode == 'L':
163 | if sharpen:
164 | im = im.filter(ImageFilter.UnsharpMask(radius=1, percent=sharpen))
165 | if brightness:
166 | im = ImageEnhance.Brightness(im).enhance(brightness)
167 | if contrast:
168 | im = ImageEnhance.Contrast(im).enhance(contrast)
169 | if text in testing_chars:
170 | TEST_IMG.append(im)
171 | im = im.convert('1')
172 |
173 | if text in testing_chars:
174 | TEST_IMG.append(im)
175 | TEST_IMG.img.show()
176 |
177 | # Convert px data to integer
178 | cur = 0
179 | for i in range(im.height):
180 | for j in range(im.width):
181 | mi = im.width * i + j
182 | cur += (im.getpixel((j, i)) < threshold) << mi
183 | return cur
184 |
185 |
186 | def _i18s_decode(cur: int, width=8, height=8) -> None:
187 | for _ in range(height):
188 | for _ in range(width):
189 | print('■' if cur & 1 else ' ', end='')
190 | cur >>= 1
191 | print()
192 |
193 |
194 | # 生成Unicode范围内的所有字符, 从 U+0000 到 U+FFFF
195 | start = 0x0000 # 开始范围
196 | end = 0xFFFF # 结束范围
197 |
198 | with open(out_file, "wb") as binary_file:
199 | for codepoint in range(start, end + 1):
200 | char = chr(codepoint)
201 |
202 | # 获取i18s_encode的值
203 | encoded_value = i18s_encode(char)
204 |
205 | # 将i18s_encode的值(8字节)写入文件
206 | t = encoded_value.to_bytes(8, byteorder='big')
207 |
208 | binary_file.write(t)
209 |
--------------------------------------------------------------------------------
/tools/utf8_util/guanzhi.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/utf8_util/guanzhi.ttf
--------------------------------------------------------------------------------
/tools/utf8_util/guanzhi8x8.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/utf8_util/guanzhi8x8.bin
--------------------------------------------------------------------------------
/tools/utf8_util/merge_font_bins.py:
--------------------------------------------------------------------------------
1 | """A tool for merging two bitmap font bins (as output by generate_utf8_font.py).
2 |
3 | This is useful because it makes it easy to fill in missing glyphs,
4 | making the most of our 65535 code points.
5 | """
6 |
7 | # This font is the preferred font
8 | font_main = "tools/utf8_util/guanzhi8x8.bin"
9 | # This font is used when the preferred font is missing glyphs.
10 | font_secondary = "tools/utf8_util/unifont8x8.bin"
11 |
12 | output_name = "tools/utf8_util/utf8_8x8.bin"
13 |
14 |
15 |
16 | empty_bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00'
17 |
18 | count_main = 0
19 | count_second = 0
20 |
21 | with open(font_main, 'rb') as main, \
22 | open(font_secondary, 'rb') as secondary, \
23 | open(output_name, 'wb') as output:
24 | for _ in range(65536):
25 | main_glyph = main.read(8)
26 | secondary_glyph = secondary.read(8)
27 |
28 | if main_glyph == empty_bytes:
29 | output.write(secondary_glyph)
30 | count_second += 1
31 | else:
32 | output.write(main_glyph)
33 | count_main += 1
34 |
35 | print(f"""\
36 | Wrote {count_main + count_second} glyphs.
37 | Used {count_main} glyphs from {font_main},
38 | used {count_second} glyphs from {font_secondary}.
39 | """)
40 |
--------------------------------------------------------------------------------
/tools/utf8_util/unifont-16.0.01.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/utf8_util/unifont-16.0.01.otf
--------------------------------------------------------------------------------
/tools/utf8_util/unifont8x8.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/utf8_util/unifont8x8.bin
--------------------------------------------------------------------------------
/tools/utf8_util/utf8_8x8.bin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-lalia/MicroHydra/1b4ac7e2a4f516b685a83a742d9bf0b5bc150fa6/tools/utf8_util/utf8_8x8.bin
--------------------------------------------------------------------------------
/tools/utf8_util/utf8bin_to_py.py:
--------------------------------------------------------------------------------
1 | """
2 | Simple script to convert the output of `generate_utf8_font.py`
3 | into a .py file that can be frozen in MicroPython firmware.
4 | """
5 |
6 | import os
7 |
8 |
9 | # decide on working in the current, or in the './tools/utf8_util' directory
10 | if os.path.exists(os.path.join('tools','utf8_util','utf8_8x8.bin')):
11 | source_file = os.path.join('tools','utf8_util','utf8_8x8.bin')
12 | dest_file = os.path.join('tools','utf8_util','utf8_8x8.py')
13 | else:
14 | source_file = 'utf8_8x8.bin'
15 | dest_file = 'utf8_8x8.py'
16 |
17 |
18 | with open(source_file, 'rb') as source:
19 | with open(dest_file, 'w') as dest:
20 | dest.write(f"""\
21 | _UTF8 = const({source.read()})
22 | utf8 = memoryview(_UTF8)
23 | """)
24 |
25 |
--------------------------------------------------------------------------------
/wiki/Accessing-config-files.md:
--------------------------------------------------------------------------------
1 | # hydra.config
2 |
3 | User-set settings for the main launcher are stored in /config.json.
4 | These settings can be easily accessed using the built-in hydra.config module.
5 |
6 | ``` Python
7 | from lib.hydra.config import Config
8 |
9 | config = Config()
10 | ```
11 |
12 |
13 |
14 | This Config object provides a simple wrapper for `config.json`. It automatically creates the file if it does not exist, and reads the file from flash. It also automatically generates an extended color palette based on the two user-set BG and UI colors in the config.
15 |
16 | Config variables can be accessed by key, like a dictionary:
17 | ``` Python
18 | wifi_ssid = config["wifi_ssid"]
19 | wifi_pass = config["wifi_pass"]
20 | ```
21 |
22 |
23 |
24 | And, you can access the configured color palette from `config.palette` *(this is the same as lib.display.palette)*
25 |
26 | Here is a quick reference of the color indices:
27 | ``` Python
28 | # 0-10 main user colors
29 | black = config.palette[0]
30 | bg_color = config.palette[2]
31 | ui_color = config.palette[8]
32 | white = config.palette[10]
33 |
34 | # 11-13 primary colors
35 | reddish = config.palette[11]
36 | greenish = config.palette[12]
37 | blueish = condfig.palette[13]
38 |
39 | # 14-15 opposite hues of bg_color and ui_color:
40 | bg_compliment = config.palette[14]
41 | ui_complement = config.palette[15]
42 | ```
43 | For a more complete overview, take a look at the wiki for [lib.display.palette](https://github.com/echo-lalia/Cardputer-MicroHydra/wiki/Palette)
44 |
45 | -----
46 |
47 |
48 |
49 | # json files
50 |
51 | Apps can also easily create and use their own, separate config files using the [json](https://docs.micropython.org/en/latest/library/json.html) module.
52 |
53 | Here's an example of reading from the main config using json:
54 | ``` Python
55 | import json
56 |
57 | with open("config.json", "r") as conf:
58 | config = json.loads(conf.read())
59 | wifi_ssid = config["wifi_ssid"]
60 | wifi_pass = config["wifi_pass"]
61 | ```
62 |
63 | Other stored values can be read the same way.
64 |
65 | **Note:** *It's best if apps don't directly modify the config created by the launcher, as if they do, they risk preventing that config from being read by the launcher. If that happens, the launcher will assume the values are missing or corrupted in some way, and reset config.json with the default values.*
66 |
67 | Apps can create their own config using the same technique used in the launcher, but they should keep a separate file to prevent conflicts between them.
68 |
69 | -----
70 |
71 |
72 |
73 | # NVS
74 |
75 | Lastly, there also exists a builtin module for the ESP32 called [NVS](https://docs.micropython.org/en/latest/library/esp32.html#non-volatile-storage) *(non volatile storage)*, which can also be used to read and store persistent information.
76 |
77 | Generally, I think using json makes more sense in most cases, as it is easier to view and edit those config values.
78 | However, there are definitely some reasonable uses for NVS. For example, for the app FlappyStamp, I'm using NVS to store the user high-score, because it allows the game to be contained in a single file, and makes it *slightly* more difficult to manually edit/fake a high score in the game.
--------------------------------------------------------------------------------
/wiki/App-Format.md:
--------------------------------------------------------------------------------
1 | ## Format of MicroHydra apps:
2 | MicroHydra apps can be placed in the apps folder on the device's main storage, or in the apps folder on an SD Card (The launcher will create these folders automatically if they don't exist.)
3 |
4 | A MicroHydra app is basically just a MicroPython [module](https://docs.python.org/3/tutorial/modules.html) without an [import-guard](https://docs.python.org/3/library/__main__.html#name-main).
5 | Apps can also (*optionally*) import and use MicroHydra modules, in order to simplify app development, or to integrate with core MicroHydra features.
6 |
7 |
8 |
9 | ### Basic App
10 |
11 | All that is needed to make a valid MicroHydra app, is a .py file *(or a compiled .mpy file)* with some MicroPython code, placed in the apps folder.
12 | The file name becomes the app name, and it will be imported by `main.py` when launched using MicroHydra.
13 | This is the simplest form of an MH app, and several apps in the [community apps repo](https://github.com/echo-lalia/MicroHydra-Apps) are designed like this.
14 |
15 |
16 |
17 | ### More Complex App
18 |
19 | Apps that are more complex can be made as a folder, instead of a single file.
20 | This can allow you to bundle in dependencies, or split the code up into multiple files for better organization.
21 |
22 | Inside your app's folder, you'll need an `__init__.py` file.
23 | When your app is imported, `__init__.py` is the specific file that MicroPython will actually be importing on launch. From there, you can import any other modules you'd like.
24 | *(This behavior is mostly identical to CPython)*
25 |
26 | > **Note on relative imports**:
27 | > *If you decide to format your app as a folder, you'll probably want to use 'relative' imports to access the other modules in the app folder.
28 | > However, relative imports don't work when running directly from the editor. My usual solution to this is to just use both relative, and absolute imports, in a `try...except` statement. Here's what that looks like:*
29 | > ``` Python
30 | > try:
31 | > # relative import for launching the app normally
32 | > from . import myothermodule
33 | > except ImportError:
34 | > # absolute path for launching from the editor (which causes the above to fail)
35 | > from apps.myappname import myothermodule
36 | > ```
37 |
38 |
39 |
40 | ## App Icons:
41 |
42 | **To put it simply:**
43 | MicroHydra app icons are 32x32, 1bit, raw bitmaps (not bmp files) named `icon.raw`. Your app icon should be placed in the main directory of your app, alongside the `__init__.py` file.
44 | You can simply create these files using the `image_to_icon.py` file in the `tools/icons` folder.
45 |
46 |
47 |
48 | **And, for a more thorough explanation:**
49 |
50 | MicroHydra icons are raw, 1bit, 32x32 bitmaps.
51 |
52 | The built-in apps use a Python file with the bitmaps stored as constants, but this method can't be used by every because it would use too much memory.
53 | Instead, MH icon files contain only the byte-information of the image, so that it can be loaded directly into a MicroPython framebuffer without modification.
54 |
55 |
56 |
57 | I have written a script to easily create these Icons named `image_to_icon.py`.
58 | To use the script, make sure you have Python 3 installed on your computer, along with Pillow (a Python image editing library):
59 | ```
60 | python3 -m pip install --upgrade pip
61 | python3 -m pip install --upgrade Pillow
62 | ```
63 |
64 |
65 | After that, you can run the script using this syntax:
66 | ```
67 | python3 path/to/image_to_icon.py path/to/your_image_file.png
68 | ```
69 | The above example will create an `icon.raw` file in your current directory based on the image file you passed it.
70 |
71 | The image can be any format supported by Pillow, and you can also specify other arguments to change the way your image is converted:
72 | ```
73 | python3 path/to/image_to_icon.py --output_file path/to/output_filename.raw --invert --preview path/to/your_image_file.png
74 | ```
75 | *(use --help to see all options)*
76 |
77 |
78 |
79 | > Quick note on old MH icons:
80 | > *The previous version of MicroHydra used a bizzare method of packing vectorized/polygonal icon definitions into a short string, which would be unpacked and executed by the launcher. This strategy was chosen for memory efficiency, but it felt awkward and is not used anymore. The script `polygon_to_raw_bmp.py` from the `tools/icons` folder has been written to convert these old polygon defs if needed.*
81 |
82 |
83 |
--------------------------------------------------------------------------------
/wiki/Device.md:
--------------------------------------------------------------------------------
1 | ## lib.device.Device
2 |
3 | `device` is an automatically generated module containing the `Device` class. This class provides access to device-specific constants (as set in the `device/DEVICENAME/definition.yml` file, and is designed to assist in building multi-platform apps.
4 |
5 | The class has two main attributes:
6 | `Device.vals` contains a dictionary of constants for this device.
7 | `Device.feats` contains a tuple of features that this device has.
8 |
9 | Usage examples:
10 | ``` Py
11 | from lib.device import Device
12 |
13 | # acess device constants
14 | width = Device.display_width
15 | height = Device.display_height
16 | name = Device.name
17 |
18 | # Check for device features
19 | if 'touchscreen' in Device:
20 | get_touch()
21 |
22 | if `keyboard` in Device or 'CARDPUTER' in device:
23 | keyboard_stuff()
24 | ```
25 |
26 | > Note: `Device` is a singleton and can't be called or re-initialized after importing.
27 |
--------------------------------------------------------------------------------
/wiki/Home.md:
--------------------------------------------------------------------------------
1 | ### Welcome to the MicroHydra wiki!
2 |
3 | *This wiki is community-editable! If you'd like to help clarify or expand its contents, just fork this repo, make your changes to [/wiki](https://github.com/echo-lalia/MicroHydra/tree/main/wiki), and submit a pull request :)*
4 |
5 |
6 |
7 | ## Multiplatform support
8 | MicroHydra uses a few different ideas in order to output code for multiple devices. You can learn about this [here](https://github.com/echo-lalia/MicroHydra/wiki/multi-platform).
9 | You can also learn more about the supported devices [here](https://github.com/echo-lalia/MicroHydra/wiki/Supported-Devices).
10 |
11 |
12 |
13 | ## Making Apps
14 | For a basic overview of how MicroHydra apps work, see the [App Format](https://github.com/echo-lalia/MicroHydra/wiki/App-Format) section.
15 |
16 |
17 | ### Lib:
18 |
19 | MicroHydra includes a built-in library, intended to help you easily make apps, and integrate with core MicroHydra functionality. Click on a module below to learn more about it.
20 |
21 |
22 | *MicroHydra*
23 | ├── $font$
24 | ├── $launcher$
25 | │ ├── $icons$
26 | │ ├── files
27 | │ ├── HyDE
28 | │ ├── launcher
29 | │ ├── settings
30 | │ └── terminal
31 | │
32 | ├── $lib$
33 | │ ├── [audio](https://github.com/echo-lalia/MicroHydra/wiki/Playing-Sound)
34 | │ ├── [display](https://github.com/echo-lalia/MicroHydra/wiki/Display)
35 | │ │ ├── [palette](https://github.com/echo-lalia/MicroHydra/wiki/Palette)
36 | │ │ └── [namedpalette](https://github.com/echo-lalia/MicroHydra/wiki/Palette#libdisplaynamedpalettenamedpalette)
37 | │ │
38 | │ ├── $hydra$
39 | │ │ ├── [beeper](https://github.com/echo-lalia/MicroHydra/wiki/Playing-Sound#beeper)
40 | │ │ ├── [color](https://github.com/echo-lalia/MicroHydra/wiki/color)
41 | │ │ ├── [config](https://github.com/echo-lalia/MicroHydra/wiki/Accessing-config-files)
42 | │ │ ├── i18n
43 | │ │ ├── [menu](https://github.com/echo-lalia/MicroHydra/wiki/HydraMenu)
44 | │ │ ├── [popup](https://github.com/echo-lalia/MicroHydra/wiki/popup)
45 | │ │ └── simpleterminal
46 | │ │
47 | │ ├── [userinput](https://github.com/echo-lalia/MicroHydra/wiki/userinput)
48 | │ ├── battlevel
49 | │ ├── [device](https://github.com/echo-lalia/MicroHydra/wiki/Device)
50 | │ ├── sdcard
51 | │ └── zipextractor
52 | │
53 | └── main
54 |
55 | ### Other Guides:
56 | - [Connecting to the internet](https://github.com/echo-lalia/MicroHydra/wiki/Internet)
57 |
58 |
--------------------------------------------------------------------------------
/wiki/HydraMenu.md:
--------------------------------------------------------------------------------
1 | [HydraMenu.py](https://github.com/echo-lalia/Cardputer-MicroHydra/blob/main/src/lib/hydra/menu.py) is a module contributed by [Gabriel-F-Sousa](https://github.com/echo-lalia/Cardputer-MicroHydra/commits?author=Gabriel-F-Sousa), which is designed to make it easy to create menu screens for MicroHydra apps.
2 |
3 | HydraMenu is being utilized heavily by the (newly refurbished) inbuilt [settings app](https://github.com/echo-lalia/Cardputer-MicroHydra/blob/main/src/launcher/settings.py). Please take a look at that file for some practical examples of what can be done with the module.
4 |
5 | Here's a simplified example, for your reference:
6 | ``` Python
7 | from lib.display import Display
8 | from lib.userinput import UserInput
9 | from lib.hydra.config import Config
10 | from lib.hydra import menu as HydraMenu
11 |
12 | # ...
13 | userinput = UserInput()
14 | display = Display()
15 | config = Config()
16 | # ...
17 |
18 |
19 | """ Create our HydraMenu.Menu:
20 | """
21 | menu = HydraMenu.Menu()
22 |
23 |
24 | """Add menu items to the menu:
25 | """
26 |
27 | # this is an integer menu item:
28 | menu.append(HydraMenu.IntItem(
29 | # items should be passed their parent menu, and they can be given display text.
30 | menu=menu, text="IntItem",
31 | # Items can be given a value as well. For an IntItem, this value should be an int
32 | value=5,
33 | # some MenuItems also have special keywords that can be used for further options.
34 | # int items, for example, can be given a minimum and maximum value.
35 | min_int=0, max_int=10,
36 | # callbacks are what makes this all functional.
37 | # Pass the function you want to be called every time a value is confirmed.
38 | callback=lambda item, val: print(f"Confirmed val: {val}"),
39 | # You can also use an 'instant_callback' if you want to track the value as it changes.
40 | # this is what the main settings app uses to update the volume, and ui colors as they're changed.
41 | instant_callback=lambda item, val: print(val)
42 | ))
43 |
44 | ...
45 |
46 | # create a variable to remember/decide when we need to redraw the menu:
47 | redraw = True
48 |
49 | # this loop will run our menu's logic.
50 | while True:
51 |
52 | # get our newly pressed keys
53 | keys = userinput.get_new_keys()
54 |
55 | # pass each key to the handle_input method of our menu.
56 | for key in keys:
57 | menu.handle_input(key)
58 |
59 |
60 | # when any key is pressed, we must redraw:
61 | if keys:
62 | redraw = True
63 |
64 | # this is used to prevent unneeded redraws (and speed up the app)
65 | # just calling menu.draw and display.show every loop also works, but it feels slower.
66 | if redraw:
67 | # menu.draw returns True when it is mid-animation,
68 | # and False when the animation is done (therefore, does not need to be redrawn until another key is pressed)
69 | redraw = menu.draw()
70 | display.show()
71 |
72 | ```
73 |
--------------------------------------------------------------------------------
/wiki/Internet.md:
--------------------------------------------------------------------------------
1 | ## Connecting to the internet
2 |
3 | MicroPython's built-in [`network`](https://docs.micropython.org/en/latest/library/network.WLAN.html#network.WLAN) module makes it easy to connect to the internet on an ESP32 device.
4 | You can also use MicroHydra's `hydra.config` module to easily access the user-set wifi configuration.
5 |
6 | Here's a really simple script that connects to WiFi using these:
7 |
8 | ```Py
9 | import network
10 | from lib.hydra.config import Config
11 |
12 | # Create the object for network control
13 | nic = network.WLAN(network.STA_IF)
14 | # Get the MicroHydra config
15 | config = Config()
16 |
17 | # Turn on the WiFi
18 | if not nic.active():
19 | nic.active(True)
20 |
21 | # Connect to the user-set wifi network
22 | if not nic.isconnected():
23 | nic.connect(
24 | config['wifi_ssid'],
25 | config['wifi_pass'],
26 | )
27 | ```
28 |
29 | The `nic.connect` command doesn't block while waiting for a connection. So, your script will need to wait until the connection is made.
30 | There can also be some unpredictable errors raised when calling the connection method.
31 |
32 | Here's an example connection function that tries to handle these potential obsticals *(similar to the function used in `getapps.py`)*:
33 | ```Py
34 | import time
35 | import network
36 | from lib.hydra.config import Config
37 |
38 |
39 | nic = network.WLAN(network.STA_IF)
40 | config = Config()
41 |
42 |
43 | def connect_wifi():
44 | """Connect to the configured WiFi network."""
45 | print("Enabling wifi...")
46 |
47 | if not nic.active():
48 | nic.active(True)
49 |
50 | if not nic.isconnected():
51 | # tell wifi to connect (with FORCE)
52 | while True:
53 | try: # keep trying until connect command works
54 | nic.connect(config['wifi_ssid'], config['wifi_pass'])
55 | break
56 | except OSError as e:
57 | print(f"Error: {e}")
58 | time.sleep_ms(500)
59 |
60 | # now wait until connected
61 | attempts = 0
62 | while not nic.isconnected():
63 | print(f"connecting... {attempts}")
64 | time.sleep_ms(500)
65 | attempts += 1
66 |
67 | print("Connected!")
68 |
69 | connect_wifi()
70 | ```
71 |
72 |
73 |
74 | ## Getting Data From the Internet
75 |
76 | MicroPython provides a lower-level [`socket`](https://docs.micropython.org/en/latest/library/socket.html#module-socket) module, but the easiest way to make internet requests in most cases is to use the other built-in [`requests`](https://github.com/micropython/micropython-lib/tree/e4cf09527bce7569f5db742cf6ae9db68d50c6a9/python-ecosys/requests) module.
77 |
78 | Here's a super simple example that fetches a random cat fact from meowfacts.herokuapp.com:
79 |
80 |
81 | ```Py
82 | import json
83 | import requests
84 |
85 | # Make a request to meowfacts
86 | response = requests.get("https://meowfacts.herokuapp.com/")
87 | # Verify that the request worked
88 | if response.status_code != 200:
89 | raise ValueError(f"Server returned {response.status_code}.\n{response.reason}")
90 |
91 | # Decode the returned JSON data, and extract the random fact
92 | fact = json.loads(response.content)['data'][0]
93 | print(fact)
94 | ```
95 |
--------------------------------------------------------------------------------
/wiki/MH-Origins.md:
--------------------------------------------------------------------------------
1 | ## The Origins of MicroHydra:
2 | > *This is a rambling recount on the origins of MicroHydra, by echo-lalia (me)*
3 | > *You probably don't need to know any of this to use or contribute to MicroHydra, but I just wanted to write it down somewhere for myself, and anyone who is curious.*
4 |
5 |
6 |
7 | ### Cardputer-MicroHydra
8 |
9 | When I got my Cardputer in the mail, there was very little software or documentation available for it,
10 | and M5Burner didn't even have a dedicated section for the Cardputer yet.
11 | (In fact, I don't think the *demo firmware, or UIFlow*, for the Cardputer were made public yet).
12 | I started by installing MicroPython on it just to play around, and tried figuring out how to get each peripheral working.
13 |
14 | The demo firmware that came on the device got a lot of people (myself included) excited about the possibilities.
15 | However, there was no way to easily extend the demo firmware, and I preferred working in MicroPython, anyways.
16 | I had previous experience with MicroPython on the RP2040, and hit a lot of roadblocks regarding memory usage.
17 | So, I knew that if I wanted to support a lot of different kinds of features in my program, I'd need to find a way to load only what was needed at any given time.
18 |
19 | I came up with the idea of resetting the device between every app, to allow the RAM to be fully cleared each time.
20 | To do this, there would need to be some way to maintain some memory of what we were doing between resets.
21 | Thankfully, the ESP32-S3 has a built-in RTC with it's own memory that doesn't reset when the main CPU does,
22 | and MicroPython provides a really easy way to read/write from it.
23 |
24 | So, I designed a really simple `main.py` script that would read the RTC memory, and import whatever file it pointed to.
25 | And, I also designed a barebones `launcher.py` that would scan for `.py` files in the `apps/` directory, and allow you to pick one.
26 | Once an app was picked, its path would be loaded into the RTC memory, and the device would reset.
27 |
28 |
29 |
30 | Initial impressions of the software were really positve, and people in the Cardputer Discord were super enthusiastic about the possibilities.
31 | People in the community were talking about what a potential 'ideal' software/operating system for the Cardputer might have in it.
32 | MicroPython is a really awesome and feature-rich project, and it made it really easy and quick to add a lot of the requested 'OS-like' features to MH.
33 | And so I read all of the community suggestions, and just kept adding new features into the program.
34 |
35 | The more I added the more enthusiasticly people responded to it, and so I was motivated to just keep adding more.
36 | Eventually, community members even started making pull requests with their own enhancements, and the software grew rapidly.
37 |
38 |
39 |
40 | ### Supporting more devices
41 |
42 | The project was named "Cardputer-MicroHydra" because it was intended to be the version of MicroHydra specific to the Cardputer.
43 | Eventually I thought I might fork the project and modify it for other devices.
44 | But, as the project got bigger, I realized this would take an increasingly huge amount of work, and that once I did that,
45 | it would become very difficult to extend community contributions to each completely separate version of MicroHydra.
46 |
47 | This kinda bummed me out as it seemed like once I got bored of working on the Cardputer (or M5Stack stopped selling them),
48 | the software would almost certainly be useless. Or at the very least, the community would be fragmented.
49 |
50 | *So*, I basically tried to polish up and 'officially' release a complete version with all of the features currently in MH (v1.0),
51 | and got to work trying to restructure the program so that it could potentially support multiple devices.
52 |
53 | This sucked 😅
54 |
55 | Working on MicroHydra is really fun for me, and I could practically spend all day doing it.
56 | However, the original code was so specific to the Cardputer that I had to almost completely rewrite a lot of the modules and built-in apps for MH2.0.
57 | I also had to restructure the repository and write scripts for automating the process of assembling/exporting the device-specific builds of MH.
58 | For the majority of the process, I wasn't even certain that my changes would ever be finished/released.
59 |
60 | Once I was able to get the same launcher to start up on both the TDeck and the Cardputer, though, things became fun again, and all that work was very much worth it!
61 | At the time of writing, MH2.0 has been released, and I am again excited about the potential of this project.
62 | When time comes to support another new device there will probably be new challenges, but I hopefully I will not have to rewrite it again.
63 |
64 |
--------------------------------------------------------------------------------
/wiki/Palette.md:
--------------------------------------------------------------------------------
1 | ## lib.display.palette.Palette
2 |
3 | This Palette class is designed to be used for storing a list of RGB565 colors,
4 | and for returning the apropriate colors by index to be used with MicroHydra's display module.
5 |
6 | When `Config` from `lib.hydra.config` is initialized, it reads the user set color data from `config.json` and creates a 16-color palette from it.
7 |
8 |
9 |
10 | Key notes on Palette:
11 | - Has a static length of 16 colors
12 |
13 | - Is used by both `lib.hydra.config.Config` and `lib.display.Display` (it is the same Palette in both)
14 |
15 | - uses a bytearray to store color information,
16 | this is intended for fast/easy use with Viper's ptr16.
17 |
18 | - Returns an RGB565 color when using normal framebuffer, or an index when Display is initialized with `use_tiny_buf`.
19 | (This makes it so that you can pass a `Palette[i]` to the Display class in either mode.)
20 |
21 | - Palette is a singleton, which is important so that different MH classes can modify and share it's data
22 | (without initializing the Display).
23 |
24 |
25 | Retrieving a color from the Palette is fairly fast, but if you want to maximize your speed, it's probably smart to read and store the colors you need as local variables (after initializing the `Display` and `Config`).
26 |
27 |
28 |
29 | For your reference, here is a complete list of the colors, by index, contained in the palette:
30 |
31 | - Black
32 | - Darker bg_color
33 | - bg_color
34 |
35 |
36 |
37 | - 84% bg_color 16% ui_color
38 | - 67% bg_color 33% ui_color
39 | - 50% bg_color 50% ui_color (mid_color)
40 | - 33% bg_color 67% ui_color
41 | - 16% bg_color 84% ui_color
42 |
43 |
44 |
45 | - ui_color
46 | - Lighter ui_color
47 | - white
48 |
49 |
50 |
51 | - reddish bg_color
52 | - greenish mid_color
53 | - bluish ui_color
54 |
55 |
56 |
57 | - compliment bg_color (opposite hue)
58 | - compliment ui_color
59 |
60 |
61 |
62 |
63 |
64 | ## lib.display.namedpalette.NamedPalette
65 |
66 | As an alternative, for improved readability and simplicity, you can import the optional `NamedPalette` class, which works similarly to the normal `Palette`, but also accepts strings containing color names.
67 |
68 | ``` Py
69 | names = {
70 | 'black':0,
71 | 'bg_dark':1,
72 | 'bg_color':2,
73 | 'mid_color':5,
74 | 'ui_color':8,
75 | 'ui_light':9,
76 | 'white':10,
77 | 'red':11,
78 | 'green':12,
79 | 'blue':13,
80 | 'bg_complement':14,
81 | 'ui_complement':15,
82 | }
83 | ```
84 |
85 |
86 |
--------------------------------------------------------------------------------
/wiki/Playing-Sound.md:
--------------------------------------------------------------------------------
1 | There are two main modules built-in to MicroHydra currently, which can be used for playing sound on the Cardputer.
2 |
3 |
4 |
5 |
6 | ### lib.audio.Audio
7 | MicroHydra's [audio](https://github.com/echo-lalia/Cardputer-MicroHydra/tree/main/src/lib/audio) module subclasses the apropriate audio module for the current device (Currently this is only `i2ssound`, but may be expanded in the future), and initializes it with the apropriate values for the device.
8 |
9 | Note:
10 | > *`i2ssound` was previously named `M5Sound`, and was contributed by [Lana-chan](https://github.com/echo-lalia/Cardputer-MicroHydra/commits?author=Lana-chan), for playing high-quality sound on the Cardputer.
11 | > It has been renamed for consistency with MicroHydras other modules.*
12 |
13 | It can play samples stored in a variable, or read large samples from storage using little memory.
14 | It also can change the pitch of those samples, and even play several at the same time, overlapping.
15 |
16 |
17 |
18 | *basic usage example (ALSO provided by Lana-chan):*
19 |
20 |
21 | ``` Python
22 | import time
23 | from lib.audio import Audio
24 |
25 | audio = Audio()
26 |
27 | _SQUARE = const(\
28 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'\
29 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'\
30 | b'\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80\x00\x80'\
31 | b'\x00\x80\x00\x80\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'\
32 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'\
33 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F'\
34 | b'\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\xFF\x7F\x00\x80'
35 | )
36 | SQUARE = memoryview(_SQUARE)
37 |
38 | for note in range(24,60): # note values, C-3 to B-5
39 | audio.play(SQUARE, note, 0, 14, 0, True)
40 | for i in range(13,1,-1): # fade out volume
41 | audio.setvolume(i)
42 | time.sleep(0.05)
43 | audio.stop(0)
44 | ```
45 |
46 | Samples can also be loaded from a 'raw' file on the flash or SDCard.
47 |
48 | ``` Python
49 | i2ssound.Sample(source, buffer_size=1024)
50 | """Initialize a sample for playback
51 |
52 | - source: If string, filename. Otherwise, use MemoryView.
53 | - buffer_size: If loading from filename, the size to buffer in RAM.
54 | """
55 | ```
56 |
57 | -----
58 |
59 |
60 |
61 | ### beeper
62 | [beeper.py](https://github.com/echo-lalia/Cardputer-MicroHydra/blob/main/src/lib/hydra/beeper.py) is a module for playing simple UI beeps.
63 |
64 | This module is very imperfect. It is somewhat limited in its use, however, it *is* simple to use.
65 | > In previous versions, this module used its own, blocking, implementation for sending audio to the speaker. However, the current version is just a wrapper for the `Audio` class above. The audio is now much higher quality and more consistent, however there is a noticable delay in it. I would like to fix this in the future, but I'm not sure how to do that.
66 |
67 |
68 |
69 | To use it:
70 |
71 | ``` Python
72 | from lib.hydra import beeper
73 | ```
74 |
75 | ``` Python
76 | #init the beeper!
77 | beep = beeper.Beeper()
78 |
79 | beep.play(
80 | # a tuple of strings containing notes to play. a nested tuple can be used to play multiple notes together.
81 | notes=('C4',('D4','D4')),
82 | # how long to play each note
83 | time_ms=120,
84 | # an integer from 0-10 representing the volume of the playback. Default is 2, 0 is almost inaudible, 10 is loud.
85 | volume=10,
86 | )
87 | ```
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/wiki/Supported-Devices.md:
--------------------------------------------------------------------------------
1 | Currently, MicroHydra supports 2 devices (the Cardputer and the TDeck), with the intention to add more in the future.
2 |
3 |
4 |
5 |
6 |
7 | # Cardputer
8 | The M5Stack Cardputer is the device MicroHydra was [first designed](https://github.com/echo-lalia/MicroHydra/wiki/MH-Origins) for.
9 |
10 | ### Pros:
11 | * Because it was the first device supported by MicroHydra, most of the features are heavily geared towards this form-factor,
12 | and feel very comfortable on the Cardputer, specifically.
13 |
14 | ### Cons:
15 | * The 512kb of RAM on the Cardputer can sometimes feel very limiting in MicroHydra.
16 |
17 |
18 |
19 |
20 |
21 | # TDeck
22 | > ***Important note:** The keyboard that comes with the TDeck has a separate ESP32-C3 which communicates with the main ESP32-S3 through I²C.*
23 | > *The firmware that comes on the keyboard does not allow many features MicroHydra requires,
24 | > (Keys can't be held, modifier keys do nothing, not many symbols can be typed etc).
25 | > *I have made an [updated firmware](https://github.com/echo-lalia/t-deck-keyboard-hydra), which adds several key features,
26 | > including the ability to enable a 'raw' output to the main MCU, while maintaining backwards-compatibility with the old firmware.*
27 | > *In order to flash this firmware, you must solder pins, and connect a **USB to TTL converter** onto the TDeck, which I found very annoying to do.*
28 | >
29 | > *The `_keys` module for the TDeck does also have a backwards-compatibility mode for when it doesn't detect the new firmware. However, the experience is sub-par.*
30 |
31 | The Lilygo T-Deck was chosen as the second device to support because it had the same ESP32-S3 MCU, and ST7789 display as the Cardputer.
32 | *(Also, I just thought it looked neat.)*
33 |
34 | ### Pros:
35 | * The 8MB of octal SPI-RAM means you practically never have to worry about memory usage in TDeck apps.
36 | * Touch support for the built-in apps was added for the T-Deck, and works fairly well.
37 |
38 | ### Cons:
39 | * The trackball on the T-Deck is not very accurate, and is slightly hard to control on some menus.
40 | * The default keyboard firmware is bad, and must be updated to get all the features. This involves soldering and using a USB to TTL adapter.
41 | * The keyboard has fewer keys than the Cardputer, and the legend on the keyboard varies randomly between units,
42 | so you need to memorize certain key-combos to get full use out of it.
43 |
44 |
--------------------------------------------------------------------------------
/wiki/color.md:
--------------------------------------------------------------------------------
1 | ## hydra.color
2 |
3 | This module contains some color logic used by MicroHydra.
4 | Previously these functions lived in `lib.microhydra` (or in `lib.st7789py`).
5 |
6 |
7 |
8 | ## Color mixing functions:
9 |
10 | `mix_color565(color1, color2, mix_factor=0.5, hue_mix_fac=None, sat_mix_fac=None)`
11 | > High quality mixing of two rgb565 colors, by converting through HSV color space.
12 | >
13 |
14 |
15 | `darker_color565(color, mix_factor=0.5)`
16 | > Get a darker version of a 565 color.
17 | >
18 |
19 |
20 | `lighter_color565(color, mix_factor=0.5)`
21 | > Get a lighter version of a 565 color.
22 | >
23 |
24 |
25 | `color565_shiftred(color, mix_factor=0.4, hue_mix_fac=0.8, sat_mix_fac=0.8)`
26 | > Simple convenience function which shifts a color toward red.
27 | > This was made for displaying 'negative' ui elements, while sticking to the central color theme.
28 | >
29 |
30 |
31 | `color565_shiftgreen(color, mix_factor=0.1, hue_mix_fac=0.4, sat_mix_fac=0.1)`
32 | > Simple convenience function which shifts a color toward green.
33 | > This was made for displaying 'positive' ui elements, while sticking to the central color theme.
34 | >
35 |
36 |
37 | `color565_shiftblue(color, mix_factor=0.1, hue_mix_fac=0.4, sat_mix_fac=0.2)`
38 | > Simple convenience function which shifts a color toward blue.
39 | >
40 |
41 |
42 | `compliment_color565(color)`
43 | > Generate a complimentary color from given RGB565 color.
44 | >
45 |
46 |
47 |
48 | ## Conversion functions:
49 |
50 | `color565(r:int, g:int, b:int) -> int`
51 | > Convert 24bit (0-255) RGB values into 16bit 565 format.
52 | > Returns a single 565-encoded integer.
53 | >
54 |
55 |
56 | `separate_color565(color)`
57 | > Separate a 16-bit 565 encoding into red, green, and blue components.
58 | >
59 |
60 |
61 | `combine_color565(red, green, blue)`
62 | > Combine red, green, and blue components into a 16-bit 565 encoding.
63 | >
64 |
65 |
66 | `rgb_to_hsv(r, g, b)`
67 | > Convert an RGB float to an HSV float.
68 | >
69 |
70 |
71 | `hsv_to_rgb(h, s, v)`
72 | > Convert an HSV float to an RGB float.
73 | >
74 |
75 |
76 |
77 | ## Misc functions:
78 |
79 | `swap_bytes(color:int) -> int`
80 | > Swap the left and right bytes in a 16 bit color.
81 | > This is mainly used internally to unswap colors that would be swapped when writing to the display.
82 | >
83 |
84 |
85 | `mix(val2, val1, fac=0.5)`
86 | > Mix two values to the weight of `fac`
87 | >
88 |
89 |
90 | `mix_angle_float(angle1, angle2, factor=0.5)`
91 | > Take two angles as floats (range 0.0 to 1.0) and average them to the weight of `factor`.
92 | > Mainly for blending hue angles.
93 | >
94 |
95 |
96 |
--------------------------------------------------------------------------------
/wiki/popup.md:
--------------------------------------------------------------------------------
1 | ## lib.hydra.popup
2 |
3 | MicroHydra includes a module called `popup`, which provides some simple, common tools for displaying various UI popups and overlays.
4 |
5 | To use the module, you must first import it, create a display object, and create the `popup.UIOverlay` object to access its methods.
6 |
7 | ``` Python
8 | from lib.display import Display
9 | from lib.hydra import popup
10 |
11 | # create the display object first
12 | # (Display must be initialized before UIOverlay can work)
13 | display = Display()
14 | # Create our overlay object
15 | overlay = popup.UIOverlay()
16 |
17 | # this demo creates a list of options, and allows the user to select one.
18 | demo = overlay.popup_options(
19 | options = ('text entry', '2d options', 'popup message', 'error message'),
20 | title = "Pick a demo:"
21 | )
22 |
23 | # popup_options returns a string, so we can use this to select another demo to display:
24 | if demo == 'text entry':
25 | # this allows the user to enter some text, and returns it as a string.
26 | print(overlay.text_entry(start_value='', title="Enter text:"))
27 |
28 | elif demo == '2d options':
29 | # popup options also allows you to make several columns using nested lists (or tuples)
30 | print(overlay.popup_options(
31 | options = (
32 | ('you', 'also', 'more'),
33 | ('can', 'make', 'columns.'),
34 | ('and', 'they', 'can', 'have', 'different', 'lengths')
35 | ),
36 | ))
37 |
38 | elif demo == 'popup message':
39 | # this simply displays a message on the screen, and blocks until the user clicks any button
40 | print(overlay.popup("This is a test"))
41 |
42 | elif demo == 'error message':
43 | # this is exactly the same as overlay.popup, but with custom styling for an error message
44 | print(overlay.error("This is an error message"))
45 | ```
46 |
47 |
--------------------------------------------------------------------------------
/wiki/userinput.md:
--------------------------------------------------------------------------------
1 |
2 | ### lib.userinput.UserInput
3 |
4 | [userinput](https://github.com/echo-lalia/Cardputer-MicroHydra/blob/main/src/lib/userinput/userinput.py) provides a all of a devices physical inputs in one place.
5 |
6 | At its core, the module is based on the device-specific `_keys` module, which provides logic for reading the keypresses and converting them into readable strings. The `UserInput` class subclasses the `_keys.Keys` class, and inherits those device-specific features.
7 | `UserInput` can also provide other kinds of input, such as providing touch data from a device with a touchscreen. These extra features, of course, are based on the specific device being used.
8 |
9 | The `UserInput` class also handles key-repeating behaviour, and locking modifier keys.
10 |
11 | > *This module was previously the `lib.smartkeyboard` module. It has been renamed and expanded for MicroHydra 2.x to support different devices and input methods.*
12 |
13 |
14 |
15 |
16 | # UserInput:
17 |
18 | ## Constructor:
19 |
20 | `userinput.UserInput(hold_ms=600, repeat_ms=80, use_sys_commands=True, allow_locking_keys=False, **kwargs)`
21 | > Creat the object for accessing user inputs.
22 | >
23 | > Args:
24 | > * `hold_ms`:
25 | > The amount of time, in milliseconds, a key must be held before it starts to repeat.
26 | >
27 | > * `repeat_ms`:
28 | > While a key is being held and repeating, how long between repetitions.
29 | >
30 | > * `use_sys_commands`:
31 | > Whether or not to enable keyboard shortcuts for built-in system commands
32 | >
33 | > * `allow_locking_keys`:
34 | > Whether or not to allow modifier keys to 'lock' (stay activated when tapped). This draws an overlay on the screen using the display module.
35 | >
36 | > * `**kwargs`:
37 | > Any other keyword args given are passed along to the `_keys.Keys` class, allowing for device-specific options to be used.
38 | >
39 |
40 |
41 |
42 | ## Read keys:
43 |
44 | `UserInput.get_pressed_keys()`
45 | > Return a list of strings, representing the names of keys that are currently being pressed.
46 | >
47 |
48 |
49 | `UserInput.get_new_keys()`
50 | > Return a list of strings, representing the names of keys that are newly pressed (are now pressed but were not pressed last time we checked).
51 | >
52 | > This method also runs additional logic based on the settings provided in the constructor:
53 | > - key repeating logic:
54 | > Keys will return when they are new, *and* periodically after they have been held down for the time specified by `UserInput.hold_ms`
55 | >
56 | > - locking keys logic:
57 | > When `allow_locking_keys` is `True`, if a modifier key is pressed without pressing another key, that key will be 'locked', and held until it is pressed again.
58 | > This also draws an overlay (displaying the locked keys) to the screen using a callback every time `Display.show()` is called.
59 | >
60 | >
61 |
62 |
63 | `UserInput.get_mod_keys()`
64 | > Return a list of modifier keys that are being held, including keys that are locked if `allow_locking_keys` is `True`.
65 | >
66 |
67 |
68 |
69 |
70 |
71 | ## Read touch:
72 | *These methods only exist when the device has a touchscreen*
73 |
74 | `UserInput.get_current_points()`
75 | > Return the current touch data in the form of a list of `TouchPoints`
76 | > Touchpoints are `namedtuple`s with the following format:
77 | > `namedtuple("TouchPoint", ["id", "x", "y", "size"])`
78 | >
79 |
80 |
81 | `UserInput.get_touch_events()`
82 | > Similar to `get_new_keys`, this method does some pre-processing on touch data to make it easier to parse.
83 | >
84 | > Touch events are only returned once, when they are completed, and take the form of either a `Tap` or a `Swipe`:
85 | > `namedtuple("Tap", ['x', 'y', 'size', 'duration'])`
86 | > `namedtuple("Swipe", ['x0', 'y0', 'x1', 'y1', 'size', 'duration', 'distance', 'direction'])`
87 | >
88 |
89 |
--------------------------------------------------------------------------------