├── .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 |
  1. Black
  2. 32 |
  3. Darker bg_color
  4. 33 |
  5. bg_color
  6. 34 |
35 | 36 |
    37 |
  1. 84% bg_color 16% ui_color
  2. 38 |
  3. 67% bg_color 33% ui_color
  4. 39 |
  5. 50% bg_color 50% ui_color (mid_color)
  6. 40 |
  7. 33% bg_color 67% ui_color
  8. 41 |
  9. 16% bg_color 84% ui_color
  10. 42 |
43 | 44 |
    45 |
  1. ui_color
  2. 46 |
  3. Lighter ui_color
  4. 47 |
  5. white
  6. 48 |
49 | 50 |
    51 |
  1. reddish bg_color
  2. 52 |
  3. greenish mid_color
  4. 53 |
  5. bluish ui_color
  6. 54 |
55 | 56 |
    57 |
  1. compliment bg_color (opposite hue)
  2. 58 |
  3. compliment ui_color
  4. 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 | --------------------------------------------------------------------------------