Repository: ussserrr/stm32pio (language: python)

Issue:
Set up CI with Azure Pipelines



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54a8f7a..50d5d33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -272,3 +272,25 @@
  - Changed: better parameters and configs merging
  - Changed: make `platformio_ini_is_patched` a property instead of function
  - Changed: improved in-code docs
+
+## ver 2.0.0
+ - New: introducing CI/CD via Azure Pipelines. I tried to make as "general" system as possible with isolated environments, reproducible builds, etc. Due to a number of such a different tools in use and scattered infrastructure around them this task is very complex and the current configuration is far from ideal though
+ - New: new project structure. All packages (core, CLI, GUI) are consolidated under the common `stm32pio` Python _namespace_
+ - New: app version is completely removed from the repo and is "computed" at build-time from the VCS (GIT) current tag (using [setuptools_scm](https://github.com/pypa/setuptools_scm)). At run-time the version obtaining process depends: for newer Python it can be retrieved from a package metadata, for older one there is an auto-generated `version.py` file with a value stored in it
+ - New: support multiple test fixtures to test against a different targets (e.g. lightweight `nucleo_f031k6` project + real-life `f103` and so on)
+ - New: new build system. Use modern tools such as `pyproject.toml`, declarative `setup.cfg`, PEP-517 and so on. However in the middle of the development process it became evident that such a workflow is still early and not so common and there are simply not enough tools to implement it gracefully (e.g. no official tool to build both `dist` source tarball and `wheel`)
+ - New: CLI. `patch` command. With this function now all meaningful (for end-user) methods of the `Stm32pio` class are mapped by CLI commands and match corresponding buttons from the GUI version so the similar workflow can be applied:
+    - Initialize - `stm32pio init`
+    - Generate - `stm32pio generate`
+    - Init PlatformIO - `platformio project init`
+    - Patch - `stm32pio patch`
+    - Build - `platformio run`
+ - New: CubeMX now can be invoked directly, without Java command need to be specified
+ - New: add dedicated `initialized` signal to `ProjectListItem`
+ - New: basic GUI tests (just make sure all imports are fine and the app at least starts)
+ - Fixed: revise config handling: more robust and straightforward procedure to merge the final runtime config from different sources (fix the reported [problem](https://community.platformio.org/t/platformio-for-stm32-and-cubemx-include-error/10984/11)). Adjust corresponding test
+ - Fixed: GUI. Add `project.lastActionSucceed` property to fix some visual behavior (bold borders remains after an error)
+ - Changed: migrate to [Semantic Versioning](https://semver.org) (2.0.0). The next version (i.e. this one) was supposed to be 1.40 so with semver – 1.4.0. This can be confusing for some automatic tools in the future to correctly find out which version is latter though so this version will be marked with 2.0.0 tag to start things over
+ - Changed: remove board absence warning in `Stm32pio` constructor (this should be done outside)
+ - Changed: take out to the `settings.py` a strings that we looked for to determine successful CubeMX code generation
+ - Changed: use newer `platformio project init` command, use verbose versions of CLI arguments
diff --git a/CI/lockfile.yml b/CI/lockfile.yml
new file mode 100644
index 0000000..56652d0
--- /dev/null
+++ b/CI/lockfile.yml
@@ -0,0 +1,13 @@
+# Azure Pipelines CI lockfile
+#
+# Locks versions of all tools have been used to run the pipeline for the particular commit.
+# These can be spread across different sources (platformio.ini, requirements.txt, pipenv' lockfile, etc.) but we decided
+# to have them all in one place (especially because they are all make sense mostly for the testing/CI purposes)
+
+variables:
+  cubemx_version: 601  # 6.0.1
+  # we should store nothing but strings for variables values so we encapsulate YAML into string
+  cubemx_packages: "f0: 1.11.1"
+  platformio_version: 5.0.1
+  test_cases: |
+    - nucleo_f031k6
diff --git a/LICENSE b/LICENSE
index 5ca1604..85184e7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,3 +19,19 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
+
+
+
+Icons for GUI version by
+
+ - Flat Icons
+ - Google
+ - Pixel Perfect
+ - Freepik
+
+from www.flaticon.com. All rights belong to their respective owners. I do not
+own any of this content.
+
+
+
+All other components that are used/involved in the project are subjects of their respective owners licenses unless the opposite is explicitly stated.
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 2274f3e..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,10 +0,0 @@
-include .gitignore
-include CHANGELOG.md
-include LICENSE
-include MANIFEST.in
-include README.md
-include TODO.md
-recursive-include stm32pio-test-project *
-include stm32pio_gui/main.qml
-include stm32pio_gui/README.md
-recursive-include stm32pio_gui/icons *
diff --git a/README.md b/README.md
index 669d78c..7294382 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,12 @@
 # stm32pio
-Small cross-platform Python app that can create and update [PlatformIO](https://platformio.org) projects from [STM32CubeMX](https://www.st.com/en/development-tools/stm32cubemx.html) `.ioc` files.
 
-It uses STM32CubeMX to generate a HAL-framework-based code and alongside creates PlatformIO project with compatible parameters to stick them both together.
+[![Build Status](https://dev.azure.com/andrei42008/stm32pio/_apis/build/status/ussserrr.stm32pio?branchName=dev)](https://dev.azure.com/andrei42008/stm32pio/_build/latest?definitionId=1&branchName=dev)
 
-The [GUI version](/stm32pio_gui) is available, too.
+Small cross-platform Python app that can create and update [PlatformIO](https://platformio.org) projects from the [STM32CubeMX](https://www.st.com/en/development-tools/stm32cubemx.html) `.ioc` files.
+
+It uses STM32CubeMX to generate a HAL-framework-based code and alongside creates PlatformIO project with the compatible parameters to stick them both together.
+
+The [GUI version](/docs/gui/README.md) is available, too.
 
 ![Logo](/screenshots/logo.png)
 
@@ -13,59 +16,59 @@ The [GUI version](/stm32pio_gui) is available, too.
 > - [Requirements](#requirements)
 > - [Installation](#installation)
 > - [Usage](#usage)
->   - [GUI from CLI](#gui-from-cli)
 >   - [Project patching](#project-patching)
 >   - [Embedding](#embedding)
 > - [Example](#example)
-> - [Testing](#testing)
+> - [Build](#build)
+> - [Test](#test)
+> - [CI](#ci)
 > - [Restrictions](#restrictions)
 
 
 ## Features
   - Start the new complete project in a single directory using only an `.ioc` file
-  - Update an existing project after changing hardware options in CubeMX
+  - Update an existing project after changing a hardware options in CubeMX
   - Clean-up the project
   - Get the status information
   - *[optional]* Automatically run your favorite editor in the end
   - *[optional]* Automatically make an initial build of the project
-  - *[optional]* GUI version (beta) (see stm32pio-gui sub-folder for the dedicated README)
+  - *[optional]* GUI version (see [the dedicated README](/docs/gui/README.md) file)
 
 
 ## Requirements:
-  - For this app:
-    - Python 3.6 and above
-  - For usage:
-    - macOS, Linux, Windows
-    - STM32CubeMX with desired downloaded frameworks (F0, F1, etc.)
-    - Java CLI (JRE) (likely is already installed if the STM32CubeMX is working)
-    - PlatformIO CLI (already presented if you have installed PlatformIO via some package manager or need to be installed as the "command line extension" from IDE)
+The app presents zero dependencies by itself and requires only this to run:
+  - macOS, Linux, Windows
+  - Python 3.6 and above
 
-A general recommendation there would be to test both CubeMX (code generation) and PlatformIO (project creation, building) at least once before using stm32pio to make sure that all tools work properly even without any "glue".
+Of course, you need to have all the necessary tools to be installed in order to work with them:
+  - STM32CubeMX with the desired downloaded frameworks (F0, F1, etc.). All recent versions are fine, probably something like 5.0+
+  - Java CLI (JRE, Java runtime environment) for the CubeMX (likely is already installed if the CubeMX is working). In other words, you should be able to launch the Java from your terminal using `java` command. Which version is appropriate for the CubeMX you can find in its docs
+  - PlatformIO (4.2.0 and above) CLI (already present if you have installed PlatformIO via some package manager (`pip`, `apt`, `brew`, etc.) or need to be installed as the "command line extension" from IDE (see [docs](https://docs.platformio.org/en/latest/core/installation.html) for more information))
 
+If you for some reasons don't want to or cannot install command line versions of these applications in your system you can always specify the direct paths to them using the project configuration file `stm32pio.ini` or even override the default values in the source code file `core/settings.py`.
 
-## Installation
-You can run the app in a portable way by downloading or cloning the snapshot of the repository and invoking the main script or Python module:
-```shell script
-stm32pio-repo/ $   python3 stm32pio/app.py  # or
-stm32pio-repo/ $   python3 -m stm32pio  # or
-any-path/ $   python3 path/to/stm32pio-repo/stm32pio/app.py
-```
-(we assume `python3` and `pip3` hereinafter). It is possible to run the app like this from anywhere.
+Also, some external dependencies may be required to build, test and pack the app. See the corresponding sections for more information.
+
+A general recommendation there would be to test both CubeMX (code generation) and PlatformIO (project creation, building) at least once before using the stm32pio to make sure all the tools work properly even without any "glue".
 
-However, it's handier to install the utility to be able to run stm32pio from anywhere. Use
+**2020 Update**: in my tests, recent versions of CubeMX had been shipping with no installer of some sort. So I just unpack the distribution archive and place it in `~/cubemx` directory so it can be started simply as `java -jar ~/cubemx/STM32CubeMX.exe`. That's why the default path is as mentioned above. Tell me if your default path is rather another. Also, the default structure of the generated code is significantly different when you invoke the generation from the GUI version of CubeMX or the CLI one. As stm32pio uses the latter, currently the PlatformIO project structure cannot be properly patched to use a code from the GUI version of CubeMX (at least with the default patch config, you can always tweak it in a configuration file `stm32pio.ini`).
+
+
+## Installation
+As a normal Python package the app can be run in a completely portable way by downloading or cloning the snapshot of this repository and invoking the main script (or the Python module):
 ```shell script
-stm32pio-repo/ $   pip install wheel
-stm32pio-repo/ $   python setup.py sdist bdist_wheel
-stm32pio-repo/ $   pip install dist/stm32pio-X.XX-py3-none-any.whl
+stm32pio-repo/ $   python3 stm32pio/cli/app.py  # or
+stm32pio-repo/ $   python3 -m stm32pio.cli  # or
+any-path/ $   python3 path/to/stm32pio-repo/stm32pio/cli/app.py
 ```
-commands to launch the setup process. Now you can simply type `stm32pio` in the terminal to run the utility in any directory.
+(we assume `python3` and `pip3` hereinafter).
 
-Finally, the PyPI distribution (starting from v0.95) is available:
+However, it's handier to install the utility to be able to run stm32pio from anywhere. The PyPI distribution (starting from v0.95) is available:
 ```shell script
 $ pip install stm32pio
 ```
 
-To uninstall in both cases run
+To uninstall run
 ```shell script
 $ pip uninstall stm32pio
 ```
@@ -73,10 +76,10 @@ $ pip uninstall stm32pio
 
 ## Usage
 Basically, you need to follow such a pattern:
-  1. Create CubeMX project (.ioc file), set-up your hardware configuration, save with the compatible parameters
+  1. Create CubeMX project (`.ioc` file), set-up your hardware configuration, save with the compatible parameters
   2. Run the stm32pio that automatically invokes CubeMX to generate the code, creates PlatformIO project, patches a `platformio.ini` file and so on
-  3. Work on the project in your editor as usual, compile/upload/debug etc.
-  4. Edit the configuration in CubeMX when necessary, then run stm32pio to re-generate the code
+  3. Work with your project normally as you wish, compile/upload/debug etc.
+  4. Come back to the hardware configuration in CubeMX when necessary, then run stm32pio to re-generate the code
 
 Refer to Example section on more detailed steps. If you face off with some error try to enable a verbose output to get more information about a problem:
 ```shell script
@@ -87,40 +90,34 @@ On the first run stm32pio will create a config file `stm32pio.ini`, syntax of wh
 ```shell script
 $ stm32pio init -d path/to/project
 ```
-It may be useful to tweak some parameters before proceeding. The structure of the config is separated in two sections: `app` and `project`. Options of the first one is related to the global settings such as commands to invoke different instruments though they can be adjusted on the per-project base while the second section contains of project-related parameters. See comments in the [`settings.py`](/stm32pio/settings.py) file for parameters description.
+It may be useful to tweak some parameters before proceeding. The structure of the config is separated into two sections: `app` and `project`. Options of the first one is related to the global settings (such as commands to invoke different instruments) though they can be adjusted on the per-project base while the second section contains of project-related parameters. See comments in the [`settings.py`](/stm32pio/core/settings.py) file for parameters description.
 
 You can always run
 ```shell script
-$ python app.py --help
+$ stm32pio --help
 ```
 to see help on available commands. Find the copy of its output on the [project wiki](https://github.com/ussserrr/stm32pio/wiki/stm32pio-help) page, also.
 
-### GUI from CLI
-You can start the [GUI version](/stm32pio_gui) using `gui` subcommand and pass some of the arguments to it:
-```shell script
-$ stm32pio gui -d ./sample-project -b discovery_f4
-```
-
 ### Project patching
 
-Note, that the patch operation (which takes the CubeMX code and PlatformIO project to the compliance) erases all the comments (lines starting with `;`) inside the `platformio.ini` file. They are not required anyway, in general, but if you need them for some reason please consider to save the information somewhere else.
+Note, that the patch operation (which takes the CubeMX code and PlatformIO project to the compliance) erases all the comments (lines starting with `;`) inside the `platformio.ini` file. They are not required anyway, in general, but if you need them for some reason please consider saving the information somewhere else.
 
-For those who want to modify the patch (default one is at [`settings.py`](/stm32pio/settings.py), project one in a config file `stm32pio.ini`): it can has a general-form .INI content so it is possible to specify several sections and apply composite patches. This works totally fine for the most cases except, perhaps, some really big complex patches involving, say, the parameters interpolation feature. It is turned off for both `platformio.ini` and user's patch parsing by default. If there are some problems you've met due to a such behavior please modify the source code to match the parameters interpolation kind for the configs you need to. Seems like `platformio.ini` uses `ExtendedInterpolation` for its needs, by the way.
+For those who wants to modify the patch (default one is at [`settings.py`](/stm32pio/core/settings.py), project one in a config file `stm32pio.ini`): it has a general-form .INI-style content so it's possible to specify several sections and apply composite patches. This works totally fine for the most cases except, perhaps, some really big complex patches involving, say, the parameters interpolation feature. It is turned off for both `platformio.ini` and user's patch parsing by default. If there are some problems you've met due to a such behavior please modify the source code to match the parameters interpolation kind for the configs you need to. Seems like `platformio.ini` uses `ExtendedInterpolation` for its needs, by the way.
 
 ### Embedding
 
-You can also use stm32pio as an ordinary Python package and embed it in your own application. Find the minimal example at the [examples](/examples) to see some possible ways of implementing this. Basically, you need to import `stm32pio.lib` module (where the main `Stm32pio` class resides), (optionally) set up a logger and you are good to go. If you prefer higher-level API similar to the CLI version, use `main()` function in `app.py` passing the same CLI arguments to it (except the actual script name). Also, take a look at the CLI ([`app.py`](/stm32pio/app.py)) or GUI versions.
+You can also use stm32pio as an ordinary Python package and embed it in your own application. Find the minimal example at the [examples](/examples) to see some possible ways of implementing this. Basically, you need to import `stm32pio.core.lib` module (where the main `Stm32pio` class resides), _optionally_ set up a logger and you are good to go. If you prefer higher-level API similar to the CLI version, use `main()` function in `cli/app.py` passing the same CLI arguments to it. Also, take a look at the CLI ([`app.py`](/stm32pio/cli/app.py)) or GUI versions.
 
 
 ## Example
 1. Run CubeMX, choose MCU/board, do all necessary tweaking
 2. Select `Project Manager -> Project` tab, specify "Project Name", choose "Other Toolchains (GPDSC)". In `Code Generator` tab check "Copy only the necessary library files" and "Generate periphery initialization as a pair of '.c/.h' files per peripheral" options
 
-![Code Generator tab](/screenshots/tab_CodeGenerator.png)
+![Code Generator tab](/docs/cubemx_project_settings/tab_CodeGenerator.png)
 
-3. Back in the first tab (Project) copy the "Toolchain Folder Location" string (you maybe not be able to copy it in modern CubeMX versions so use terminal or file manager to do this). Save the project, close CubeMX
+3. Back in the first tab (Project) copy the "Toolchain Folder Location" string (you may not be able to copy it in modern CubeMX versions so use a terminal or a file manager to do this). Save the project
 
-![Project tab](/screenshots/tab_Project.png)
+![Project tab](/docs/cubemx_project_settings/tab_Project.png)
 
 4. Use a copied string (project folder) as a `-d` argument for stm32pio (can be omitted if your current working directory is already a project directory).
 5. Run `platformio boards` (`pio boards`) or go to [boards](https://docs.platformio.org/en/latest/boards) to list all supported devices. Pick one and use its ID as a `-b` argument (for example, `nucleo_f031k6`)
@@ -133,25 +130,52 @@ You can also use stm32pio as an ordinary Python package and embed it in your own
    path/to/cubemx/project/ $   stm32pio new -b nucleo_f031k6
    ```
 7. To get the information about the current state of the project use `status` command.
-8. If you will be in need to update hardware configuration in the future, make all the necessary stuff in CubeMX and run `generate` command in a similar way:
+8. If you will be in need to update the hardware configuration in a future, make all the necessary stuff in CubeMX and run `generate` command in a similar way:
    ```shell script
    $ stm32pio generate -d /path/to/cubemx/project
    ```
 9. To clean-up the folder and keep only the `.ioc` file run `clean` command.
 
 
-## Testing
-There are some tests in file [`test.py`](/stm32pio/tests/test.py) (based on the unittest module). Run
+## Build
+Staring from v2.0.0 PEP517-compatible build process is supported. For the build dependencies list see [pyproject.toml](/pyproject.toml) file:
 ```shell script
-stm32pio-repo/ $   python -m unittest -b -v
+$ pip install wheel
+$ pip install setuptools setuptools_scm
 ```
-to test the app. It uses STM32F0 framework to generate and build a code from the test [`stm32pio-test-project.ioc`](/stm32pio-test-project/stm32pio-test-project.ioc) project file. Please make sure that the test project folder is clean (i.e. contains only an .ioc file) before running the test otherwise it can lead to some cases failing. Tests automatically create temporary directory (using `tempfile` Python standard module) where all actions are performed.
+This process yet described and standardized in PEPs is still early and not fully implemented by the different tools (such as pip, twine) though (at the time of this version at least). So the current way is a little bit messy and depends on different instruments. Use the latest Python and build packages versions. To build a _wheel_ `setup.py` is not even required:
+```shell script
+$ pip wheel . --wheel-dir dist
+```
+but for the source distribution tarball it is still necessary:
+```shell script
+$ python setup.py sdist
+```
+
 
-For the specific test suite or case you can use
+## Test
+There are some tests in [`tests`](/tests) directory (based on the unittest module). The test stage is a CubeMX project (`.ioc` file) with an optional `platformio.ini.lockfile` config specifying the versions of the used ("locked") PlatformIO libraries (see "CI" chapter) (this config is an ordinary .INI-style file which will be merged with the `platformio.ini` test file). Several such targets can be placed to the `fixtures` folder to test against. Finally, run this command setting the current case as an environment variable
 ```shell script
-stm32pio-repo/ $   python -m unittest tests.test_integration.TestIntegration -b -v
-stm32pio-repo/ $   python -m unittest tests.test_cli.TestCLI.test_verbosity -b -v
+stm32pio-repo/ $   STM32PIO_TEST_CASE=nucleo_f031k6 python -m unittest -b -v
 ```
+to test the stm32pio. Tests code automatically create temporary directory (using `tempfile` Python standard module) where all actions are performed.
+
+To run the specific group of tests or a single test function you can use:
+```shell script
+stm32pio-repo/ $   python -m unittest tests.test_integration.TestIntegration
+stm32pio-repo/ $   python -m unittest tests.test_cli.TestCLI.test_verbosity
+```
+
+Automated tests against old `.ioc` files seems not possible because of CubeMX interactive GUI prompts about migrations. I don't find any CLI key or option to disable them (and in general, documentation for the CubeMX CLI doesn't look complete...).
+
+
+## CI
+Azure Pipelines is used to automate test, build, and publish tasks. The repo is tested against all 3 major OSes and for the Linux the test coverage is also calculated. For this purposes some additional external dependencies are necessary, such as
+  - pytest
+  - coverage
+  - yaml
+  
+There are some elements of the "reproducible builds" approach using several "lockfiles", isolated test fixtures and caching. Actually, due to a number of different tools in use and their nature the truly and fully "canonical" CI seems challenging to implement so the current system is far from ideal. It probably will be improved in the future, see [azure-pipelines.yml](/azure-pipelines.yml), [CI](/CI) for more information for now.
 
 
 ## Restrictions
diff --git a/TODO.md b/TODO.md
index 07ca4b3..a21d31f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -7,6 +7,8 @@
  - [ ] Create VSCode plugin
  - [ ] UML diagrams (core, GUI back- and front-ends, thread flows, events, etc.)
  - [ ] CI is possible (Arch's AUR has the STM32CubeMX package, also there is a direct link). Deploy Docker one in Azure Pipelines, basic at Travis CI
+ - [ ] In the future, probably use https://setuptools.readthedocs.io/en/latest/setuptools.html#accessing-data-files-at-runtime `importlib.resources` as a standard API to access non-Python files inside Python packages (such as `.qml`)
+ - [ ] Use some features of newer Pythons after dropping the support for 3.6 (and so on)
 
 ## GUI version
  - [ ] Obtain boards on demand (not at the startup)
@@ -22,7 +24,7 @@
            visitedAfterInstantiating,
 
            actionRunning,
-           lastActionStatus,
+           [+] lastActionStatus,
            visitedAfterAction,
            ...
        }
@@ -36,8 +38,12 @@
  - [ ] Crash on shutdown in Win and Linux (errors such as `[QML] CRITICAL QThread: Destroyed while thread is still running Process finished with exit code 1073741845`)
  - [ ] Linux: Not a monospaced font in the log area
  - [ ] Temporarily pin projects with currently running actions to the top (and stay there on scrolling). See QML Package type
+ - [ ] "Pressed" effect for action buttons
+ - [x] Fix: bold borders remains after an error
+ - [ ] Maybe do not save the stm32pio.ini if there wasn't one (after starting from CLI)
+ - [ ] Specify board without reloading the app. Warn that board is not specified after cleanup
 
-## Core library
+## Core library and CLI
  - [ ] when updating the project (`generate` command), check for boards match
  - [ ] Remove casts to string where we can use path-like objects (related to a Python version as new ones receives path-like objects arguments while old ones aren't)
  - [ ] We look for some snippets of strings in logs and output for the testing code but we hard-code them and this is not good, probably (e.g. 'DEBUG')
@@ -53,7 +59,20 @@
  - [ ] test using virtualenv
  - [ ] test for different `.ioc` files (i.e. F0, F1, F4 and so on) as it is not the same actually
  - [ ] mb allow to use an arbitrary strings (arrays of str) to specify tools commands in stm32pio.ini (shell=True or a list of args (split a string))
- - [ ] cache boards for a small interval of time
- - [ ] count another '-v' as '-v' for PlatformIO calls (slider in GUI settings window)
+ - [x] cache boards for a small interval of time
+ - [ ] count another '-v' as '-v' for the PlatformIO calls (as a slider in the GUI settings window)
  - [ ] Project' name (path) can be reused so cannot be used as a unique identifier but so is id(self)? Probably it is better to use a path (human-readable)
  - [ ] Analyze `.ioc` file for the wrong framework/parameters
+ - [x] Take out to settings "[ERROR]", "Successful code generation" etc.
+ - [ ] Kill subprocesses if there is no output have appeared for some timeout (i.e. hung)
+ - [x] Fix when empty '' board string overwrites existing
+ - [x] Allow to not specify a board for the `new` command when it is already specified in the config
+ - [ ] Maybe logging notifications about which parameters has superseded which
+ - [x] Add `patch` command (all other are already present in some way (through `stm32pio` or `pio`)) or another
+ - [ ] Use CubeMX options such as `project couplefilesbyip <0|1>` and `project toolchain <toolchain>` or ...
+ - [ ] ... parse an `.ioc` file and edit the parameters in-place if necessary
+ - [x] Expand CLI keys and options everywhere (e.g. `--project-option` instead of `-O`)
+ - [ ] Build, install and only then test the app
+ - [ ] Templates for CI?
+ - [ ] Deal with CubeMX requests about software package and CubeMX versions migrations (seems like the only way is to set them in `.ioc` file, no appropriate CLI keys)
+ - [ ] If `--board` has not been supplied try to get it from the `platformio.ini` (if present)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
new file mode 100644
index 0000000..464311e
--- /dev/null
+++ b/azure-pipelines.yml
@@ -0,0 +1,282 @@
+# Python package
+# Create and test a Python package on multiple Python versions.
+# Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more:
+# https://docs.microsoft.com/azure/devops/pipelines/languages/python
+
+# TODO: Lock ALL tools versions per commit! CubeMX F0 framework, PlatformIO and its build tools & libraries versions
+#        (use templates, variables and cache)
+
+trigger:
+- master
+- dev
+
+variables:
+  - template: 'CI/lockfile.yml'
+  - name: stm32pio_cubemx_cache_folder
+    value: $(Pipeline.Workspace)/cubemx  # cross-platform location
+  - name: stm32pio_cubemx_packages_cache_folder
+    value: $(HOME)/STM32Cube  # cross-platform location
+  - name: stm32pio_platformio_cache_folder
+    value: $(HOME)/.platformio  # cross-platform location
+  - name: stm32pio_cubemx_link
+    value: https://sw-center.st.com/packs/resource/library/stm32cube_mx_v$(cubemx_version).zip
+
+stages:
+  - stage: Test
+    jobs:
+      - job: 'Ubuntu'
+        pool:
+          vmImage: 'ubuntu-latest'
+        strategy:
+          matrix:
+            Python36:
+              python.version: '3.6'
+            Python37:
+              python.version: '3.7'
+            Python38:
+              python.version: '3.8'
+        steps:
+        - task: UsePythonVersion@0
+          inputs:
+            versionSpec: '$(python.version)'
+          displayName: 'Use Python $(python.version)'
+
+        - script: |
+            sudo apt install xvfb
+            sudo systemctl start xvfb
+            export DISPLAY=:99.0
+          displayName: 'Set up X virtual frame buffer'
+
+        - script: |
+            pip install wheel
+            pip install platformio==$(PLATFORMIO_VERSION)
+            pip install pyyaml pytest pytest-cov PySide2
+          displayName: 'Install tools'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx | "$(CUBEMX_VERSION)"'
+            restoreKeys: 'cubemx | "$(CUBEMX_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_cache_folder }}
+            cacheHitVar: CUBEMX_CACHE_RESTORED
+          displayName: 'Restore CubeMX cache'
+
+        - script: |
+            wget -O cubemx_${{ variables.cubemx_version }}.zip ${{ variables.stm32pio_cubemx_link }}
+            unzip -q cubemx_${{ variables.cubemx_version }}.zip -d ${{ variables.stm32pio_cubemx_cache_folder }}
+          condition: ne(variables.CUBEMX_CACHE_RESTORED, 'true')
+          displayName: 'Download & unzip CubeMX'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'
+            restoreKeys: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_packages_cache_folder }}
+            cacheHitVar: CUBEMX_PACKAGES_CACHE_RESTORED
+          displayName: 'Restore CubeMX packages cache'
+
+        - script: |
+            xvfb-run python CI/setup_test_env.py
+          condition: ne(variables.CUBEMX_PACKAGES_CACHE_RESTORED, 'true')
+          displayName: 'Download CubeMX packages'
+
+        - task: Cache@2
+          inputs:
+            key: 'platformio | "$(PLATFORMIO_VERSION)"'
+            restoreKeys: 'platformio | "$(PLATFORMIO_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_platformio_cache_folder }}
+            cacheHitVar: PLATFORMIO_CACHE_RESTORED
+          displayName: 'Restore PlatformIO packages cache'
+
+        # Use temp pytest config file to specify correct output format
+        - script: |
+            xvfb-run python CI/tests_runner.py
+          displayName: 'Test & coverage'
+
+        - task: PublishTestResults@2
+          condition: succeededOrFailed()
+          inputs:
+            testResultsFiles: '**/test-*.xml'
+            testRunTitle: 'Publish test results for Python $(python.version) (Ubuntu)'
+
+        - task: PublishCodeCoverageResults@1
+          inputs:
+            codeCoverageTool: Cobertura
+            summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
+
+
+      - job: 'Windows'
+        pool:
+          vmImage: 'windows-latest'
+        strategy:
+          matrix:
+            Python36:
+              python.version: '3.6'
+            Python37:
+              python.version: '3.7'
+            Python38:
+              python.version: '3.8'
+        steps:
+        - task: UsePythonVersion@0
+          inputs:
+            versionSpec: '$(python.version)'
+          displayName: 'Use Python $(python.version)'
+
+        - script: |
+            pip install wheel
+            pip install platformio==$(PLATFORMIO_VERSION)
+            pip install pyyaml PySide2
+          displayName: 'Install tools'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx | "$(CUBEMX_VERSION)"'
+            restoreKeys: 'cubemx | "$(CUBEMX_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_cache_folder }}
+            cacheHitVar: CUBEMX_CACHE_RESTORED
+          displayName: 'Restore CubeMX cache'
+
+        - powershell: |
+            Invoke-WebRequest ${{ variables.stm32pio_cubemx_link }} -OutFile cubemx_${{ variables.cubemx_version }}.zip
+            7z x -o${{ variables.stm32pio_cubemx_cache_folder }} cubemx_${{ variables.cubemx_version }}.zip
+          condition: ne(variables.CUBEMX_CACHE_RESTORED, 'true')
+          displayName: 'Download & unzip CubeMX'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'
+            restoreKeys: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_packages_cache_folder }}
+            cacheHitVar: CUBEMX_PACKAGES_CACHE_RESTORED
+          displayName: 'Restore CubeMX packages cache'
+
+        - script: |
+            python CI/setup_test_env.py
+          condition: ne(variables.CUBEMX_PACKAGES_CACHE_RESTORED, 'true')
+          displayName: 'Download CubeMX packages'
+
+        - task: Cache@2
+          inputs:
+            key: 'platformio | "$(PLATFORMIO_VERSION)"'
+            restoreKeys: 'platformio | "$(PLATFORMIO_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_platformio_cache_folder }}
+            cacheHitVar: PLATFORMIO_CACHE_RESTORED
+          displayName: 'Restore PlatformIO packages cache'
+
+        - script: |
+            python CI/tests_runner.py
+          displayName: 'Test'
+
+
+      - job: 'macOS'
+        pool:
+          vmImage: 'macOS-latest'
+        strategy:
+          matrix:
+            Python36:
+              python.version: '3.6'
+            Python37:
+              python.version: '3.7'
+            Python38:
+              python.version: '3.8'
+        steps:
+        - task: UsePythonVersion@0
+          inputs:
+            versionSpec: '$(python.version)'
+          displayName: 'Use Python $(python.version)'
+
+        - script: |
+            pip install wheel
+            pip install platformio==$(PLATFORMIO_VERSION)
+            pip install pyyaml PySide2
+          displayName: 'Install tools'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx | "$(CUBEMX_VERSION)"'
+            restoreKeys: 'cubemx | "$(CUBEMX_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_cache_folder }}
+            cacheHitVar: CUBEMX_CACHE_RESTORED
+          displayName: 'Restore CubeMX cache'
+
+        - script: |
+            wget -O cubemx_${{ variables.cubemx_version }}.zip ${{ variables.stm32pio_cubemx_link }}
+            unzip -q cubemx_${{ variables.cubemx_version }}.zip -d ${{ variables.stm32pio_cubemx_cache_folder }}
+          condition: ne(variables.CUBEMX_CACHE_RESTORED, 'true')
+          displayName: 'Download & unzip CubeMX'
+
+        - task: Cache@2
+          inputs:
+            key: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'
+            restoreKeys: 'cubemx_packages | "$(CUBEMX_PACKAGES)"'  # we want an exact match
+            path: ${{ variables.stm32pio_cubemx_packages_cache_folder }}
+            cacheHitVar: CUBEMX_PACKAGES_CACHE_RESTORED
+          displayName: 'Restore CubeMX packages cache'
+
+        - script: |
+            python CI/setup_test_env.py
+          condition: ne(variables.CUBEMX_PACKAGES_CACHE_RESTORED, 'true')
+          displayName: 'Download CubeMX packages'
+
+        - task: Cache@2
+          inputs:
+            key: 'platformio | "$(PLATFORMIO_VERSION)"'
+            restoreKeys: 'platformio | "$(PLATFORMIO_VERSION)"'  # we want an exact match
+            path: ${{ variables.stm32pio_platformio_cache_folder }}
+            cacheHitVar: PLATFORMIO_CACHE_RESTORED
+          displayName: 'Restore PlatformIO packages cache'
+
+        - script: |
+            python CI/tests_runner.py
+          displayName: 'Test'
+
+
+  - stage: Build
+    jobs:
+      - job: Build
+        displayName: 'Build to wheel & source distribution'
+        pool:
+          vmImage: 'ubuntu-latest'
+        steps:
+        - task: UsePythonVersion@0
+          inputs:
+            versionSpec: '3.8'
+          displayName: 'Use Ubuntu Python38'
+
+        - script: |
+            pip install wheel
+            pip install setuptools setuptools_scm
+          displayName: 'Install build dependencies'
+
+        - script: |
+            pip wheel . --wheel-dir dist
+            python setup.py sdist
+          displayName: 'Pack via setuptools'
+
+        - task: CopyFiles@2
+          inputs:
+            contents: dist/**
+            targetFolder: $(Build.ArtifactStagingDirectory)
+
+        - task: PublishBuildArtifacts@1
+          inputs:
+            pathToPublish: $(Build.ArtifactStagingDirectory)
+            artifactName: dist
+
+
+  - stage: Publish
+    condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/master'))
+    jobs:
+      - job: PyPI
+        steps:
+        - script: |
+            pip install twine
+
+        - task: TwineAuthenticate@1
+          displayName: 'Twine Authenticate'
+          inputs:
+            pythonUploadServiceConnection: stm32pio
+
+        # Use command line script to 'twine upload', use -r to pass the repository name and --config-file to pass the environment variable set by the authenticate task.
+        - script: |
+            python -m twine upload -r "stm32pio" --config-file $(PYPIRC_PATH) dist/*
\ No newline at end of file
diff --git a/screenshots/tab_CodeGenerator.png b/docs/cubemx_project_settings/tab_CodeGenerator.png
similarity index 100%
rename from screenshots/tab_CodeGenerator.png
rename to docs/cubemx_project_settings/tab_CodeGenerator.png
diff --git a/screenshots/tab_Project.png b/docs/cubemx_project_settings/tab_Project.png
similarity index 100%
rename from screenshots/tab_Project.png
rename to docs/cubemx_project_settings/tab_Project.png
diff --git a/docs/gui/README.md b/docs/gui/README.md
new file mode 100644
index 0000000..f4466cd
--- /dev/null
+++ b/docs/gui/README.md
@@ -0,0 +1,80 @@
+# stm32pio GUI
+
+![Main](../../screenshots/gui.png)
+
+The cross-platform GUI version of the stm32pio. It wraps the core library functionality into the Qt5-QML skin using the PySide2 (aka "Qt for Python" project) and adding the projects management feature allowing you to store and manipulate on multiple stm32pio projects at one place.
+
+
+## Table of contents
+> - [Install and run](#install-and-run)
+> - [Usage](#usage)
+> - [Architecture notes](#architecture-notes)
+
+
+## Install and run
+
+The app requires PySide2 5.14+ package. It should be available in all major package managers including pip, apt, brew and so on.
+
+**WARNING:** PySide2 5.15 at the moment has a bug making the Add/Remove buttons not active.
+
+The convenient way to install is via `pip` specifying `extras` option:
+```shell script
+$ pip install stm32pio[GUI]
+```
+
+Then it can be started as
+```shell script
+$ stm32pio_gui
+```
+or
+```shell script
+$ stm32pio gui
+```
+from anywhere. If you have already installed the latest basic CLI version, the GUI sources are already on your machine so you can reinstall it using the command above or just supplement the existing setup installing the PySide2 manually.
+
+If you rather want to launch completely from sources, it is possible like this:
+```shell script
+$ python path/to/stm32pio/gui/app.py
+```
+or
+```shell script
+stm32pio-repo/ $   python -m stm32pio.gui
+```
+
+Either way, you can additionally specify the project (and board ID) to start with:
+```shell script
+$ stm32pio gui -d ./sample-project -b discovery_f4
+```
+
+
+## Usage
+
+Add a folder with an `.ioc` file to begin with. You can either use an "Add" button or drag-and-drop it into the main window, in the latter case you also have an ability to add multiple projects simultaneously. If the project is empty the initialization screen will be shown to help in setup:
+
+![Init](usage_tutorial_screenshots/init_screen.png)
+
+Skip it or enter one of the available PlatformIO STM32 boards identifier. Select "Run" to apply all actions to the project (analog of the `new` CLI command).
+
+In the main screen the buttons row allows you to run specific actions while, at the same time, represents the state of project. Green color means that this stage is fulfilled. The active project is monitored automatically while all the others refreshes only when you click on them so the "stage" line at the projects list item can be outdated.
+
+Let's assume you've worked on the project for some time and now need to re-generate and rebuild the configuration. To schedule all the necessary actions to run one after another navigate to the last desired action pressing the Shift key. All the actions prior this one should be colored light-green now:
+
+![Highlighting](usage_tutorial_screenshots/highlighting.png)
+
+Shift-click on it to execute the series. The picked actions will be framed with the border around each of them:
+
+![Group](usage_tutorial_screenshots/group.png)
+
+Add Ctrl to the mouse click to start the editor specified in the settings after the action. It can be combined with Shift as well. **Hint:** specify a `start` as an "Editor" command to open the folder in the new Explorer window under the Windows, `open` for the Finder on the macOS. So it basically can be a generic shell script which will be executed in the end with a pattern
+```
+<script> <project_path>
+```
+
+**Note:** Currently, the project config (stm32pio.ini) is not live-reloaded so any changes you do to it will not be reflected until the next start.
+
+
+## Architecture notes
+
+Projects list (not the projects themself) and settings are stored by `QSettings` so refer to its docs if you bother about the actual location.
+
+See `docs` directory to see the state machine diagram of the project action button.
diff --git a/stm32pio_gui/docs/action_button_state_machine.drawio b/docs/gui/action_button_state_machine.drawio
similarity index 100%
rename from stm32pio_gui/docs/action_button_state_machine.drawio
rename to docs/gui/action_button_state_machine.drawio
diff --git a/stm32pio_gui/docs/action_button_state_machine.png b/docs/gui/action_button_state_machine.png
similarity index 100%
rename from stm32pio_gui/docs/action_button_state_machine.png
rename to docs/gui/action_button_state_machine.png
diff --git a/stm32pio_gui/screenshots/group.png b/docs/gui/usage_tutorial_screenshots/group.png
similarity index 100%
rename from stm32pio_gui/screenshots/group.png
rename to docs/gui/usage_tutorial_screenshots/group.png
diff --git a/stm32pio_gui/screenshots/highlighting.png b/docs/gui/usage_tutorial_screenshots/highlighting.png
similarity index 100%
rename from stm32pio_gui/screenshots/highlighting.png
rename to docs/gui/usage_tutorial_screenshots/highlighting.png
diff --git a/stm32pio_gui/screenshots/init_screen.png b/docs/gui/usage_tutorial_screenshots/init_screen.png
similarity index 100%
rename from stm32pio_gui/screenshots/init_screen.png
rename to docs/gui/usage_tutorial_screenshots/init_screen.png
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9c7f84d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+write_to = "stm32pio/core/version.py"
diff --git a/stm32pio_gui/screenshots/main.png b/screenshots/gui.png
similarity index 100%
rename from stm32pio_gui/screenshots/main.png
rename to screenshots/gui.png
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..c5863f9
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,49 @@
+[metadata]
+name = stm32pio
+author = ussserrr
+author_email = andrei4.2008@gmail.com
+description =
+    Small cross-platform Python app that can create and update PlatformIO projects from STM32CubeMX
+    .ioc files. It uses STM32CubeMX to generate a HAL-framework-based code and alongside creates
+    PlatformIO project with compatible parameters to stick them both together
+long_description = file: README.md
+long_description_content_type = text/markdown
+license = MIT License
+license_file = LICENSE
+url = https://github.com/ussserrr/stm32pio
+classifiers =
+    Programming Language :: Python :: 3 :: Only
+    License :: OSI Approved :: MIT License
+    Development Status :: 5 - Production/Stable
+    Operating System :: MacOS :: MacOS X
+    Operating System :: Microsoft :: Windows
+    Operating System :: Unix
+    Environment :: Console
+    Environment :: MacOS X
+    Environment :: Win32 (MS Windows)
+    Environment :: X11 Applications
+    Natural Language :: English
+    Topic :: Software Development :: Embedded Systems
+keywords =
+    platformio
+    stm32cubemx
+    stm32
+    cubemx
+
+[options]
+python_requires = >=3.6,
+setup_requires =
+    wheel
+    setuptools_scm
+include_package_data = True
+packages = find_namespace:
+
+[options.packages.find]
+include = stm32pio.*
+
+[options.extras_require]
+GUI = PySide2>=5.12.0
+
+[options.entry_points]
+console_scripts = stm32pio = stm32pio.cli.app:main
+gui_scripts = stm32pio_gui = stm32pio.gui.app:main [GUI]
diff --git a/setup.py b/setup.py
index b53bffd..9f0cd1b 100644
--- a/setup.py
+++ b/setup.py
@@ -9,61 +9,4 @@
 
 import setuptools
 
-import stm32pio.app
-
-
-with open('README.md', 'r') as readme:
-    long_description = readme.read()
-
-
-setuptools.setup(
-    name='stm32pio',
-    version=stm32pio.app.__version__,
-    author='ussserrr',
-    author_email='andrei4.2008@gmail.com',
-    description="Small cross-platform Python app that can create and update PlatformIO projects from STM32CubeMX .ioc "
-                "files. It uses STM32CubeMX to generate a HAL-framework-based code and alongside creates PlatformIO "
-                "project with compatible parameters to stick them both together",
-    long_description=long_description,
-    long_description_content_type='text/markdown',
-    url="https://github.com/ussserrr/stm32pio",
-    packages=setuptools.find_packages(
-        exclude=[
-            'tests'
-        ]
-    ),
-    include_package_data=True,
-    classifiers=[
-        "Programming Language :: Python :: 3 :: Only",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-        "Development Status :: 5 - Production/Stable",
-        "Environment :: Console",
-        "Topic :: Software Development :: Embedded Systems"
-    ],
-    keywords=[
-        'platformio',
-        'stm32',
-        'stm32cubemx',
-        'cubemx'
-    ],
-    python_requires='>=3.6',
-    setup_requires=[
-        'wheel'
-    ],
-    # GUI module (stm32pio_gui) will be installed in any case (because find_packages() will find it) but its
-    # dependencies will be installed only when this option is given
-    extras_require={
-        'GUI': [
-            'PySide2'
-        ]
-    },
-    entry_points={
-        'console_scripts': [
-            'stm32pio = stm32pio.app:main'
-        ],
-        'gui_scripts': [
-            'stm32pio_gui = stm32pio_gui.app:main [GUI]'
-        ]
-    }
-)
+setuptools.setup(use_scm_version=True)
diff --git a/stm32pio/__main__.py b/stm32pio/__main__.py
deleted file mode 100644
index 1c9b88c..0000000
--- a/stm32pio/__main__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import sys
-
-import stm32pio.app
-
-
-if __name__ == '__main__':
-    sys.exit(stm32pio.app.main())
diff --git a/stm32pio/__init__.py b/stm32pio/cli/__init__.py
similarity index 100%
rename from stm32pio/__init__.py
rename to stm32pio/cli/__init__.py
diff --git a/stm32pio/cli/__main__.py b/stm32pio/cli/__main__.py
new file mode 100644
index 0000000..7c3befc
--- /dev/null
+++ b/stm32pio/cli/__main__.py
@@ -0,0 +1,12 @@
+import sys
+
+try:
+    import stm32pio.cli.app
+except ModuleNotFoundError:
+    import pathlib
+    sys.path.append(str(pathlib.Path(__file__).parent.parent))  # hack to run the app as 'python __main__.py'
+    import stm32pio.cli.app
+
+
+if __name__ == '__main__':
+    sys.exit(stm32pio.cli.app.main())
diff --git a/stm32pio/app.py b/stm32pio/cli/app.py
similarity index 75%
rename from stm32pio/app.py
rename to stm32pio/cli/app.py
index 325a546..dc25885 100755
--- a/stm32pio/app.py
+++ b/stm32pio/cli/app.py
@@ -1,8 +1,6 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
-__version__ = '1.30'
-
 import argparse
 import inspect
 import logging
@@ -11,14 +9,14 @@
 from typing import Optional, List
 
 try:
-    import stm32pio.settings
-    import stm32pio.lib
-    import stm32pio.util
+    import stm32pio.core.settings
+    import stm32pio.core.lib
+    import stm32pio.core.util
 except ModuleNotFoundError:
-    sys.path.append(str(pathlib.Path(sys.path[0]).parent))  # hack to be able to run the app as 'python app.py'
-    import stm32pio.settings
-    import stm32pio.lib
-    import stm32pio.util
+    sys.path.append(str(pathlib.Path(sys.path[0]).parent.parent))  # hack to be able to run the app as 'python app.py'
+    import stm32pio.core.settings
+    import stm32pio.core.lib
+    import stm32pio.core.util
 
 
 def parse_args(args: List[str]) -> Optional[argparse.Namespace]:
@@ -38,7 +36,7 @@ def parse_args(args: List[str]) -> Optional[argparse.Namespace]:
         take a glimpse on the available functionality'''))
 
     # Global arguments (there is also an automatically added '-h, --help' option)
-    root_parser.add_argument('--version', action='version', version=f"stm32pio v{__version__}")
+    root_parser.add_argument('--version', action='version', version=f"stm32pio {stm32pio.core.util.get_version()}")
     root_parser.add_argument('-v', '--verbose', help="enable verbose output (default: INFO)", action='count', default=0)
 
     subparsers = root_parser.add_subparsers(dest='subcommand', title='subcommands', description="valid subcommands",
@@ -46,25 +44,27 @@ def parse_args(args: List[str]) -> Optional[argparse.Namespace]:
 
     parser_init = subparsers.add_parser('init',
                                         help="create config .ini file to check and tweak parameters before proceeding")
+    parser_generate = subparsers.add_parser('generate', help="generate CubeMX code only")
+    parser_patch = subparsers.add_parser('patch',
+                                         help="tweak the project so the CubeMX and PlatformIO could work together")
     parser_new = subparsers.add_parser('new',
                                        help="generate CubeMX code, create PlatformIO project, glue them together")
-    parser_gui = subparsers.add_parser('gui', help="start the graphical version of the application. All arguments will "
-                                                   "be passed forward, see its --help for more information")
-    parser_generate = subparsers.add_parser('generate', help="generate CubeMX code only")
     parser_status = subparsers.add_parser('status', help="get the description of the current project state")
     parser_clean = subparsers.add_parser('clean',
                                          help="clean-up the project (delete ALL content of 'path' except an .ioc file)")
+    parser_gui = subparsers.add_parser('gui', help="start the graphical version of the application. All arguments will "
+                                                   "be passed forward, see its own --help for more information")
 
     # Common subparsers options
-    for parser in [parser_init, parser_new, parser_gui, parser_generate, parser_status, parser_clean]:
+    for parser in [parser_init, parser_generate, parser_patch, parser_new, parser_status, parser_clean, parser_gui]:
         parser.add_argument('-d', '--directory', dest='path', default=pathlib.Path.cwd(),
                             help="path to the project (current directory, if not given)")
     for parser in [parser_init, parser_new, parser_gui]:
         parser.add_argument('-b', '--board', dest='board', default='', help="PlatformIO name of the board")
-    for parser in [parser_init, parser_new, parser_generate]:
+    for parser in [parser_init, parser_generate, parser_new]:
         parser.add_argument('--start-editor', dest='editor',
                             help="use specified editor to open the PlatformIO project (e.g. subl, code, atom, etc.)")
-    for parser in [parser_new, parser_generate]:
+    for parser in [parser_generate, parser_new]:
         parser.add_argument('--with-build', action='store_true', help="build the project after generation")
 
     parser_clean.add_argument('-q', '--quiet', action='store_true',
@@ -95,12 +95,13 @@ def setup_logging(args_verbose_counter: int = 0, dummy: bool = False) -> logging
         logger = logging.getLogger('stm32pio')
         logger.setLevel(logging.DEBUG if args_verbose_counter else logging.INFO)
         handler = logging.StreamHandler()
-        formatter = stm32pio.util.DispatchingFormatter(
-            verbosity=stm32pio.util.Verbosity.VERBOSE if args_verbose_counter else stm32pio.util.Verbosity.NORMAL,
+        formatter = stm32pio.core.util.DispatchingFormatter(
+            verbosity=stm32pio.core.util.Verbosity.VERBOSE if args_verbose_counter else
+                stm32pio.core.util.Verbosity.NORMAL,
             general={
-                stm32pio.util.Verbosity.NORMAL: logging.Formatter("%(levelname)-8s %(message)s"),
-                stm32pio.util.Verbosity.VERBOSE: logging.Formatter(
-                    f"%(levelname)-8s %(funcName)-{stm32pio.settings.log_fieldwidth_function}s %(message)s")
+                stm32pio.core.util.Verbosity.NORMAL: logging.Formatter("%(levelname)-8s %(message)s"),
+                stm32pio.core.util.Verbosity.VERBOSE: logging.Formatter(
+                    f"%(levelname)-8s %(funcName)-{stm32pio.core.settings.log_fieldwidth_function}s %(message)s")
             })
         handler.setFormatter(formatter)
         logger.addHandler(handler)
@@ -110,7 +111,7 @@ def setup_logging(args_verbose_counter: int = 0, dummy: bool = False) -> logging
 
 def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
     """
-    Can be used as a high-level wrapper to do complete tasks.
+    Can be used as a high-level wrapper to perform independent tasks.
 
     Example:
         ret_code = stm32pio.app.main(sys_argv=['new', '-d', '~/path/to/project', '-b', 'nucleo_f031k6', '--with-build'])
@@ -132,9 +133,9 @@ def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
     args = parse_args(sys_argv)
 
     if args is not None and args.subcommand == 'gui':
-        import stm32pio_gui.app
         gui_args = [arg for arg in sys_argv if arg != 'gui']
-        return stm32pio_gui.app.main(sys_argv=gui_args)
+        import stm32pio.gui.app as gui_app
+        return gui_app.main(sys_argv=gui_args).exec_()
     elif args is not None and args.subcommand is not None:
         logger = setup_logging(args_verbose_counter=args.verbose, dummy=not should_setup_logging)
     else:
@@ -144,9 +145,9 @@ def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
     # Main routine
     try:
         if args.subcommand == 'init':
-            project = stm32pio.lib.Stm32pio(args.path, parameters={'project': {'board': args.board}},
-                                            instance_options={'save_on_destruction': True})
-            if not args.board:
+            project = stm32pio.core.lib.Stm32pio(args.path, parameters={'project': {'board': args.board}},
+                                                 instance_options={'save_on_destruction': True})
+            if project.config.get('project', 'board') == '':
                 logger.warning("PlatformIO board identifier is not specified, it will be needed on PlatformIO project "
                                "creation. Type 'pio boards' or go to https://platformio.org to find an appropriate "
                                "identifier")
@@ -154,10 +155,23 @@ def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
             if args.editor:
                 project.start_editor(args.editor)
 
+        elif args.subcommand == 'generate':
+            project = stm32pio.core.lib.Stm32pio(args.path)
+            project.generate_code()
+            if args.with_build:
+                project.build()
+            if args.editor:
+                project.start_editor(args.editor)
+
+        elif args.subcommand == 'patch':
+            project = stm32pio.core.lib.Stm32pio(args.path)
+            project.patch()
+
         elif args.subcommand == 'new':
-            project = stm32pio.lib.Stm32pio(args.path, parameters={'project': {'board': args.board}},
-                                            instance_options={'save_on_destruction': True})
+            project = stm32pio.core.lib.Stm32pio(args.path, parameters={'project': {'board': args.board}},
+                                                 instance_options={'save_on_destruction': True})
             if project.config.get('project', 'board') == '':
+                logger.info("project has been initialized. You can now edit stm32pio.ini config file")
                 raise Exception("PlatformIO board identifier is not specified, it is needed for PlatformIO project "
                                 "creation. Type 'pio boards' or go to https://platformio.org to find an appropriate "
                                 "identifier")
@@ -169,20 +183,12 @@ def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
             if args.editor:
                 project.start_editor(args.editor)
 
-        elif args.subcommand == 'generate':
-            project = stm32pio.lib.Stm32pio(args.path)
-            project.generate_code()
-            if args.with_build:
-                project.build()
-            if args.editor:
-                project.start_editor(args.editor)
-
         elif args.subcommand == 'status':
-            project = stm32pio.lib.Stm32pio(args.path)
+            project = stm32pio.core.lib.Stm32pio(args.path)
             print(project.state)
 
         elif args.subcommand == 'clean':
-            project = stm32pio.lib.Stm32pio(args.path)
+            project = stm32pio.core.lib.Stm32pio(args.path)
             if args.quiet:
                 project.clean()
             else:
@@ -198,7 +204,7 @@ def main(sys_argv: List[str] = None, should_setup_logging: bool = True) -> int:
 
     # Library is designed to throw the exception in bad cases so we catch here globally
     except Exception:
-        stm32pio.util.log_current_exception(logger)
+        stm32pio.core.util.log_current_exception(logger)
         return -1
 
     return 0
diff --git a/stm32pio_gui/__init__.py b/stm32pio/core/__init__.py
similarity index 100%
rename from stm32pio_gui/__init__.py
rename to stm32pio/core/__init__.py
diff --git a/stm32pio/lib.py b/stm32pio/core/lib.py
similarity index 89%
rename from stm32pio/lib.py
rename to stm32pio/core/lib.py
index 45ca8b5..1b3ea70 100644
--- a/stm32pio/lib.py
+++ b/stm32pio/core/lib.py
@@ -17,8 +17,8 @@
 import weakref
 from typing import Mapping, Any, Union
 
-import stm32pio.settings
-import stm32pio.util
+import stm32pio.core.settings
+import stm32pio.core.util
 
 
 _stages_string_representations = {
@@ -176,7 +176,7 @@ def __init__(self, dirty_path: Union[str, pathlib.Path], parameters: Mapping[str
             self.logger = instance_options['logger']
         else:
             underlying_logger = logging.getLogger('stm32pio.projects')
-            self.logger = stm32pio.util.ProjectLoggerAdapter(underlying_logger, { 'project_id': id(self) })
+            self.logger = stm32pio.core.util.ProjectLoggerAdapter(underlying_logger, { 'project_id': id(self) })
 
         # The path is a primary entity of the project so we process it first and foremost. Handle 'path/to/proj',
         # 'path/to/proj/', '.', '../proj', etc., make the path absolute and check for existence. Also, the .ioc file can
@@ -194,17 +194,15 @@ def __init__(self, dirty_path: Union[str, pathlib.Path], parameters: Mapping[str
         self.ioc_file = self._find_ioc_file(explicit_file=ioc_file)
         self.config.set('project', 'ioc_file', self.ioc_file.name)  # save only the name of file to the config
 
-        # Notify the caller about the board presence
-        if 'board' in parameters and parameters['board'] is not None:
-            try:
-                boards = stm32pio.util.get_platformio_boards(self.config.get('app', 'platformio_cmd'))
-            except Exception as e:
-                self.logger.warning(f"There was an error while obtaining possible PlatformIO boards: {e}",
-                                    exc_info=self.logger.isEnabledFor(logging.DEBUG))
-                boards = []
-            if parameters['board'] not in boards:
-                self.logger.warning(f"'{parameters['board']}' was not found in PlatformIO. "
-                                    "Run 'platformio boards' for possible names")
+        # Put away unnecessary processing as the string still will be formed even if the logging level doesn't allow a
+        # propagation of this message
+        if self.logger.isEnabledFor(logging.DEBUG):
+            debug_str = 'resolved config:'
+            for section in self.config.sections():
+                debug_str += f"\n========== {section} ==========\n"
+                for value in self.config.items(section):
+                    debug_str += f"{value}\n"
+            self.logger.debug(debug_str)
 
         # Save the config on an instance destruction
         if instance_options['save_on_destruction']:
@@ -234,7 +232,7 @@ def state(self) -> ProjectState:
         stages_conditions = collections.OrderedDict()
         stages_conditions[ProjectStage.UNDEFINED] = [True]
         stages_conditions[ProjectStage.EMPTY] = [self.ioc_file.is_file()]
-        stages_conditions[ProjectStage.INITIALIZED] = [self.path.joinpath(stm32pio.settings.config_file_name).is_file()]
+        stages_conditions[ProjectStage.INITIALIZED] = [self.path.joinpath(stm32pio.core.settings.config_file_name).is_file()]
         stages_conditions[ProjectStage.GENERATED] = [self.path.joinpath('Inc').is_dir() and
                                                      len(list(self.path.joinpath('Inc').iterdir())) > 0,
                                                      self.path.joinpath('Src').is_dir() and
@@ -304,7 +302,8 @@ def _find_ioc_file(self, explicit_file: pathlib.Path = None) -> pathlib.Path:
 
     def _load_config(self, runtime_parameters: Mapping[str, Any] = None) -> configparser.ConfigParser:
         """
-        Prepare ConfigParser config for the project. Order of getting values (masking) (higher levels overwrites lower):
+        Prepare ConfigParser config for the project. Order of getting values (masking) (higher levels overwrites lower,
+        but only if a value is non-empty):
 
             default dict (settings module)  =>  config file stm32pio.ini  =>  user-given (runtime) values
                                                                               (via CLI or another way)
@@ -316,42 +315,24 @@ def _load_config(self, runtime_parameters: Mapping[str, Any] = None) -> configpa
         if runtime_parameters is None:
             runtime_parameters = {}
 
-        config = configparser.ConfigParser(interpolation=None)
-
         # Fill with default values ...
-        config.read_dict(copy.deepcopy(stm32pio.settings.config_default))
-
-        # ... then merge with user's config file values (if exist) ...
-        self.logger.debug(f"searching for {stm32pio.settings.config_file_name}...")
-        config.read(self.path.joinpath(stm32pio.settings.config_file_name))
+        config = configparser.ConfigParser(interpolation=None)
+        config.read_dict(copy.deepcopy(stm32pio.core.settings.config_default))
 
+        # ... then merge with user's config file (if exist) values ...
+        self.logger.debug(f"searching for {stm32pio.core.settings.config_file_name}...")
         ini_config = configparser.ConfigParser(interpolation=None)
-        ini_config.read(self.path.joinpath(stm32pio.settings.config_file_name))
-        runtime_config = configparser.ConfigParser(interpolation=None)
-        runtime_config.read_dict(runtime_parameters)
-
-        if len(ini_config.sections()):
-            if len(runtime_config.sections()):
-                for ini_sect in ini_config.sections():
-                    if runtime_config.has_section(ini_sect):
-                        for ini_key, ini_value in ini_config.items(ini_sect):
-                            if runtime_config.get(ini_sect, ini_key, fallback=None) not in [None, ini_value]:
-                                self.logger.info(f"given '{ini_key}' has taken a precedence over the .ini one")
-        else:
-            self.logger.debug(f"no or empty {stm32pio.settings.config_file_name} config file, will use the default one")
+        ini_config.read(self.path.joinpath(stm32pio.core.settings.config_file_name))
+        ini_config_dict = stm32pio.core.util.configparser_to_dict(ini_config)
+        ini_config_dict_cleaned = stm32pio.core.util.cleanup_dict(ini_config_dict)
+        config.read_dict(ini_config_dict_cleaned)
 
         # ... finally merge with the given in this session CLI parameters
-        config.read_dict(runtime_parameters)
-
-        # Put away unnecessary processing as the string still will be formed even if the logging level doesn't allow a
-        # propagation of this message
-        if self.logger.isEnabledFor(logging.DEBUG):
-            debug_str = 'resolved config:'
-            for section in config.sections():
-                debug_str += f"\n========== {section} ==========\n"
-                for value in config.items(section):
-                    debug_str += f"{value}\n"
-            self.logger.debug(debug_str)
+        runtime_config = configparser.ConfigParser(interpolation=None)
+        runtime_config.read_dict(runtime_parameters)
+        runtime_config_dict = stm32pio.core.util.configparser_to_dict(runtime_config)
+        runtime_config_dict_cleaned = stm32pio.core.util.cleanup_dict(runtime_config_dict)
+        config.read_dict(runtime_config_dict_cleaned)
 
         return config
 
@@ -369,9 +350,9 @@ def _save_config(config: configparser.ConfigParser, path: pathlib.Path, logger:
             0 on success, -1 otherwise
         """
         try:
-            with path.joinpath(stm32pio.settings.config_file_name).open(mode='w') as config_file:
+            with path.joinpath(stm32pio.core.settings.config_file_name).open(mode='w') as config_file:
                 config.write(config_file)
-            logger.debug(f"{stm32pio.settings.config_file_name} config file has been saved")
+            logger.debug(f"{stm32pio.core.settings.config_file_name} config file has been saved")
             return 0
         except Exception as e:
             logger.warning(f"cannot save the config: {e}", exc_info=logger.isEnabledFor(logging.DEBUG))
@@ -424,10 +405,15 @@ def generate_code(self) -> int:
                                                                           project_dir_absolute_path=self.path)
                 cubemx_script.write(cubemx_script_content.encode())  # should encode, since mode='w+b'
 
-                command_arr = [self.config.get('app', 'java_cmd'), '-jar', self.config.get('app', 'cubemx_cmd'), '-q',
-                               cubemx_script_name, '-s']  # -q: read the commands from the file, -s: silent performance
+                command_arr = []
+                java_cmd = self.config.get('app', 'java_cmd')
+                # CubeMX can be invoked directly, without a need in Java command
+                if java_cmd and (java_cmd.lower() not in ['none', 'no', 'null', '0']):
+                    command_arr += [self.config.get('app', 'java_cmd'), '-jar']
+                # -q: read the commands from the file, -s: silent performance
+                command_arr += [self.config.get('app', 'cubemx_cmd'), '-q', cubemx_script_name, '-s']
                 # Redirect the output of the subprocess into the logging module (with DEBUG level)
-                with stm32pio.util.LogPipe(self.logger, logging.DEBUG) as log:
+                with stm32pio.core.util.LogPipe(self.logger, logging.DEBUG) as log:
                     result = subprocess.run(command_arr, stdout=log.pipe, stderr=log.pipe)
                     result_output = log.value
 
@@ -438,14 +424,13 @@ def generate_code(self) -> int:
 
         error_msg = "code generation error"
         if result.returncode == 0:
-            # CubeMX 0 return code doesn't necessarily means the correct generation (e.g. migration dialog has appeared
-            # and 'Cancel' was chosen, or CubeMX_version < ioc_file_version), should analyze the output
-            if 'Code succesfully generated' in result_output:
+            if stm32pio.core.settings.cubemx_str_indicating_success in result_output:
                 self.logger.info("successful code generation")
                 return result.returncode
             else:
                 # GUESSING
-                error_lines = [line for line in result_output.splitlines(keepends=True) if '[ERROR]' in line]
+                error_lines = [line for line in result_output.splitlines(keepends=True)
+                               if stm32pio.core.settings.cubemx_str_indicating_error in line]
                 if len(error_lines):
                     self.logger.error(error_lines, extra={ 'from_subprocess': True })
                     raise Exception(error_msg)
@@ -480,8 +465,8 @@ def pio_init(self) -> int:
         except Exception:
             self.logger.warning("'platformio.ini' file is already exist and incorrect")
 
-        command_arr = [self.config.get('app', 'platformio_cmd'), 'init', '-d', str(self.path), '-b',
-                       self.config.get('project', 'board'), '-O', 'framework=stm32cube']
+        command_arr = [self.config.get('app', 'platformio_cmd'), 'project', 'init', '--project-dir', str(self.path),
+                       '--board', self.config.get('project', 'board'), '--project-option', 'framework=stm32cube']
         if not self.logger.isEnabledFor(logging.DEBUG):
             command_arr.append('--silent')
 
@@ -570,11 +555,11 @@ def patch(self) -> None:
         violated. In the end, removes an old empty folders.
         """
 
-        self.logger.debug("patching 'platformio.ini' file...")
-
         if self.platformio_ini_is_patched:
             self.logger.info("'platformio.ini' has been already patched")
         else:
+            self.logger.debug("patching 'platformio.ini' file...")
+
             platformio_ini_config = self.platformio_ini_config  # existing .ini file
 
             patch_config = configparser.ConfigParser(interpolation=None)  # our patch has the INI config format, too
@@ -589,7 +574,7 @@ def patch(self) -> None:
                     self.logger.debug(f"set [{patch_section}]{patch_key} = {patch_value}")
                     platformio_ini_config.set(patch_section, patch_key, patch_value)
 
-            # Save, overwriting (node='w') the original file (deletes all comments!)
+            # Save, overwriting (mode='w') the original file (deletes all comments!)
             with self.path.joinpath('platformio.ini').open(mode='w') as platformio_ini_file:
                 platformio_ini_config.write(platformio_ini_file)
                 self.logger.debug("'platformio.ini' has been patched")
@@ -629,7 +614,7 @@ def build(self) -> int:
         # In the non-verbose mode (logging.INFO) there would be a '--silent' option so if the PlatformIO will decide to
         # output something then it's really important and we use logging.WARNING as a level
         log_level = logging.DEBUG if self.logger.isEnabledFor(logging.DEBUG) else logging.WARNING
-        with stm32pio.util.LogPipe(self.logger, log_level) as log:
+        with stm32pio.core.util.LogPipe(self.logger, log_level) as log:
             result = subprocess.run(command_arr, stdout=log.pipe, stderr=log.pipe)
 
         if result.returncode == 0:
diff --git a/stm32pio/settings.py b/stm32pio/core/settings.py
similarity index 56%
rename from stm32pio/settings.py
rename to stm32pio/core/settings.py
index 4e66c3c..fb7aa7d 100644
--- a/stm32pio/settings.py
+++ b/stm32pio/core/settings.py
@@ -1,16 +1,34 @@
 import collections
 import inspect
-import pathlib
+import os
+from pathlib import Path
 import platform
 
-import stm32pio.lib
+
+# Environment variable indicating we are running on a CI server and should tweak some parameters
+CI_ENV_VARIABLE = os.environ.get('PIPELINE_WORKSPACE')
+
+TEST_FIXTURES_PATH = Path(os.environ.get('STM32PIO_TEST_FIXTURES',
+                                         default=Path(__file__).parent.joinpath('../../tests/fixtures')))
+TEST_CASE = os.environ.get('STM32PIO_TEST_CASE')
+
+patch_mixin = ''
+if TEST_FIXTURES_PATH is not None and TEST_CASE is not None:
+    platformio_ini_lockfile = TEST_FIXTURES_PATH / TEST_CASE / 'platformio.ini.lockfile'
+    if platformio_ini_lockfile.exists():
+        patch_mixin = '\n' + platformio_ini_lockfile.read_text()
 
 
 my_os = platform.system()
 
 config_default = collections.OrderedDict(
     app={
-        # (default is OK) How do you start Java from the command line? (edit if Java not in PATH)
+        'java_cmd': 'java',
+        'platformio_cmd': 'platformio',
+        'cubemx_cmd': str(Path(os.getenv('STM32PIO_CUBEMX_CACHE_FOLDER')).joinpath('STM32CubeMX.exe'))
+    } if CI_ENV_VARIABLE is not None else {
+        # (default is OK) How do you start Java from the command line? (edit if Java not in PATH). Set to 'None'
+        # (string) if in your setup the CubeMX can be invoked straightforwardly
         'java_cmd': 'java',
 
         # (default is OK) How do you start PlatformIO from the command line? (edit if not in PATH, if you use PlatformIO
@@ -20,11 +38,7 @@
         # (default is OK) Trying to guess the STM32CubeMX location. STM actually had changed the installation path
         # several times already. Note that STM32CubeMX will be invoked as 'java -jar CUBEMX'
         'cubemx_cmd':
-            # macOS default: 'Applications' folder
-            "/Applications/STMicroelectronics/STM32CubeMX.app/Contents/Resources/STM32CubeMX" if my_os == 'Darwin' else
-            # Linux (Ubuntu) default: home directory
-            str(pathlib.Path.home().joinpath("STM32CubeMX/STM32CubeMX")) if my_os == 'Linux' else
-            # Windows default: Program Files
+            str(Path.home().joinpath("cubemx/STM32CubeMX.exe")) if my_os in ['Darwin', 'Linux'] else
             "C:/Program Files/STMicroelectronics/STM32Cube/STM32CubeMX/STM32CubeMX.exe" if my_os == 'Windows' else None
     },
     project={
@@ -46,7 +60,7 @@
             [platformio]
             include_dir = Inc
             src_dir = Src
-        ''') + '\n',
+        ''') + '\n' + patch_mixin,
 
         # Runtime-determined values
         'board': '',
@@ -56,6 +70,11 @@
 
 config_file_name = 'stm32pio.ini'
 
+# CubeMX 0 return code doesn't necessarily means the correct generation (e.g. migration dialog has appeared and 'Cancel'
+# was chosen, or CubeMX_version < ioc_file_version, etc.), we should analyze the actual output (STDOUT)
+cubemx_str_indicating_success = 'Code succesfully generated'
+cubemx_str_indicating_error = '[ERROR]'
+
 # Longest name (not necessarily a method so a little bit tricky...)
 # log_fieldwidth_function = max([len(member) for member in dir(stm32pio.lib.Stm32pio)]) + 1
 log_fieldwidth_function = 25 + 1
diff --git a/stm32pio/util.py b/stm32pio/core/util.py
similarity index 70%
rename from stm32pio/util.py
rename to stm32pio/core/util.py
index 7533cd8..acb2244 100644
--- a/stm32pio/util.py
+++ b/stm32pio/core/util.py
@@ -2,20 +2,58 @@
 Some auxiliary entities not falling into other categories
 """
 
+import collections
+import configparser
 import contextlib
+import copy
 import enum
 import json
 import logging
 import os
 import subprocess
+import sys
 import threading
+import time
 import traceback
 import warnings
 from typing import Any, List, Mapping, MutableMapping, Tuple, Optional
 
+import stm32pio.core.settings
+
 module_logger = logging.getLogger(__name__)  # this file logger
 
 
+def _get_version_from_scm() -> str:
+    try:
+        import setuptools_scm  # setuptools_scm is the dev-only dependency
+    except ImportError:
+        return 'Portable (not-installed). See git tag'
+    else:
+        # Calculate the version in real-time from the Git repo state
+        return setuptools_scm.get_version(root='../..', relative_to=__file__)
+
+def get_version() -> str:
+    """Retrieve the app version as string"""
+    if sys.version_info >= (3, 8):
+        import importlib.metadata
+        try:
+            # For modern Python use the package metadata (if we are installed). For this to be available the wheel build
+            # should be done with setuptools_scm
+            return importlib.metadata.version('stm32pio')
+        except importlib.metadata.PackageNotFoundError:
+            # stm32pio is not installed (i.e. running from sources)
+            return _get_version_from_scm()
+    else:
+        try:
+            # Version is stored in the stm32pio/core/version.py file auto-generated by setuptools_scm tool
+            import stm32pio.core.version
+        except ImportError:
+            # Version file is not available, most likely we are not installed (i.e. running from sources)
+            return _get_version_from_scm()
+        else:
+            return stm32pio.core.version.version
+
+
 logging_levels = {  # for exposing the levels to the GUI
     logging.getLevelName(logging.CRITICAL): logging.CRITICAL,
     logging.getLevelName(logging.ERROR): logging.ERROR,
@@ -39,6 +77,8 @@ def log_current_exception(logger: logging.Logger, show_traceback_threshold_level
     """
     exc_full_str = traceback.format_exc()
     exc_str = exc_full_str.splitlines()[-1]
+    if exc_str.startswith('Exception') and not logger.isEnabledFor(show_traceback_threshold_level):
+        exc_str = exc_str[len('Exception: '):]  # meaningless information
     exc_tb = ''.join(exc_full_str.splitlines(keepends=True)[:-1])
     logger.error(f'{exc_str}\n{exc_tb}' if logger.isEnabledFor(show_traceback_threshold_level) else exc_str)
 
@@ -99,7 +139,7 @@ class DispatchingFormatter(logging.Formatter):
     # Mapping of logging formatters for "special". Currently, only "from_subprocess" is defined. It's good to hide such
     # implementation details as much as possible though they are still tweakable from the outer code
     special_formatters = {
-        'from_subprocess': {  # TODO: maybe remade as enum, too? To have an IDE hints and more safety in general
+        'from_subprocess': {
             level: as_is_formatter for level in Verbosity
         }
     }
@@ -176,7 +216,7 @@ def __init__(self, logger: logging.Logger, level: int, *args, **kwargs):
         self.fd_read, self.fd_write = os.pipe()  # create 2 ends of the pipe and setup the reading one
         self.pipe_reader = os.fdopen(self.fd_read)
 
-        self.rc = LogPipeRC(self.fd_write)  # "remote control"
+        self.rc = LogPipeRC(self.fd_write)  # RC stands for "remote control"
 
     def __enter__(self) -> LogPipeRC:
         """
@@ -203,17 +243,46 @@ def __exit__(self, exc_type, exc_val, exc_tb):
         os.close(self.fd_write)
 
 
-def get_platformio_boards(platformio_cmd) -> List[str]:
+_pio_boards_cache: List[str] = []
+_pio_boards_cache_lifetime: float = 5.0
+_pio_boards_fetched_at: float = 0
+
+def get_platformio_boards() -> List[str]:
     """
-    Obtain the PlatformIO boards list. As we interested only in STM32 ones, cut off all the others.
+    Obtain the PlatformIO boards list. As we interested only in STM32 ones, cut off all of the others. Additionally,
+    establish a short-time "cache" to prevent the overflooding with requests to subprocess.
 
     IMPORTANT NOTE: PlatformIO can go to the Internet from time to time when it decides that its cache is out of date.
-    So it can take a long time to execute.
+    So it MAY take a long time to execute.
     """
 
-    # Windows 7, as usual, correctly works only with shell=True...
-    result = subprocess.run(f"{platformio_cmd} boards --json-output stm32cube",
-                            encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, check=True)
+    global _pio_boards_fetched_at, _pio_boards_cache, _pio_boards_cache_lifetime
+
+    current_time = time.time()
+    if len(_pio_boards_cache) == 0 or (current_time - _pio_boards_fetched_at > _pio_boards_cache_lifetime):
+        # Windows 7, as usual, correctly works only with shell=True...
+        result = subprocess.run(f"{stm32pio.core.settings.config_default['app']['platformio_cmd']} boards "
+                                f"--json-output stm32cube", encoding='utf-8', shell=True, stdout=subprocess.PIPE,
+                                check=True)
+        _pio_boards_cache = [board['id'] for board in json.loads(result.stdout)]
+        _pio_boards_fetched_at = current_time
+
+    return copy.copy(_pio_boards_cache)
+
+
+def cleanup_dict(mapping: Mapping[str, Any]) -> dict:
+    """Recursively copy non-empty values to the new dictionary. Return this new dict"""
+    cleaned = {}
+
+    for key, value in mapping.items():
+        if isinstance(value, collections.abc.Mapping):
+            cleaned[key] = cleanup_dict(value)
+        elif value is not None and value != '':
+            cleaned[key] = value
+
+    return cleaned
+
 
-    boards = json.loads(result.stdout)
-    return [board['id'] for board in boards]
+def configparser_to_dict(config: configparser.ConfigParser) -> dict:
+    """Convert configparser.ConfigParser instance to a dictionary"""
+    return {section: {key: value for key, value in config.items(section)} for section in config.sections()}
diff --git a/stm32pio/gui/__init__.py b/stm32pio/gui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stm32pio/gui/__main__.py b/stm32pio/gui/__main__.py
new file mode 100644
index 0000000..7aeb013
--- /dev/null
+++ b/stm32pio/gui/__main__.py
@@ -0,0 +1,12 @@
+import sys
+
+try:
+    import stm32pio.gui.app
+except ModuleNotFoundError:
+    import pathlib
+    sys.path.append(str(pathlib.Path(__file__).parent.parent))  # hack to run the app as 'python __main__.py'
+    import stm32pio.gui.app
+
+
+if __name__ == '__main__':
+    sys.exit(stm32pio.gui.app.main())
diff --git a/stm32pio_gui/app.py b/stm32pio/gui/app.py
old mode 100644
new mode 100755
similarity index 82%
rename from stm32pio_gui/app.py
rename to stm32pio/gui/app.py
index 1487985..02383b2
--- a/stm32pio_gui/app.py
+++ b/stm32pio/gui/app.py
@@ -11,7 +11,7 @@
 import threading
 import time
 import weakref
-from typing import List, Callable, Optional, Any, Mapping, MutableMapping, Iterator
+from typing import List, Callable, Optional, Any, Mapping, MutableMapping, Iterator, Union
 
 try:
     from PySide2.QtCore import QUrl, Property, QAbstractListModel, QModelIndex, QObject, Qt, Slot, Signal, QThread,\
@@ -21,8 +21,10 @@
         # Most UNIX systems does not provide QtDialogs implementation so the program should be 'linked' against
         # the QApplication...
         from PySide2.QtWidgets import QApplication
+        QApplicationClass = QApplication
     else:
         from PySide2.QtGui import QGuiApplication
+        QApplicationClass = QGuiApplication
     from PySide2.QtGui import QIcon
     from PySide2.QtQml import qmlRegisterType, QQmlApplicationEngine, QJSValue
 except ImportError as e:
@@ -31,22 +33,22 @@
           "or manually install its dependencies by yourself")
     sys.exit(-1)
 
-ROOT_PATH = pathlib.Path(sys.path[0]).parent  # repo's or the site-package's root
 MODULE_PATH = pathlib.Path(__file__).parent  # module path, e.g. stm32pio-repo/stm32pio_gui/
+ROOT_PATH = MODULE_PATH.parent.parent  # repo's or the site-package's entry root
 try:
-    import stm32pio.settings
-    import stm32pio.lib
-    import stm32pio.util
-    import stm32pio.app
+    import stm32pio.core.settings
+    import stm32pio.core.lib
+    import stm32pio.core.util
+    import stm32pio.cli.app
 except ModuleNotFoundError:
     sys.path.insert(0, str(ROOT_PATH))
-    import stm32pio.settings
-    import stm32pio.lib
-    import stm32pio.util
-    import stm32pio.app
+    import stm32pio.core.settings
+    import stm32pio.core.lib
+    import stm32pio.core.util
+    import stm32pio.cli.app
 
 
-ProjectID = int
+ProjectID = type(id(object))
 
 
 class BuffersDispatchingHandler(logging.Handler):
@@ -76,7 +78,7 @@ class LoggingWorker(QObject):
     """
     QObject living in a separate QThread, logging everything it receiving. Intended to be an attached
     ProjectListItem property. Stringifies log records using global BuffersDispatchingHandler instance (its
-    stm32pio.util.DispatchingFormatter, to be precise) and passes them via Qt Signal interface so they can be
+    stm32pio.core.util.DispatchingFormatter, to be precise) and passes them via Qt Signal interface so they can be
     conveniently received by any Qt entity. Also, the level of the message is attaching so the reader can
     interpret them differently.
 
@@ -98,7 +100,7 @@ def __init__(self, project_id: ProjectID, parent: QObject = None):
         self.stopped = threading.Event()
         self.can_flush_log = threading.Event()
 
-        self.thread = QThread()
+        self.thread = QThread(parent=self)
         self.moveToThread(self.thread)
         self.thread.started.connect(self.routine)
         self.thread.start()
@@ -124,10 +126,7 @@ class ProjectListItem(QObject):
     """
 
     logAdded = Signal(str, int, arguments=['message', 'level'])  # send the log message to the front-end
-
-    actionStarted = Signal(str, arguments=['action'])
-    actionFinished = Signal(str, bool, arguments=['action', 'success'])
-
+    initialized = Signal(ProjectID, arguments=['project_id'])
 
     def __init__(self, project_args: List[any] = None, project_kwargs: Mapping[str, Any] = None,
                  from_startup: bool = False, parent: QObject = None):
@@ -154,8 +153,8 @@ def __init__(self, project_args: List[any] = None, project_kwargs: Mapping[str,
 
         self._from_startup = from_startup
 
-        underlying_logger = logging.getLogger('stm32pio_gui.projects')
-        self.logger = stm32pio.util.ProjectLoggerAdapter(underlying_logger, { 'project_id': id(self) })
+        underlying_logger = logging.getLogger('stm32pio.gui.projects')
+        self.logger = stm32pio.core.util.ProjectLoggerAdapter(underlying_logger, { 'project_id': id(self) })
         self.logging_worker = LoggingWorker(project_id=id(self))
         self.logging_worker.sendLog.connect(self.logAdded)
 
@@ -163,7 +162,9 @@ def __init__(self, project_args: List[any] = None, project_kwargs: Mapping[str,
         self.workers_pool = QThreadPool(parent=self)
         self.workers_pool.setMaxThreadCount(1)
         self.workers_pool.setExpiryTimeout(-1)  # tasks wait forever for the available spot
-        self._current_action = ''
+
+        self._current_action: str = ''
+        self._last_action_succeed: bool = True
 
         # These values are valid only until the Stm32pio project initialize itself (or failed to) (see init_project)
         self.project = None
@@ -196,9 +197,11 @@ def init_project(self, *args, **kwargs) -> None:
             **kwargs: keyword arguments of the Stm32pio constructor
         """
         try:
-            self.project = stm32pio.lib.Stm32pio(*args, **kwargs)
+            # time.sleep(2.0)
+            # raise Exception('blabla')
+            self.project = stm32pio.core.lib.Stm32pio(*args, **kwargs)
         except Exception:
-            stm32pio.util.log_current_exception(self.logger)
+            stm32pio.core.util.log_current_exception(self.logger)
             if len(args):
                 self._name = args[0]  # use a project path string (as it should be a first argument) as a name
             else:
@@ -215,6 +218,12 @@ def init_project(self, *args, **kwargs) -> None:
             self._finalizer = weakref.finalize(self, self.at_exit, self.workers_pool, self.logging_worker,
                                                self.name if self.project is None else str(self.project))
             self.qml_ready.wait()  # wait for the GUI to initialize (which one is earlier, actually, back or front)
+
+            # TODO: causing
+            # RuntimeWarning: libshiboken: Overflow: Value 4595188736 exceeds limits of type  [signed] "i" (4bytes).
+            # OverflowError
+            self.initialized.emit(id(self))
+
             self.nameChanged.emit()  # in any case we should notify the GUI part about the initialization ending
             self.stageChanged.emit()
             self.stateChanged.emit()
@@ -231,7 +240,7 @@ def at_exit(workers_pool: QThreadPool, logging_worker: LoggingWorker, name: str)
         workers_pool.waitForDone(msecs=-1)
         logging_worker.stopped.set()  # post the event in the logging worker to inform it...
         logging_worker.thread.wait()  # ...and wait for it to exit, too
-        module_logger.info(f"destroyed {name} ProjectListItem")
+        module_logger.info(f"destroyed {name}")
 
 
     @Property(bool)
@@ -272,7 +281,7 @@ def state(self) -> dict:
             # to necessarily keeps them separated
             self._current_stage = str(state.current_stage)
 
-            state.pop(stm32pio.lib.ProjectStage.UNDEFINED)  # exclude UNDEFINED key
+            state.pop(stm32pio.core.lib.ProjectStage.UNDEFINED)  # exclude UNDEFINED key
             # Convert to {string: boolean} dict (will be translated into the JavaScript object)
             return { stage.name: value for stage, value in state.items() }
         else:
@@ -295,6 +304,12 @@ def currentAction(self) -> str:
         """
         return self._current_action
 
+    @Property(bool)
+    def lastActionSucceed(self) -> bool:
+        """Have the last action ended with a success?"""
+        return self._last_action_succeed
+
+    actionStarted = Signal(str, arguments=['action'])
     @Slot(str)
     def actionStartedSlot(self, action: str):
         """Pass the corresponding signal from the worker, perform related tasks"""
@@ -304,9 +319,11 @@ def actionStartedSlot(self, action: str):
         self._current_action = action
         self.actionStarted.emit(action)
 
+    actionFinished = Signal(str, bool, arguments=['action', 'success'])
     @Slot(str, bool)
     def actionFinishedSlot(self, action: str, success: bool):
         """Pass the corresponding signal from the worker, perform related tasks"""
+        self._last_action_succeed = success
         if not success:
             # Clear the queue - stop further execution (cancel planned tasks if an error had happened)
             self.workers_pool.clear()
@@ -333,7 +350,7 @@ def run(self, action: str, args: List[Any]):
             args: list of positional arguments for this action
         """
 
-        worker = Worker(getattr(self.project, action), args, self.logger)
+        worker = Worker(getattr(self.project, action), args, self.logger, parent=self)
         worker.started.connect(self.actionStartedSlot)
         worker.finished.connect(self.actionFinishedSlot)
         worker.finished.connect(self.stateChanged)
@@ -378,7 +395,7 @@ def run(self):
             result = self.func(*self.args)
         except Exception:
             if self.logger is not None:
-                stm32pio.util.log_current_exception(self.logger)
+                stm32pio.core.util.log_current_exception(self.logger)
             result = -1
 
         if result is None or (type(result) == int and result == 0):
@@ -403,7 +420,7 @@ class ProjectsList(QAbstractListModel):
     ProjectListItem
     """
 
-    goToProject = Signal(int, arguments=['indexToGo'])
+    goToProject = Signal(int, arguments=['indexToGo'])  # TODO: should probably belongs to list
 
     def __init__(self, projects: List[ProjectListItem] = None, parent: QObject = None):
         """
@@ -437,32 +454,31 @@ def data(self, index: QModelIndex, role=None):
 
     def _saveInSettings(self) -> None:
         """
-        Get correct projects and save them to Settings. Intended to be run in a thread
+        Get correct projects and save them to Settings. Intended to be run in a thread (as it blocks)
         """
 
         # Wait for all projects to be loaded (project.init_project is finished), whether successful or not
         while not all(project.name != 'Loading...' for project in self.projects):
             pass
 
+        # Only correct ones (inner Stm32pio instance has been successfully constructed)
+        projects_to_save = [project for project in self.projects if project.project is not None]
+
         settings.beginGroup('app')
         settings.remove('projects')  # clear the current saved list
-
         settings.beginWriteArray('projects')
-        # Only correct ones (inner Stm32pio instance has been successfully constructed)
-        projects_to_save = [project for project in self.projects if project.project is not None]
         for idx, project in enumerate(projects_to_save):
             settings.setArrayIndex(idx)
             # This ensures that we always save paths in pathlib form
             settings.setValue('path', str(project.project.path))
         settings.endArray()
-
         settings.endGroup()
+
         module_logger.info(f"{len(projects_to_save)} projects have been saved to Settings")  # total amount
 
     def saveInSettings(self) -> None:
         """Spawn a thread to wait for all projects and save them in background"""
-        w = Worker(self._saveInSettings, logger=module_logger)
-        self.workers_pool.start(w)
+        self.workers_pool.start(Worker(self._saveInSettings, logger=module_logger, parent=self))
 
     def each_project_is_duplicate_of(self, path: str) -> Iterator[bool]:
         """
@@ -480,7 +496,8 @@ def each_project_is_duplicate_of(self, path: str) -> Iterator[bool]:
             except OSError:
                 yield False
 
-    def addListItem(self, path: str, list_item_kwargs: Mapping[str, Any] = None, go_to_this: bool = False) -> None:
+    def addListItem(self, path: str, list_item_kwargs: Mapping[str, Any] = None, go_to_this: bool = False,
+                    on_initialized: Callable[[ProjectID], None] = None) -> ProjectListItem:
         """
         Create and append to the list tail a new ProjectListItem instance. This doesn't save in QSettings, it's an up to
         the caller task (e.g. if we adding a bunch of projects, it make sense to store them once in the end).
@@ -489,12 +506,11 @@ def addListItem(self, path: str, list_item_kwargs: Mapping[str, Any] = None, go_
             path: path as string
             list_item_kwargs: keyword arguments passed to the ProjectListItem constructor
             go_to_this: should we jump to the new project in GUI
+            on_initialized:
         """
 
-        if list_item_kwargs is not None:
-            list_item_kwargs = dict(list_item_kwargs)  # shallow copy, dict makes it mutable
-        else:
-            list_item_kwargs = {}
+        # Shallow copy, dict makes it mutable
+        list_item_kwargs = dict(list_item_kwargs if list_item_kwargs is not None else {})
 
         duplicate_index = next((idx for idx, is_duplicated in enumerate(self.each_project_is_duplicate_of(path))
                                 if is_duplicated), -1)
@@ -506,40 +522,43 @@ def addListItem(self, path: str, list_item_kwargs: Mapping[str, Any] = None, go_
             proj_params = list_item_kwargs.get('project_kwargs', {}).get('parameters', {})
             if len(proj_params):
                 self.projects[duplicate_index].logger.info(f"updating parameters from the CLI... {proj_params}")
+                # Note: will save stm32pio.ini even if there was not one
                 self.projects[duplicate_index].run('save_config', [proj_params])
 
             self.goToProject.emit(duplicate_index)  # jump to the existing one
-            return
-
-        # Insert given path into the constructor args (do not use dict.update() as we have list value that we also want
-        # to "merge")
-        if len(list_item_kwargs) == 0:
-            list_item_kwargs = { 'project_args': [path] }
-        elif 'project_args' not in list_item_kwargs or len(list_item_kwargs['project_args']) == 0:
-            list_item_kwargs['project_args'] = [path]
+
+            return self.projects[duplicate_index]
         else:
-            list_item_kwargs['project_args'][0] = path
+            # Insert given path into the constructor args (do not use dict.update() as we have list value that we also
+            # want to "merge")
+            if len(list_item_kwargs) == 0:
+                list_item_kwargs = { 'project_args': [path] }
+            elif 'project_args' not in list_item_kwargs or len(list_item_kwargs['project_args']) == 0:
+                list_item_kwargs['project_args'] = [path]
+            else:
+                list_item_kwargs['project_args'][0] = path
+
+            # The project is ready to be appended to the model right after the main constructor (wrapper) finished.
+            # The underlying Stm32pio class will be initialized soon later in the dedicated thread
+            project = ProjectListItem(**list_item_kwargs)
 
-        self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
+            if on_initialized is not None:
+                project.initialized.connect(on_initialized)
 
-        # The project is ready to be appended to the model right after the main constructor (wrapper) finished. The
-        # underlying Stm32pio class will be initialized soon later in the dedicated thread
-        project = ProjectListItem(**list_item_kwargs)
-        self.projects.append(project)
+            self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
+            self.projects.append(project)
+            self.endInsertRows()
 
-        self.endInsertRows()
+            if go_to_this:
+                self.goToProject.emit(len(self.projects) - 1)  # append always at the end
 
-        if go_to_this:
-            self.goToProject.emit(len(self.projects) - 1)
+            return project
 
 
     @Slot('QStringList')
     def addProjectsByPaths(self, paths: List[str]):
         """QUrl path (typically is sent from the QML GUI)"""
-        if len(paths) == 0:
-            module_logger.warning("No paths were given")
-            return
-        else:
+        if len(paths):
             for path_str in paths:  # convert to strings
                 path_qurl = QUrl(path_str)
                 if path_qurl.isEmpty():
@@ -553,7 +572,9 @@ def addProjectsByPaths(self, paths: List[str]):
                     module_logger.error(f"Incorrect path: {path_str}")
                     continue
                 self.addListItem(path, list_item_kwargs={ 'parent': self })
-            self.saveInSettings()
+            self.saveInSettings()  # save after all
+        else:
+            module_logger.warning("No paths were given")
 
 
     @Slot(int)
@@ -561,19 +582,17 @@ def removeProject(self, index: int):
         """
         Remove the project residing on the index both from the runtime list and QSettings
         """
-        if index not in range(len(self.projects)):
-            return
-
-        self.beginRemoveRows(QModelIndex(), index, index)
-        project = self.projects.pop(index)
-        self.endRemoveRows()
+        if index in range(len(self.projects)):
+            self.beginRemoveRows(QModelIndex(), index, index)
+            project = self.projects.pop(index)
+            self.endRemoveRows()
 
-        if project.project is not None:
-            # Re-save the settings only if this project was correct and therefore is saved in the settings
-            self.saveInSettings()
+            # Re-save the settings only if this project is saved in the settings
+            if project.project is not None or project.fromStartup:
+                self.saveInSettings()
 
-        # It allows the project to be deconstructed (i.e. GC'ed) very soon, not at the app shutdown time
-        project.deleteLater()
+            # It allows the project to be deconstructed (i.e. GC'ed) very soon, not at the app shutdown time
+            project.deleteLater()
 
 
 
@@ -641,11 +660,22 @@ def set(self, key, value):
             self.external_triggers[key](value)
 
 
+class Application(QApplicationClass):
+
+    loaded = Signal(str, bool, arguments=['action', 'success'])
+
+    def quit(self):
+        """Shutdown"""
+        for window in self.allWindows():
+            window.close()
+
+
 def parse_args(args: list) -> Optional[argparse.Namespace]:
-    parser = argparse.ArgumentParser(description=inspect.cleandoc('''lala'''))
+    parser = argparse.ArgumentParser(description=inspect.cleandoc('''stm32pio GUI version.
+        Visit https://github.com/ussserrr/stm32pio for more information.'''))
 
     # Global arguments (there is also an automatically added '-h, --help' option)
-    parser.add_argument('--version', action='version', version=f"stm32pio v{stm32pio.app.__version__}")
+    parser.add_argument('--version', action='version', version=f"stm32pio v{stm32pio.core.util.get_version()}")
 
     parser.add_argument('-d', '--directory', dest='path', default=str(pathlib.Path.cwd()),
         help="path to the project (current directory, if not given, but any other option should be specified then)")
@@ -654,7 +684,7 @@ def parse_args(args: list) -> Optional[argparse.Namespace]:
     return parser.parse_args(args) if len(args) else None
 
 
-def main(sys_argv: List[str] = None) -> int:
+def main(sys_argv: List[str] = None):
     if sys_argv is None:
         sys_argv = sys.argv[1:]
 
@@ -664,7 +694,7 @@ def main(sys_argv: List[str] = None) -> int:
     module_log_handler.setFormatter(logging.Formatter("%(levelname)s %(funcName)s %(message)s"))
     module_logger.addHandler(module_log_handler)
     module_logger.setLevel(logging.INFO)  # set this again later after getting QSettings
-    module_logger.info('Starting stm32pio_gui...')
+    module_logger.info('Starting stm32pio GUI...')
 
     def qt_message_handler(mode, context, message):
         """
@@ -685,18 +715,14 @@ def qt_message_handler(mode, context, message):
 
     # Apparently Windows version of PySide2 doesn't have QML logging feature turn on so we fill this gap
     # TODO: set up for other platforms too (separate console.debug, console.warn, etc.)
-    qml_logger = logging.getLogger('stm32pio_gui.qml')
+    qml_logger = logging.getLogger('stm32pio.gui.qml')
     if platform.system() == 'Windows':
         qml_log_handler = logging.StreamHandler()
         qml_log_handler.setFormatter(logging.Formatter("[QML] %(levelname)s %(message)s"))
         qml_logger.addHandler(qml_log_handler)
         qInstallMessageHandler(qt_message_handler)
 
-    # Most Linux distros should be "linked" with QWidgets' QApplication instead of QGuiApplication to enable QtDialogs
-    if platform.system() == 'Linux':
-        app = QApplication(sys.argv)
-    else:
-        app = QGuiApplication(sys.argv)
+    app = Application(sys.argv)
 
     # These are used as a settings identifier too
     app.setOrganizationName('ussserrr')
@@ -710,19 +736,19 @@ def verbose_setter(value):
         module_logger.setLevel(logging.DEBUG if value else logging.INFO)
         qml_logger.setLevel(logging.DEBUG if value else logging.INFO)
         projects_logger.setLevel(logging.DEBUG if value else logging.INFO)
-        formatter.verbosity = stm32pio.util.Verbosity.VERBOSE if value else stm32pio.util.Verbosity.NORMAL
+        formatter.verbosity = stm32pio.core.util.Verbosity.VERBOSE if value else stm32pio.core.util.Verbosity.NORMAL
 
     settings = Settings(prefix='app/settings/', qs_kwargs={ 'parent': app },
                         external_triggers={ 'verbose': verbose_setter })
 
     # Use "singleton" real logger for all projects just wrapping it into the LoggingAdapter for every project
-    projects_logger = logging.getLogger('stm32pio_gui.projects')
+    projects_logger = logging.getLogger('stm32pio.gui.projects')
     projects_logger.setLevel(logging.DEBUG if settings.get('verbose') else logging.INFO)
-    formatter = stm32pio.util.DispatchingFormatter(
+    formatter = stm32pio.core.util.DispatchingFormatter(
         general={
-            stm32pio.util.Verbosity.NORMAL: logging.Formatter("%(levelname)-8s %(message)s"),
-            stm32pio.util.Verbosity.VERBOSE: logging.Formatter(
-                f"%(levelname)-8s %(funcName)-{stm32pio.settings.log_fieldwidth_function}s %(message)s")
+            stm32pio.core.util.Verbosity.NORMAL: logging.Formatter("%(levelname)-8s %(message)s"),
+            stm32pio.core.util.Verbosity.VERBOSE: logging.Formatter(
+                f"%(levelname)-8s %(funcName)-{stm32pio.core.settings.log_fieldwidth_function}s %(message)s")
         })
     projects_logger_handler.setFormatter(formatter)
     projects_logger.addHandler(projects_logger_handler)
@@ -746,8 +772,8 @@ def verbose_setter(value):
     projects_model = ProjectsList(parent=engine)
     boards_model = QStringListModel(parent=engine)
 
-    engine.rootContext().setContextProperty('appVersion', stm32pio.app.__version__)
-    engine.rootContext().setContextProperty('Logging', stm32pio.util.logging_levels)
+    engine.rootContext().setContextProperty('appVersion', stm32pio.core.util.get_version())
+    engine.rootContext().setContextProperty('Logging', stm32pio.core.util.logging_levels)
     engine.rootContext().setContextProperty('projectsModel', projects_model)
     engine.rootContext().setContextProperty('boardsModel', boards_model)
     engine.rootContext().setContextProperty('appSettings', settings)
@@ -761,46 +787,54 @@ def verbose_setter(value):
     # them before the projects list is restored, so we start a dedicated loading thread. We actually can add other
     # start-up operations here if there will be a need to. Use the same Worker class to spawn the thread at the pool
     def loading():
-        boards = ['None'] + stm32pio.util.get_platformio_boards('platformio')
+        boards = ['None'] + stm32pio.core.util.get_platformio_boards()
         boards_model.setStringList(boards)
 
-    def loaded(_: str, success: bool):
+    def loaded(action_name: str, success: bool):
         try:
+            cli_project_provided = args is not None
+            initialized_projects_counter = 0
+            def on_initialized(_: ProjectID):
+                nonlocal initialized_projects_counter
+                initialized_projects_counter += 1
+                if initialized_projects_counter == (len(restored_projects_paths) + (1 if cli_project_provided else 0)):
+                    app.loaded.emit(action_name, all((list_item.project is not None for list_item in projects_model.projects)))
+
             # Qt objects cannot be parented from the different thread so we restore the projects list in the main thread
             for path in restored_projects_paths:
-                projects_model.addListItem(path, go_to_this=False, list_item_kwargs={
+                projects_model.addListItem(path, go_to_this=False, on_initialized=on_initialized, list_item_kwargs={
                    'from_startup': True,
                    'parent': projects_model
                 })
 
             # At the end, append (or jump to) a CLI-provided project, if there is one
-            if args is not None:
+            if cli_project_provided:
                 list_item_kwargs = {
                     'from_startup': True,
-                    'parent': projects_model
+                    'parent': projects_model  # TODO: probably can be omitted and automatically passed in the addListItem method (as we do now with a path)
                 }
                 if args.board:
                     list_item_kwargs['project_kwargs'] = { 'parameters': { 'project': { 'board': args.board } } }  # pizdec konechno...
-                projects_model.addListItem(str(pathlib.Path(args.path)), go_to_this=True,
+                projects_model.addListItem(str(pathlib.Path(args.path)), go_to_this=True, on_initialized=on_initialized,
                                            list_item_kwargs=list_item_kwargs)
                 projects_model.saveInSettings()
         except Exception:
-            stm32pio.util.log_current_exception(module_logger)
+            stm32pio.core.util.log_current_exception(module_logger)
             success = False
 
         main_window.backendLoaded.emit(success)  # inform the GUI
 
-    loader = Worker(loading, logger=module_logger)
+    loader = Worker(loading, logger=module_logger, parent=app)
     loader.finished.connect(loaded)
     QThreadPool.globalInstance().start(loader)
 
-    return app.exec_()
+    return app
 
 
 
 # [necessary] globals
-module_logger = logging.getLogger(f'stm32pio_gui.{__name__}')  # use it as a console logger for whatever you want to,
-                                                               # typically not related to the concrete project
+module_logger = logging.getLogger('stm32pio.gui.app')  # use it as a console logger for whatever you want to, typically
+                                                       # not related to the concrete project
 projects_logger_handler = BuffersDispatchingHandler()  # a storage of the buffers for the logging messages of all
                                                        # current projects (see its docs for more info)
 settings = QSettings()  # placeholder, will be replaced in main()
@@ -808,4 +842,5 @@ def loaded(_: str, success: bool):
 
 
 if __name__ == '__main__':
-    sys.exit(main())
+    app_ = main()
+    sys.exit(app_.exec_())
diff --git a/stm32pio_gui/icons/add.svg b/stm32pio/gui/icons/add.svg
similarity index 100%
rename from stm32pio_gui/icons/add.svg
rename to stm32pio/gui/icons/add.svg
diff --git a/stm32pio_gui/icons/drop-here.svg b/stm32pio/gui/icons/drop-here.svg
similarity index 100%
rename from stm32pio_gui/icons/drop-here.svg
rename to stm32pio/gui/icons/drop-here.svg
diff --git a/stm32pio_gui/icons/edit.svg b/stm32pio/gui/icons/edit.svg
similarity index 100%
rename from stm32pio_gui/icons/edit.svg
rename to stm32pio/gui/icons/edit.svg
diff --git a/stm32pio_gui/icons/icon.svg b/stm32pio/gui/icons/icon.svg
similarity index 100%
rename from stm32pio_gui/icons/icon.svg
rename to stm32pio/gui/icons/icon.svg
diff --git a/stm32pio_gui/icons/remove.svg b/stm32pio/gui/icons/remove.svg
similarity index 100%
rename from stm32pio_gui/icons/remove.svg
rename to stm32pio/gui/icons/remove.svg
diff --git a/stm32pio_gui/icons/trash-bin.svg b/stm32pio/gui/icons/trash-bin.svg
similarity index 100%
rename from stm32pio_gui/icons/trash-bin.svg
rename to stm32pio/gui/icons/trash-bin.svg
diff --git a/stm32pio_gui/main.qml b/stm32pio/gui/main.qml
similarity index 96%
rename from stm32pio_gui/main.qml
rename to stm32pio/gui/main.qml
index fa5ca46..b7d7dd5 100644
--- a/stm32pio_gui/main.qml
+++ b/stm32pio/gui/main.qml
@@ -1,10 +1,10 @@
-import QtQuick 2.12
-import QtQuick.Controls 2.12
-import QtQml.Models 2.12
-import QtQuick.Layouts 1.12
-import QtGraphicalEffects 1.12
+import QtQuick 2.14
+import QtQuick.Controls 2.14
+import QtQml.Models 2.14
+import QtQuick.Layouts 1.14
+import QtGraphicalEffects 1.14
 import QtQuick.Dialogs 1.3 as Dialogs
-import QtQml.StateMachine 1.12 as DSM
+import QtQml.StateMachine 1.14 as DSM
 
 import Qt.labs.platform 1.1 as Labs
 
@@ -148,7 +148,7 @@ ApplicationWindow {
                     text: `ver. ${appVersion}<br>
                            2018 - 2020 © ussserrr<br>
                            <a href='https://github.com/ussserrr/stm32pio'>GitHub</a><br><br>
-                           Powered by Python3, PlatformIO, Qt for Python, FlatIcons and other awesome technologies`
+                           Powered by Python, PlatformIO, PySide2, FlatIcons and other awesome technologies`
                     onLinkActivated: {
                         Qt.openUrlExternally(link);
                         aboutDialog.close();
@@ -187,7 +187,9 @@ ApplicationWindow {
 
     Connections {
         target: projectsModel
-        onGoToProject: projectsListView.currentIndex = indexToGo
+        function onGoToProject(indexToGo) {
+            projectsListView.currentIndex = indexToGo;
+        }
     }
     function removeCurrentProject() {
         const indexToRemove = projectsListView.currentIndex;
@@ -276,7 +278,7 @@ ApplicationWindow {
                 id: projectsListView
                 Layout.fillWidth: true
                 Layout.fillHeight: true
-                clip: true  // crawls under the Add/Remove buttons otherwise
+                clip: true
                 keyNavigationWraps: true
 
                 highlight: Rectangle { color: 'darkseagreen' }
@@ -292,7 +294,7 @@ ApplicationWindow {
                     delegate: Loader {
                         /*
                            (See setInitInfo docs) One of the two main widgets representing the project. Use Loader component
-                           as it can give us the relible timestamp of all its children loading completion (unlike Component.onCompleted)
+                           as it can give us the reliable timestamp of all its children loading completion (unlike Component.onCompleted)
                         */
                         onLoaded: {
                             setInitInfo(index);
@@ -303,8 +305,7 @@ ApplicationWindow {
                             readonly property ProjectListItem project: projectsModel.get(index)
                             Connections {
                                 target: project
-                                // Currently, this event is equivalent to the complete initialization of the backend side of the project
-                                onNameChanged: {
+                                function onInitialized() {
                                     initLoading = false;
 
                                     // Appropriately highlight an item depending on its initialization result
@@ -320,11 +321,11 @@ ApplicationWindow {
                                         projectCurrentStage.color = 'seagreen';
                                     }
                                 }
-                                onActionStarted: {
+                                function onActionStarted(action) {
                                     runningOrFinished.currentIndex = 0;
                                     runningOrFinished.visible = true;
                                 }
-                                onActionFinished: {
+                                function onActionFinished(action, success) {
                                     if (index !== projectsListView.currentIndex) {
                                         projectCurrentStage.color = 'darkgray';  // show that the stage has changed from the last visit
                                         runningOrFinished.currentIndex = 1;  // show "notification" about the finished action
@@ -337,7 +338,7 @@ ApplicationWindow {
                             }
                             Connections {
                                 target: projectsListView
-                                onCurrentIndexChanged: {
+                                function onCurrentIndexChanged() {
                                     // "Read" all "notifications" after navigating to the list element
                                     if (projectsListView.currentIndex === index) {
                                         if (Qt.colorEqual(projectName.color, 'seagreen')) {
@@ -476,7 +477,9 @@ ApplicationWindow {
 
             Connections {
                 target: projectsListView
-                onCurrentIndexChanged: projectsWorkspaceView.currentIndex = projectsListView.currentIndex
+                function onCurrentIndexChanged() {
+                    projectsWorkspaceView.currentIndex = projectsListView.currentIndex;
+                }
             }
             Repeater {
                 // Use similar to ListView pattern (same projects model, Loader component)
@@ -498,7 +501,7 @@ ApplicationWindow {
 
                         /*
                            State retrieving procedure is relatively expensive (many IO operations) so we optimize it by getting the state
-                           only in certain situations (see Component.onCompleted below) and caching a value in the local varible. Then, all
+                           only in certain situations (see Component.onCompleted below) and caching a value in the local variable. Then, all
                            widgets can pick up this value as many times as they want while not abusing the real property getter. Such a subscription
                            can be established by the creation of a local reference to the cache and listening to the change event like this:
 
@@ -533,8 +536,7 @@ ApplicationWindow {
 
                         Connections {
                             target: project
-                            // Currently, this event is equivalent to the complete initialization of the backend side of the project
-                            onNameChanged: {
+                            function onInitialized() {
                                 const state = project.state;
                                 stateCached = state;
                                 const completedStages = Object.keys(state).filter(stateName => state[stateName]);
@@ -708,7 +710,7 @@ ApplicationWindow {
                                They also serve the project state displaying - each button indicates a stage associated with it:
                                  - green (and green glow): done
                                  - yellow: in progress right now
-                                 - red glow: an error has occured during the last execution
+                                 - red glow: an error has occurred during the last execution
                             */
                             RowLayout {
                                 id: projActionsRow
@@ -872,11 +874,11 @@ ApplicationWindow {
                                                     }
                                                 }
                                                 onExited: {
-                                                    // Erase highlighting if this action is last in the series or at all
-                                                    if (project.currentAction === model.action &&
+                                                    // Erase highlighting if this action is the last in the series (or an error occurred)
+                                                    if ((project.currentAction === model.action || !project.lastActionSucceed) &&
                                                         shouldBeHighlightedWhileRunning &&
                                                         (buttonIndex === (projActionsModel.count - 1) ||
-                                                         projActionsRow.children[buttonIndex + 1].shouldBeHighlightedWhileRunning === false)
+                                                         !projActionsRow.children[buttonIndex + 1].shouldBeHighlightedWhileRunning)
                                                     ) {
                                                         for (let i = projActionsModel.statefulActionsStartIndex; i <= buttonIndex; ++i) {
                                                             projActionsRow.children[i].shouldBeHighlightedWhileRunning = false;
@@ -899,7 +901,7 @@ ApplicationWindow {
                                             }
                                         }
                                         /*
-                                           Detect modifier keys using overlayed MouseArea:
+                                           Detect modifier keys using overlaying MouseArea:
                                              - Ctrl (Cmd): start the editor after the action(s)
                                              - Shift: batch actions run
                                         */
@@ -971,10 +973,10 @@ ApplicationWindow {
                                         }
                                         Connections {
                                             target: project
-                                            onActionStarted: {
+                                            function onActionStarted(action) {
                                                 glow.visible = false;
                                             }
-                                            onActionFinished: {
+                                            function onActionFinished(action, success) {
                                                 if (action === model.action) {
                                                     if (success) {
                                                         glow.color = 'lightgreen';
@@ -995,7 +997,7 @@ ApplicationWindow {
                                             }
                                         }
                                         /*
-                                           Blinky glowing
+                                           "Blinky" glowing
                                         */
                                         RectangularGlow {
                                             id: glow
@@ -1043,7 +1045,7 @@ ApplicationWindow {
                                         textFormat: TextEdit.RichText
                                         Connections {
                                             target: project
-                                            onLogAdded: {
+                                            function onLogAdded(message, level) {
                                                 if (level === Logging.WARNING) {
                                                     log.append('<font color="goldenrod"><pre style="white-space: pre-wrap">' + message + '</pre></font>');
                                                 } else if (level >= Logging.ERROR) {
diff --git a/stm32pio_gui/README.md b/stm32pio_gui/README.md
deleted file mode 100644
index 8fbbd14..0000000
--- a/stm32pio_gui/README.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# stm32pio GUI
-
-![Main](screenshots/main.png)
-
-The cross-platform GUI version of the stm32pio. It wraps the core library functionality into the Qt5-QML skin using the PySide2 (aka "Qt for Python" project) and adding the projects management feature allowing you to store and manipulate on multiple stm32pio projects at one place.
-
-
-## Table of contents
-> - [Install and run](#install-and-run)
-> - [Usage](#usage)
-> - [Architecture notes](#architecture-notes)
-
-
-## Install and run
-
-The app requires PySide2 5.12+ package (Qt 5.12 respectively). It is available in all major package managers including pip, apt, brew and so on.
-
-The convenient way to install is via `pip` specifying `extras` option:
-```shell script
-$ pip install stm32pio[GUI]
-```
-
-Then it can be started as
-```shell script
-$ stm32pio_gui
-```
-or
-```shell script
-$ stm32pio gui
-```
-from anywhere. If you have already installed the latest basic CLI version, this script and sources are already on your machine so you can reinstall it using the command above or just supplement the setup installing the PySide2 manually.
-
-If you rather want to launch completely from sources, it is possible like this:
-```shell script
-$ python path/to/stm32pio_gui/app.py
-```
-or
-```shell script
-stm32pio-repo/ $   python -m stm32pio_gui
-```
-
-Either way, you can additionally specify the project (and board ID) to open with:
-```shell script
-$ stm32pio_gui -d ./sample-project -b discovery_f4
-```
-
-
-## Usage
-
-Add a folder with the `.ioc` file to begin with. You can either use an "Add" button or drag-and-drop it into the main window, in the latter case you can also have an ability to add multiple projects simultaneously. If the project is empty the initialization screen will be shown to help in setup:
-
-![Init](screenshots/init_screen.png)
-
-Skip it or enter one of the available PlatformIO STM32 boards identifier. Select "Run" to apply all actions to the project (analog of the `new` CLI command).
-
-In the main screen the buttons row allows you to run specific actions while, at the same time, represents the state of the project. Green color means that this stage is fulfilled. The active project is monitored automatically while all the others refreshes only when you click on them so the "stage" line at the projects list item can be outdated.
-
-Let's assume you've worked on the project for some time and need to re-generate and rebuild the configuration. To schedule all the necessary actions to run one after another navigate to the last desired action pressing the Shift key. All the actions prior this one should be colored light-green now:
-
-![Highlighting](screenshots/highlighting.png)
-
-Shift-click on it to execute the series. The picked actions will be framed with the border around each of them:
-
-![Group](screenshots/group.png)
-
-Add Ctrl to the mouse click to start the editor specified in the settings after the action. It can be combined with Shift as well. **Hint:** specify a `start` as an "Editor" command to open the folder in the new Explorer window under the Windows, `open` for the Finder on the macOS.
-
-Currently, the project config (stm32pio.ini) is not live-reloaded so any changes you do to it will not be reflected until the next start.
-
-
-## Architecture notes
-
-Projects list (not the projects themself) and settings are stored by `QSettings` so refer to its docs if you bother about the actual location.
-
-See `docs` directory to see state machine diagram of the project action button.
diff --git a/stm32pio_gui/__main__.py b/stm32pio_gui/__main__.py
deleted file mode 100644
index 619d9a0..0000000
--- a/stm32pio_gui/__main__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import sys
-
-import stm32pio_gui.app
-
-
-if __name__ == '__main__':
-    sys.exit(stm32pio_gui.app.main())
diff --git a/stm32pio_gui/icons/LICENSE b/stm32pio_gui/icons/LICENSE
deleted file mode 100644
index 5e98f5c..0000000
--- a/stm32pio_gui/icons/LICENSE
+++ /dev/null
@@ -1,8 +0,0 @@
-Icons by
-
- - Flat Icons
- - Google
- - Pixel Perfect
- - Freepik
-
-from www.flaticon.com


Test patch:
diff --git a/CI/setup_test_env.py b/CI/setup_test_env.py
new file mode 100644
index 0000000..f379bd0
--- /dev/null
+++ b/CI/setup_test_env.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+
+import yaml
+
+
+def install_cubemx_mcu_packages(query):
+    """Install software packages for CubeMX (which are used for code generation)
+    """
+    # Use mkstemp() instead of the higher-level API for the compatibility with Windows (see tempfile docs for
+    # more details)
+    cubemx_script_file, cubemx_script_name = tempfile.mkstemp()
+    # buffering=0 leads to the immediate flushing on writing
+    with open(cubemx_script_file, mode='w+b', buffering=0) as cubemx_script:
+        cubemx_script_content = '\n'.join([f"swmgr install stm32cube_{series}_{version} accept"
+                                           for series, version in query.items()]) + "\nexit"
+        cubemx_script.write(cubemx_script_content.encode())  # encode since mode='w+b'
+        subprocess.run(['java', '-jar', str(Path(os.getenv('STM32PIO_CUBEMX_CACHE_FOLDER')) / 'STM32CubeMX.exe'), '-q',
+                        cubemx_script_name, '-s'])
+
+
+if __name__ == '__main__':
+    lockfile = yaml.safe_load(Path(__file__).parent.joinpath('lockfile.yml').read_text())['variables']
+
+    install_cubemx_mcu_packages(yaml.safe_load(lockfile['cubemx_packages']))
diff --git a/CI/tests_runner.py b/CI/tests_runner.py
new file mode 100644
index 0000000..0190d19
--- /dev/null
+++ b/CI/tests_runner.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+Run tests on CI
+"""
+
+import os
+from pathlib import Path
+import platform
+import subprocess
+
+import yaml
+
+
+# Environment variable indicating we are running on a CI server and should tweak some parameters
+CI_ENV_VARIABLE = os.environ.get('PIPELINE_WORKSPACE')
+
+
+if __name__ == '__main__':
+    lockfile = yaml.safe_load(Path(__file__).parent.joinpath('lockfile.yml').read_text())['variables']
+    cases = yaml.safe_load(lockfile['test_cases'])
+
+    if CI_ENV_VARIABLE and platform.system() == 'Linux':
+        Path('./pytest.ini').write_text("[pytest]\njunit_family = xunit2\n")  # temp config for pytest
+
+    for case in cases:
+        print('========================================', flush=True)
+        print(f"Test case: {case}", flush=True)
+        print('========================================', flush=True)
+        os.environ['STM32PIO_TEST_CASE'] = case
+        # On Linux also form code coverage report
+        if platform.system() == 'Linux':
+            args = ['pytest', 'tests', '--junitxml=junit/test-results.xml', '--cov=stm32pio/core', '--cov=stm32pio/cli',
+                    '--cov-branch', '--cov-report=xml']
+        else:
+            args = ['python', '-m', 'unittest', '-b', '-v']
+        subprocess.run(args)
diff --git a/tests/common.py b/tests/common.py
new file mode 100755
index 0000000..36914d9
--- /dev/null
+++ b/tests/common.py
@@ -0,0 +1,85 @@
+"""
+Common preparations for all test suites. Use this as a source of constants for test cases. Find the tests themselfs at
+the concrete files
+
+NOTE: make sure the test project tree is clean before running the tests!
+
+'pyenv' was used to execute tests with different Python versions (under Linux):
+https://github.com/pyenv/pyenv
+https://www.tecmint.com/pyenv-install-and-manage-multiple-python-versions-in-linux/
+
+To get the test coverage install and use 'coverage' package:
+    $  coverage run -m unittest -b
+    $  coverage html
+
+This will not cover subprocess calls, though. To get them covered too, use pytest and its pytest-cov plugin
+    $  pip install pytest pytest-cov
+    $  pytest --cov=stm32pio --cov-branch --cov-report=html
+"""
+
+import inspect
+import os
+from pathlib import Path
+import shutil
+import sys
+import tempfile
+import unittest
+
+
+CASES_ROOT = Path(os.environ.get('STM32PIO_TEST_FIXTURES', default=Path(__file__).parent / 'fixtures')).resolve(strict=True)
+os.environ['STM32PIO_TEST_FIXTURES'] = str(CASES_ROOT)
+
+CASE = os.environ.get('STM32PIO_TEST_CASE', default='nucleo_f031k6')
+PROJECT_PATH = CASES_ROOT.joinpath(CASE).resolve(strict=True)
+PROJECT_BOARD = CASE  # currently (PlatformIO board == folder name)
+os.environ['STM32PIO_TEST_CASE'] = CASE
+
+
+import stm32pio.cli.app
+
+
+if not next(PROJECT_PATH.glob('*.ioc'), False):
+    raise FileNotFoundError(f"No .ioc file is present for '{PROJECT_PATH.name}' test case")
+else:
+    PROJECT_IOC_FILENAME = next(PROJECT_PATH.glob('*.ioc')).name
+print(PROJECT_IOC_FILENAME)
+
+# Instantiate a temporary folder on every test suite run. It is used across all the tests and is deleted on shutdown
+# automatically
+TEMP_DIR = tempfile.TemporaryDirectory()
+STAGE_PATH = Path(TEMP_DIR.name).joinpath(PROJECT_PATH.name)
+
+# Absolute path to the main stm32pio script (make sure what we are testing)
+STM32PIO_MAIN_SCRIPT: str = inspect.getfile(stm32pio.cli.app.main)
+# Absolute path to the Python executable (no need to guess whether it's 'python' or 'python3' and so on)
+PYTHON_EXEC: str = sys.executable
+
+print(f"Test case: {PROJECT_BOARD}")
+print(f"The file of {stm32pio.cli.app.__name__} module: {STM32PIO_MAIN_SCRIPT}")
+print(f"Python executable: {PYTHON_EXEC} {sys.version}")
+print(f"Temp test stage path: {STAGE_PATH}")
+print()
+
+
+class CustomTestCase(unittest.TestCase):
+    """These pre- and post-tasks are common for all test cases"""
+
+    def setUp(self):
+        """
+        Copy the test project from the repo to our temp directory. WARNING: make sure the test project folder (one from
+        this repo, not a temporarily created one) is clean (i.e. contains only an .ioc file) before running the test
+        """
+        shutil.rmtree(STAGE_PATH, ignore_errors=True)
+        # ignore: copy only an .ioc files (there should be only 1)
+        shutil.copytree(PROJECT_PATH, STAGE_PATH,
+                        ignore=lambda folder, files: [folder] + list(filter(lambda name: '.ioc' not in name, files)))
+
+    def tearDown(self):
+        """
+        Clean up the temp directory
+        """
+        shutil.rmtree(STAGE_PATH, ignore_errors=True)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/fixtures/bluepill_f103c8/LICENSE b/tests/fixtures/bluepill_f103c8/LICENSE
new file mode 100644
index 0000000..2589ff4
--- /dev/null
+++ b/tests/fixtures/bluepill_f103c8/LICENSE
@@ -0,0 +1,1 @@
+By romychs from the reported GitHub issue https://github.com/ussserrr/stm32pio/issues/13
diff --git a/tests/fixtures/bluepill_f103c8/bluepill_f103c8.ioc b/tests/fixtures/bluepill_f103c8/bluepill_f103c8.ioc
new file mode 100644
index 0000000..98963b6
--- /dev/null
+++ b/tests/fixtures/bluepill_f103c8/bluepill_f103c8.ioc
@@ -0,0 +1,299 @@
+#MicroXplorer Configuration settings - do not modify
+ADC1.Channel-1\#ChannelRegularConversion=ADC_CHANNEL_0
+ADC1.Channel-2\#ChannelRegularConversion=ADC_CHANNEL_1
+ADC1.Channel-3\#ChannelRegularConversion=ADC_CHANNEL_2
+ADC1.Channel-4\#ChannelRegularConversion=ADC_CHANNEL_3
+ADC1.ContinuousConvMode=DISABLE
+ADC1.DataAlign=ADC_DATAALIGN_RIGHT
+ADC1.EnableRegularConversion=ENABLE
+ADC1.IPParameters=ContinuousConvMode,DataAlign,EnableRegularConversion,Rank-1\#ChannelRegularConversion,Channel-1\#ChannelRegularConversion,SamplingTime-1\#ChannelRegularConversion,NbrOfConversionFlag,NbrOfConversion,Rank-2\#ChannelRegularConversion,Channel-2\#ChannelRegularConversion,SamplingTime-2\#ChannelRegularConversion,Rank-3\#ChannelRegularConversion,Channel-3\#ChannelRegularConversion,SamplingTime-3\#ChannelRegularConversion,Rank-4\#ChannelRegularConversion,Channel-4\#ChannelRegularConversion,SamplingTime-4\#ChannelRegularConversion,master
+ADC1.NbrOfConversion=4
+ADC1.NbrOfConversionFlag=1
+ADC1.Rank-1\#ChannelRegularConversion=1
+ADC1.Rank-2\#ChannelRegularConversion=2
+ADC1.Rank-3\#ChannelRegularConversion=3
+ADC1.Rank-4\#ChannelRegularConversion=4
+ADC1.SamplingTime-1\#ChannelRegularConversion=ADC_SAMPLETIME_7CYCLES_5
+ADC1.SamplingTime-2\#ChannelRegularConversion=ADC_SAMPLETIME_7CYCLES_5
+ADC1.SamplingTime-3\#ChannelRegularConversion=ADC_SAMPLETIME_7CYCLES_5
+ADC1.SamplingTime-4\#ChannelRegularConversion=ADC_SAMPLETIME_7CYCLES_5
+ADC1.master=1
+Dma.ADC1.0.Direction=DMA_PERIPH_TO_MEMORY
+Dma.ADC1.0.Instance=DMA1_Channel1
+Dma.ADC1.0.MemDataAlignment=DMA_MDATAALIGN_HALFWORD
+Dma.ADC1.0.MemInc=DMA_MINC_ENABLE
+Dma.ADC1.0.Mode=DMA_NORMAL
+Dma.ADC1.0.PeriphDataAlignment=DMA_PDATAALIGN_HALFWORD
+Dma.ADC1.0.PeriphInc=DMA_PINC_DISABLE
+Dma.ADC1.0.Priority=DMA_PRIORITY_LOW
+Dma.ADC1.0.RequestParameters=Instance,Direction,PeriphInc,MemInc,PeriphDataAlignment,MemDataAlignment,Mode,Priority
+Dma.Request0=ADC1
+Dma.RequestsNb=1
+File.Version=6
+GPIO.groupedBy=Group By Peripherals
+KeepUserPlacement=false
+Mcu.Family=STM32F1
+Mcu.IP0=ADC1
+Mcu.IP1=DMA
+Mcu.IP2=I2C1
+Mcu.IP3=NVIC
+Mcu.IP4=RCC
+Mcu.IP5=SPI2
+Mcu.IP6=SYS
+Mcu.IP7=TIM3
+Mcu.IP8=TIM4
+Mcu.IP9=USART3
+Mcu.IPNb=10
+Mcu.Name=STM32F103C(8-B)Tx
+Mcu.Package=LQFP48
+Mcu.Pin0=PC13-TAMPER-RTC
+Mcu.Pin1=PD0-OSC_IN
+Mcu.Pin10=PA7
+Mcu.Pin11=PB0
+Mcu.Pin12=PB1
+Mcu.Pin13=PB2
+Mcu.Pin14=PB10
+Mcu.Pin15=PB11
+Mcu.Pin16=PB12
+Mcu.Pin17=PB13
+Mcu.Pin18=PB14
+Mcu.Pin19=PB15
+Mcu.Pin2=PD1-OSC_OUT
+Mcu.Pin20=PA8
+Mcu.Pin21=PA9
+Mcu.Pin22=PA13
+Mcu.Pin23=PA14
+Mcu.Pin24=PB3
+Mcu.Pin25=PB4
+Mcu.Pin26=PB5
+Mcu.Pin27=PB6
+Mcu.Pin28=PB7
+Mcu.Pin29=PB9
+Mcu.Pin3=PA0-WKUP
+Mcu.Pin30=VP_SYS_VS_Systick
+Mcu.Pin31=VP_TIM4_VS_ClockSourceINT
+Mcu.Pin4=PA1
+Mcu.Pin5=PA2
+Mcu.Pin6=PA3
+Mcu.Pin7=PA4
+Mcu.Pin8=PA5
+Mcu.Pin9=PA6
+Mcu.PinsNb=32
+Mcu.ThirdPartyNb=0
+Mcu.UserConstants=
+Mcu.UserName=STM32F103C8Tx
+MxCube.Version=5.6.1
+MxDb.Version=DB.5.0.60
+NVIC.BusFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.DMA1_Channel1_IRQn=true\:0\:0\:false\:false\:true\:false\:true
+NVIC.DebugMonitor_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.ForceEnableDMAVector=true
+NVIC.HardFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.MemoryManagement_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.NonMaskableInt_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.PendSV_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.PriorityGroup=NVIC_PRIORITYGROUP_4
+NVIC.SVCall_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+NVIC.SysTick_IRQn=true\:0\:0\:false\:false\:true\:false\:true
+NVIC.TIM3_IRQn=true\:0\:0\:false\:false\:true\:true\:true
+NVIC.TIM4_IRQn=true\:0\:0\:false\:false\:true\:true\:true
+NVIC.UsageFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false
+PA0-WKUP.GPIOParameters=GPIO_Label
+PA0-WKUP.GPIO_Label=STICK_R_X
+PA0-WKUP.Locked=true
+PA0-WKUP.Signal=ADCx_IN0
+PA1.GPIOParameters=GPIO_Label
+PA1.GPIO_Label=STICK_R_Y
+PA1.Locked=true
+PA1.Signal=ADCx_IN1
+PA13.Mode=Serial_Wire
+PA13.Signal=SYS_JTMS-SWDIO
+PA14.Mode=Serial_Wire
+PA14.Signal=SYS_JTCK-SWCLK
+PA2.GPIOParameters=GPIO_Label
+PA2.GPIO_Label=STICK_L_X
+PA2.Locked=true
+PA2.Signal=ADCx_IN2
+PA3.GPIOParameters=GPIO_Label
+PA3.GPIO_Label=STICK_L_Y
+PA3.Locked=true
+PA3.Signal=ADCx_IN3
+PA4.GPIOParameters=GPIO_Label
+PA4.GPIO_Label=VA
+PA4.Signal=ADCx_IN4
+PA5.GPIOParameters=GPIO_Label
+PA5.GPIO_Label=VB
+PA5.Signal=ADCx_IN5
+PA6.GPIOParameters=GPIO_Label
+PA6.GPIO_Label=SB
+PA6.Signal=ADCx_IN6
+PA7.GPIOParameters=GPIO_Label
+PA7.GPIO_Label=SC
+PA7.Signal=ADCx_IN7
+PA8.GPIOParameters=GPIO_Label
+PA8.GPIO_Label=ST_A0
+PA8.Locked=true
+PA8.Signal=GPIO_Output
+PA9.GPIOParameters=GPIO_Label
+PA9.GPIO_Label=ST_RESET
+PA9.Locked=true
+PA9.Signal=GPIO_Output
+PB0.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB0.GPIO_Label=SA
+PB0.GPIO_PuPd=GPIO_PULLUP
+PB0.Locked=true
+PB0.Signal=GPIO_Input
+PB1.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB1.GPIO_Label=SD
+PB1.GPIO_PuPd=GPIO_PULLUP
+PB1.Locked=true
+PB1.Signal=GPIO_Input
+PB10.Mode=Asynchronous
+PB10.Signal=USART3_TX
+PB11.Mode=Asynchronous
+PB11.Signal=USART3_RX
+PB12.GPIOParameters=GPIO_Label
+PB12.GPIO_Label=ST_CS
+PB12.Locked=true
+PB12.Signal=GPIO_Output
+PB13.Mode=Full_Duplex_Master
+PB13.Signal=SPI2_SCK
+PB14.Mode=Full_Duplex_Master
+PB14.Signal=SPI2_MISO
+PB15.Mode=Full_Duplex_Master
+PB15.Signal=SPI2_MOSI
+PB2.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB2.GPIO_Label=SE
+PB2.GPIO_PuPd=GPIO_PULLUP
+PB2.Locked=true
+PB2.Signal=GPIO_Input
+PB3.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB3.GPIO_Label=ENC_BTN
+PB3.GPIO_PuPd=GPIO_PULLUP
+PB3.Locked=true
+PB3.Signal=GPIO_Input
+PB4.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB4.GPIO_Label=ENC_A
+PB4.GPIO_PuPd=GPIO_PULLUP
+PB4.Locked=true
+PB4.Signal=S_TIM3_CH1
+PB5.GPIOParameters=GPIO_PuPd,GPIO_Label
+PB5.GPIO_Label=ENC_B
+PB5.GPIO_PuPd=GPIO_PULLUP
+PB5.Locked=true
+PB5.Signal=S_TIM3_CH2
+PB6.Mode=I2C
+PB6.Signal=I2C1_SCL
+PB7.Mode=I2C
+PB7.Signal=I2C1_SDA
+PB9.GPIOParameters=GPIO_Speed,GPIO_Label
+PB9.GPIO_Label=ST_LED_PWM
+PB9.GPIO_Speed=GPIO_SPEED_FREQ_HIGH
+PB9.Locked=true
+PB9.Signal=S_TIM4_CH4
+PC13-TAMPER-RTC.GPIOParameters=GPIO_Label,GPIO_ModeDefaultOutputPP
+PC13-TAMPER-RTC.GPIO_Label=RED_LED
+PC13-TAMPER-RTC.GPIO_ModeDefaultOutputPP=GPIO_MODE_OUTPUT_PP
+PC13-TAMPER-RTC.Locked=true
+PC13-TAMPER-RTC.Signal=GPIO_Output
+PD0-OSC_IN.Mode=HSE-External-Oscillator
+PD0-OSC_IN.Signal=RCC_OSC_IN
+PD1-OSC_OUT.Mode=HSE-External-Oscillator
+PD1-OSC_OUT.Signal=RCC_OSC_OUT
+PinOutPanel.RotationAngle=0
+ProjectManager.AskForMigrate=true
+ProjectManager.BackupPrevious=false
+ProjectManager.CompilerOptimize=6
+ProjectManager.ComputerToolchain=false
+ProjectManager.CoupleFile=true
+ProjectManager.CustomerFirmwarePackage=
+ProjectManager.DefaultFWLocation=true
+ProjectManager.DeletePrevious=true
+ProjectManager.DeviceId=STM32F103C8Tx
+ProjectManager.FirmwarePackage=STM32Cube FW_F1 V1.8.0
+ProjectManager.FreePins=false
+ProjectManager.HalAssertFull=false
+ProjectManager.HeapSize=0x200
+ProjectManager.KeepUserCode=true
+ProjectManager.LastFirmware=true
+ProjectManager.LibraryCopy=1
+ProjectManager.MainLocation=Src
+ProjectManager.NoMain=false
+ProjectManager.PreviousToolchain=
+ProjectManager.ProjectBuild=false
+ProjectManager.ProjectFileName=TEnco.ioc
+ProjectManager.ProjectName=TEnco
+ProjectManager.StackSize=0x400
+ProjectManager.TargetToolchain=Other Toolchains (GPDSC)
+ProjectManager.ToolChainLocation=
+ProjectManager.UnderRoot=false
+ProjectManager.functionlistsort=1-MX_GPIO_Init-GPIO-false-HAL-true,2-MX_DMA_Init-DMA-false-HAL-true,3-SystemClock_Config-RCC-false-HAL-false,4-MX_ADC1_Init-ADC1-false-HAL-true,5-MX_SPI2_Init-SPI2-false-HAL-true,6-MX_USART3_UART_Init-USART3-false-HAL-true,7-MX_TIM4_Init-TIM4-false-HAL-true,8-MX_TIM3_Init-TIM3-false-HAL-true
+RCC.ADCFreqValue=3000000
+RCC.ADCPresc=RCC_ADCPCLK2_DIV6
+RCC.AHBCLKDivider=RCC_SYSCLK_DIV2
+RCC.AHBFreq_Value=36000000
+RCC.APB1Freq_Value=36000000
+RCC.APB1TimFreq_Value=36000000
+RCC.APB2CLKDivider=RCC_HCLK_DIV2
+RCC.APB2Freq_Value=18000000
+RCC.APB2TimFreq_Value=36000000
+RCC.FCLKCortexFreq_Value=36000000
+RCC.FamilyName=M
+RCC.HCLKFreq_Value=36000000
+RCC.IPParameters=ADCFreqValue,ADCPresc,AHBCLKDivider,AHBFreq_Value,APB1Freq_Value,APB1TimFreq_Value,APB2CLKDivider,APB2Freq_Value,APB2TimFreq_Value,FCLKCortexFreq_Value,FamilyName,HCLKFreq_Value,MCOFreq_Value,PLLCLKFreq_Value,PLLMCOFreq_Value,PLLMUL,PLLSourceVirtual,SYSCLKFreq_VALUE,SYSCLKSource,TimSysFreq_Value,USBFreq_Value,VCOOutput2Freq_Value
+RCC.MCOFreq_Value=72000000
+RCC.PLLCLKFreq_Value=72000000
+RCC.PLLMCOFreq_Value=36000000
+RCC.PLLMUL=RCC_PLL_MUL9
+RCC.PLLSourceVirtual=RCC_PLLSOURCE_HSE
+RCC.SYSCLKFreq_VALUE=72000000
+RCC.SYSCLKSource=RCC_SYSCLKSOURCE_PLLCLK
+RCC.TimSysFreq_Value=36000000
+RCC.USBFreq_Value=72000000
+RCC.VCOOutput2Freq_Value=8000000
+SH.ADCx_IN0.0=ADC1_IN0,IN0
+SH.ADCx_IN0.ConfNb=1
+SH.ADCx_IN1.0=ADC1_IN1,IN1
+SH.ADCx_IN1.ConfNb=1
+SH.ADCx_IN2.0=ADC1_IN2,IN2
+SH.ADCx_IN2.ConfNb=1
+SH.ADCx_IN3.0=ADC1_IN3,IN3
+SH.ADCx_IN3.ConfNb=1
+SH.ADCx_IN4.0=ADC1_IN4,IN4
+SH.ADCx_IN4.ConfNb=1
+SH.ADCx_IN5.0=ADC1_IN5,IN5
+SH.ADCx_IN5.ConfNb=1
+SH.ADCx_IN6.0=ADC1_IN6,IN6
+SH.ADCx_IN6.ConfNb=1
+SH.ADCx_IN7.0=ADC1_IN7,IN7
+SH.ADCx_IN7.ConfNb=1
+SH.S_TIM3_CH1.0=TIM3_CH1,Encoder_Interface
+SH.S_TIM3_CH1.ConfNb=1
+SH.S_TIM3_CH2.0=TIM3_CH2,Encoder_Interface
+SH.S_TIM3_CH2.ConfNb=1
+SH.S_TIM4_CH4.0=TIM4_CH4,PWM Generation4 CH4
+SH.S_TIM4_CH4.ConfNb=1
+SPI2.BaudRatePrescaler=SPI_BAUDRATEPRESCALER_8
+SPI2.CalculateBaudRate=4.5 MBits/s
+SPI2.Direction=SPI_DIRECTION_2LINES
+SPI2.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate,BaudRatePrescaler
+SPI2.Mode=SPI_MODE_MASTER
+SPI2.VirtualType=VM_MASTER
+TIM3.IC1Filter=2
+TIM3.IC1Prescaler=TIM_ICPSC_DIV1
+TIM3.IC2Filter=2
+TIM3.IC2Prescaler=TIM_ICPSC_DIV1
+TIM3.IPParameters=Period,IC1Filter,IC2Filter,Prescaler,IC1Prescaler,IC2Prescaler
+TIM3.Period=1023
+TIM3.Prescaler=0
+TIM4.Channel-PWM\ Generation4\ CH4=TIM_CHANNEL_4
+TIM4.IPParameters=Channel-PWM Generation4 CH4,Period
+TIM4.Period=65535
+USART3.BaudRate=9600
+USART3.IPParameters=VirtualMode,BaudRate
+USART3.VirtualMode=VM_ASYNC
+VP_SYS_VS_Systick.Mode=SysTick
+VP_SYS_VS_Systick.Signal=SYS_VS_Systick
+VP_TIM4_VS_ClockSourceINT.Mode=Internal
+VP_TIM4_VS_ClockSourceINT.Signal=TIM4_VS_ClockSourceINT
+board=custom
diff --git a/stm32pio-test-project/stm32pio-test-project.ioc b/tests/fixtures/nucleo_f031k6/nucleo_f031k6.ioc
similarity index 91%
rename from stm32pio-test-project/stm32pio-test-project.ioc
rename to tests/fixtures/nucleo_f031k6/nucleo_f031k6.ioc
index 90016d5..521404f 100644
--- a/stm32pio-test-project/stm32pio-test-project.ioc
+++ b/tests/fixtures/nucleo_f031k6/nucleo_f031k6.ioc
@@ -19,8 +19,8 @@ Mcu.PinsNb=6
 Mcu.ThirdPartyNb=0
 Mcu.UserConstants=
 Mcu.UserName=STM32F031K6Tx
-MxCube.Version=5.6.0
-MxDb.Version=DB.5.0.60
+MxCube.Version=6.0.1
+MxDb.Version=DB.6.0.0
 NVIC.ForceEnableDMAVector=true
 NVIC.HardFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false
 NVIC.NonMaskableInt_IRQn=true\:0\:0\:false\:false\:true\:false\:false
@@ -60,19 +60,20 @@ ProjectManager.CustomerFirmwarePackage=
 ProjectManager.DefaultFWLocation=true
 ProjectManager.DeletePrevious=true
 ProjectManager.DeviceId=STM32F031K6Tx
-ProjectManager.FirmwarePackage=STM32Cube FW_F0 V1.11.0
+ProjectManager.FirmwarePackage=STM32Cube FW_F0 V1.11.1
 ProjectManager.FreePins=false
 ProjectManager.HalAssertFull=false
 ProjectManager.HeapSize=0x200
 ProjectManager.KeepUserCode=true
 ProjectManager.LastFirmware=true
 ProjectManager.LibraryCopy=1
-ProjectManager.MainLocation=Src
+ProjectManager.MainLocation=Core/Src
 ProjectManager.NoMain=false
 ProjectManager.PreviousToolchain=
 ProjectManager.ProjectBuild=false
-ProjectManager.ProjectFileName=stm32pio-test-project.ioc
-ProjectManager.ProjectName=stm32pio-test-project
+ProjectManager.ProjectFileName=nucleo_f031k6.ioc
+ProjectManager.ProjectName=nucleo_f031k6
+ProjectManager.RegisterCallBack=
 ProjectManager.StackSize=0x400
 ProjectManager.TargetToolchain=Other Toolchains (GPDSC)
 ProjectManager.ToolChainLocation=
diff --git a/tests/fixtures/nucleo_f031k6/platformio.ini.lockfile b/tests/fixtures/nucleo_f031k6/platformio.ini.lockfile
new file mode 100644
index 0000000..0cf28ae
--- /dev/null
+++ b/tests/fixtures/nucleo_f031k6/platformio.ini.lockfile
@@ -0,0 +1,4 @@
+; Doesn't work on all OSes...
+; [env:nucleo_f031k6]
+; platform = ststm32@6.1.0
+; platform_packages = toolchain-gccarmnoneeabi@1.70201.0, framework-stm32cube@2.0.181130
diff --git a/tests/test.py b/tests/test.py
deleted file mode 100755
index 91270e1..0000000
--- a/tests/test.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""
-Common preparations for all test suites. Use this as a source of constants for test cases. Find the tests themselfs at
-the concrete files
-
-NOTE: make sure the test project tree is clean before running the tests!
-
-'pyenv' was used to execute tests with different Python versions (under Linux):
-https://github.com/pyenv/pyenv
-https://www.tecmint.com/pyenv-install-and-manage-multiple-python-versions-in-linux/
-
-To get the test coverage install and use 'coverage' package:
-    $  coverage run -m stm32pio.tests.test -b
-    $  coverage html
-"""
-
-import inspect
-import logging
-import pathlib
-import shutil
-import sys
-import tempfile
-import unittest
-
-import stm32pio.app
-
-
-TEST_PROJECT_PATH = pathlib.Path('stm32pio-test-project').resolve(strict=True)
-if not TEST_PROJECT_PATH.joinpath('stm32pio-test-project.ioc').is_file():
-    raise FileNotFoundError("No test project is present")
-
-# Gently ask a user running tests to remove all irrelevant files from the TEST_PROJECT_PATH as they can interfere with
-# execution
-if len(list(TEST_PROJECT_PATH.iterdir())) > 1:
-    raise Warning(f"There are extrinsic files in the test project directory '{TEST_PROJECT_PATH}'. Please persist only "
-                  "the .ioc file and restart")
-
-# Make sure you have F0 framework installed (both for PlatformIO and CubeMX) (try to run a code generation and build
-# manually at least once before proceeding)
-TEST_PROJECT_BOARD = 'nucleo_f031k6'
-
-# Instantiate a temporary folder on every test suite run. It is used across all the tests and is deleted on shutdown
-# automatically
-TEMP_DIR = tempfile.TemporaryDirectory()
-FIXTURE_PATH = pathlib.Path(TEMP_DIR.name).joinpath(TEST_PROJECT_PATH.name)
-
-# Absolute path to the main stm32pio script (make sure what repo we are testing)
-STM32PIO_MAIN_SCRIPT: str = inspect.getfile(stm32pio.app)
-# Absolute path to the Python executable (no need to guess whether it's 'python' or 'python3' and so on)
-PYTHON_EXEC: str = sys.executable
-
-print(f"The file of 'stm32pio.app' module: {STM32PIO_MAIN_SCRIPT}")
-print(f"Python executable: {PYTHON_EXEC} {sys.version}")
-print(f"Temp test fixture path: {FIXTURE_PATH}")
-print()
-
-
-class CustomTestCase(unittest.TestCase):
-    """These pre- and post-tasks are common for all test cases"""
-
-    def setUp(self):
-        """
-        Copy the test project from the repo to our temp directory. WARNING: make sure the test project folder (one from
-        this repo, not a temporarily created one) is clean (i.e. contains only an .ioc file) before running the test
-        """
-        shutil.rmtree(FIXTURE_PATH, ignore_errors=True)
-        shutil.copytree(TEST_PROJECT_PATH, FIXTURE_PATH)
-
-    def tearDown(self):
-        """
-        Clean up the temp directory
-        """
-        shutil.rmtree(FIXTURE_PATH, ignore_errors=True)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 7e672b7..58abdd2 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -6,13 +6,13 @@
 import subprocess
 import unittest.mock
 
-import stm32pio.app
-import stm32pio.lib
-import stm32pio.settings
-import stm32pio.util
+# Provides test constants and definitions
+from tests.common import *
 
-# Provides test constants
-from tests.test import *
+import stm32pio.cli.app
+import stm32pio.core.lib
+import stm32pio.core.settings
+import stm32pio.core.util
 
 
 class TestCLI(CustomTestCase):
@@ -25,19 +25,19 @@ def test_clean(self):
         for case in ['--quiet', 'yes', 'no']:
             with self.subTest(case=case):
                 # Create files and folders
-                test_file = FIXTURE_PATH.joinpath('test.file')
-                test_dir = FIXTURE_PATH.joinpath('test.dir')
+                test_file = STAGE_PATH.joinpath('test.file')
+                test_dir = STAGE_PATH.joinpath('test.dir')
                 test_file.touch(exist_ok=False)
                 test_dir.mkdir(exist_ok=False)
 
                 # Clean ...
                 if case == '--quiet':
-                    return_code = stm32pio.app.main(sys_argv=['clean', case, '-d', str(FIXTURE_PATH)],
-                                                    should_setup_logging=False)
+                    return_code = stm32pio.cli.app.main(sys_argv=['clean', case, '-d', str(STAGE_PATH)],
+                                                        should_setup_logging=False)
                 else:
                     with unittest.mock.patch('builtins.input', return_value=case):
-                        return_code = stm32pio.app.main(sys_argv=['clean', '-d', str(FIXTURE_PATH)],
-                                                        should_setup_logging=False)
+                        return_code = stm32pio.cli.app.main(sys_argv=['clean', '-d', str(STAGE_PATH)],
+                                                            should_setup_logging=False)
 
                 self.assertEqual(return_code, 0, msg="Non-zero return code")
 
@@ -55,33 +55,33 @@ def test_clean(self):
 
                 # ... and .ioc file should be preserved in any case
                 with self.subTest():
-                    self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(),
+                    self.assertTrue(STAGE_PATH.joinpath(f"{STAGE_PATH.name}.ioc").is_file(),
                                     msg="Missing .ioc file")
 
     def test_new(self):
         """
         Successful build is the best indicator that all went right so we use '--with-build' option here
         """
-        return_code = stm32pio.app.main(
-            sys_argv=['new', '-d', str(FIXTURE_PATH), '-b', TEST_PROJECT_BOARD, '--with-build'],
+        return_code = stm32pio.cli.app.main(
+            sys_argv=['new', '-d', str(STAGE_PATH), '-b', PROJECT_BOARD, '--with-build'],
             should_setup_logging=False)
         self.assertEqual(return_code, 0, msg="Non-zero return code")
 
         # .ioc file should be preserved
-        self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file")
+        self.assertTrue(STAGE_PATH.joinpath(f"{STAGE_PATH.name}.ioc").is_file(), msg="Missing .ioc file")
 
     def test_generate(self):
-        return_code = stm32pio.app.main(sys_argv=['generate', '-d', str(FIXTURE_PATH)], should_setup_logging=False)
+        return_code = stm32pio.cli.app.main(sys_argv=['generate', '-d', str(STAGE_PATH)], should_setup_logging=False)
         self.assertEqual(return_code, 0, msg="Non-zero return code")
 
         for directory in ['Inc', 'Src']:
             with self.subTest():
-                self.assertTrue(FIXTURE_PATH.joinpath(directory).is_dir(), msg=f"Missing '{directory}'")
-                self.assertNotEqual(len(list(FIXTURE_PATH.joinpath(directory).iterdir())), 0,
+                self.assertTrue(STAGE_PATH.joinpath(directory).is_dir(), msg=f"Missing '{directory}'")
+                self.assertNotEqual(len(list(STAGE_PATH.joinpath(directory).iterdir())), 0,
                                     msg=f"'{directory}' is empty")
 
         # .ioc file should be preserved
-        self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file")
+        self.assertTrue(STAGE_PATH.joinpath(f"{STAGE_PATH.name}.ioc").is_file(), msg="Missing .ioc file")
 
     def test_should_log_error(self):
         """
@@ -90,19 +90,19 @@ def test_should_log_error(self):
         with self.subTest(error="Incorrect path"):
             path_not_exist = pathlib.Path('path_some_uniq_name/does/not/exist')
             with self.assertLogs(level='ERROR') as logs:
-                return_code = stm32pio.app.main(sys_argv=['init', '-d', str(path_not_exist)],
-                                                should_setup_logging=False)
+                return_code = stm32pio.cli.app.main(sys_argv=['init', '-d', str(path_not_exist)],
+                                                    should_setup_logging=False)
                 self.assertNotEqual(return_code, 0, msg="Return code should be non-zero")
                 # Actual text varies for different OSes and system languages so we check only for a part of the string
                 self.assertTrue(next((True for msg in logs.output if 'path_some_uniq_name' in msg.lower()), False),
                                 msg="'ERROR' logging message hasn't been printed")
 
         with self.subTest(error="No .ioc file"):
-            dir_with_no_ioc_file = FIXTURE_PATH.joinpath('dir.with.no.ioc.file')
+            dir_with_no_ioc_file = STAGE_PATH.joinpath('dir.with.no.ioc.file')
             dir_with_no_ioc_file.mkdir(exist_ok=False)
             with self.assertLogs(level='ERROR') as logs:
-                return_code = stm32pio.app.main(sys_argv=['init', '-d', str(dir_with_no_ioc_file)],
-                                                should_setup_logging=False)
+                return_code = stm32pio.cli.app.main(sys_argv=['init', '-d', str(dir_with_no_ioc_file)],
+                                                    should_setup_logging=False)
                 self.assertNotEqual(return_code, 0, msg="Return code should be non-zero")
                 self.assertTrue(next((True for msg in logs.output if FileNotFoundError.__name__ in msg), False),
                                 msg="'ERROR' logging message hasn't been printed")
@@ -121,10 +121,10 @@ def test_verbosity(self):
 
         # inspect.getmembers() is great but it triggers class properties to execute leading to the unwanted code
         # execution
-        methods = dir(stm32pio.lib.Stm32pio) + ['main']
+        methods = dir(stm32pio.core.lib.Stm32pio) + ['main']
 
-        with self.subTest(verbosity_level=stm32pio.util.Verbosity.NORMAL):
-            result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'generate', '-d', str(FIXTURE_PATH)],
+        with self.subTest(verbosity_level=stm32pio.core.util.Verbosity.NORMAL):
+            result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'generate', '-d', str(STAGE_PATH)],
                                     stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8')
 
             self.assertEqual(result.returncode, 0, msg="Non-zero return code")
@@ -140,9 +140,9 @@ def test_verbosity(self):
             # The snippet of the actual STM32CubeMX output
             self.assertNotIn('Starting STM32CubeMX', result.stderr, msg="STM32CubeMX has printed its logs")
 
-        with self.subTest(verbosity_level=stm32pio.util.Verbosity.VERBOSE):
-            result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, '-v', 'new', '-d', str(FIXTURE_PATH),
-                                     '-b', TEST_PROJECT_BOARD], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+        with self.subTest(verbosity_level=stm32pio.core.util.Verbosity.VERBOSE):
+            result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, '-v', 'new', '-d', str(STAGE_PATH),
+                                     '-b', PROJECT_BOARD], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                     encoding='utf-8')
 
             self.assertEqual(result.returncode, 0, msg="Non-zero return code")
@@ -153,8 +153,8 @@ def test_verbosity(self):
 
             # Inject all methods' names in the regex. Inject the width of field in a log format string
             regex = re.compile("^(?=(DEBUG) {0,4})(?=.{8} (?=(" + '|'.join(methods) + ") {0," +
-                               str(stm32pio.settings.log_fieldwidth_function) + "})(?=.{" +
-                               str(stm32pio.settings.log_fieldwidth_function) + "} [^ ]))", flags=re.MULTILINE)
+                               str(stm32pio.core.settings.log_fieldwidth_function) + "})(?=.{" +
+                               str(stm32pio.core.settings.log_fieldwidth_function) + "} [^ ]))", flags=re.MULTILINE)
             self.assertGreaterEqual(len(re.findall(regex, result.stderr)), 1,
                                     msg="Logs messages doesn't match the format")
 
@@ -165,21 +165,21 @@ def test_init(self):
         """
         Check for the config creation and parameters presence
         """
-        result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'init', '-d', str(FIXTURE_PATH),
-                                 '-b', TEST_PROJECT_BOARD], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+        result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'init', '-d', str(STAGE_PATH),
+                                 '-b', PROJECT_BOARD], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
         self.assertEqual(result.returncode, 0, msg="Non-zero return code")
 
-        self.assertTrue(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name).is_file(),
-                        msg=f"{stm32pio.settings.config_file_name} file hasn't been created")
+        self.assertTrue(STAGE_PATH.joinpath(stm32pio.core.settings.config_file_name).is_file(),
+                        msg=f"{stm32pio.core.settings.config_file_name} file hasn't been created")
 
         config = configparser.ConfigParser(interpolation=None)
-        config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name)))
-        for section, parameters in stm32pio.settings.config_default.items():
+        config.read(str(STAGE_PATH.joinpath(stm32pio.core.settings.config_file_name)))
+        for section, parameters in stm32pio.core.settings.config_default.items():
             for option, value in parameters.items():
                 with self.subTest(section=section, option=option,
                                   msg="Section/key is not found in the saved config file"):
                     self.assertIsNotNone(config.get(section, option, fallback=None))
-        self.assertEqual(config.get('project', 'board', fallback="Not found"), TEST_PROJECT_BOARD,
+        self.assertEqual(config.get('project', 'board', fallback="Not found"), PROJECT_BOARD,
                          msg="'board' has not been set")
 
     def test_status(self):
@@ -189,14 +189,14 @@ def test_status(self):
 
         buffer_stdout = io.StringIO()
         with contextlib.redirect_stdout(buffer_stdout), contextlib.redirect_stderr(None):
-            return_code = stm32pio.app.main(sys_argv=['status', '-d', str(FIXTURE_PATH)], should_setup_logging=False)
+            return_code = stm32pio.cli.app.main(sys_argv=['status', '-d', str(STAGE_PATH)], should_setup_logging=False)
 
         self.assertEqual(return_code, 0, msg="Non-zero return code")
 
         matches_counter = 0
         last_stage_pos = -1
-        for stage in stm32pio.lib.ProjectStage:
-            if stage != stm32pio.lib.ProjectStage.UNDEFINED:
+        for stage in stm32pio.core.lib.ProjectStage:
+            if stage != stm32pio.core.lib.ProjectStage.UNDEFINED:
                 match = re.search(r"^((\[ \])|(\[\*\])) {2}" + str(stage) + '$', buffer_stdout.getvalue(), re.MULTILINE)
                 self.assertTrue(match, msg="Status information was not found on STDOUT")
                 if match:
@@ -204,4 +204,4 @@ def test_status(self):
                     self.assertGreater(match.start(), last_stage_pos, msg="The order of stages is messed up")
                     last_stage_pos = match.start()
 
-        self.assertEqual(matches_counter, len(stm32pio.lib.ProjectStage) - 1)  # UNDEFINED stage should not be printed
+        self.assertEqual(matches_counter, len(stm32pio.core.lib.ProjectStage) - 1)  # UNDEFINED stage should not be printed
diff --git a/tests/test_gui.py b/tests/test_gui.py
new file mode 100644
index 0000000..dddbcab
--- /dev/null
+++ b/tests/test_gui.py
@@ -0,0 +1,29 @@
+import platform
+
+# Provides test constants and definitions
+from tests.common import *
+
+
+class TestGUI(CustomTestCase):
+    def test_imports(self):
+        import stm32pio.gui.app
+
+    @unittest.skipIf(platform.system() == 'Linux', "works unstable under Linux")
+    def test_starts(self):
+        import stm32pio.gui.app
+
+        successful_loading_flag = False
+
+        def on_loaded(_: str, success: bool):
+            nonlocal successful_loading_flag
+            successful_loading_flag = success
+            projects_list = app.findChildren(stm32pio.gui.app.ProjectsList)[0]
+            projects_list.removeProject(0)
+            app.quit()
+
+        # TODO: this will create and use QSettings, probably should use local storage (not OS-level one)
+        app = stm32pio.gui.app.main(sys_argv=['--directory', str(PROJECT_PATH)])
+        app.loaded.connect(on_loaded)
+        app.exec_()
+
+        self.assertTrue(successful_loading_flag)
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 390bac6..ed259cf 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -1,12 +1,13 @@
 import configparser
+import gc
 import inspect
 import shutil
 
-import stm32pio.lib
-import stm32pio.settings
+# Provides test constants and definitions
+from tests.common import *
 
-# Provides test constants
-from tests.test import *
+import stm32pio.core.lib
+import stm32pio.core.settings
 
 
 class TestIntegration(CustomTestCase):
@@ -19,13 +20,13 @@ def test_rebase_project(self):
         Test the portability of projects: they should stay totally valid after moving to another path (same as renaming
         the parent part of the path). If we will not meet any exceptions, we should consider the test passed
         """
-        project_before = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project_before = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
         project_before.save_config()
 
         new_path = f'{project_before.path}-moved'
         shutil.move(str(project_before.path), new_path)
 
-        project_after = stm32pio.lib.Stm32pio(new_path, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project_after = stm32pio.core.lib.Stm32pio(new_path, parameters={'project': {'board': PROJECT_BOARD}})
         self.assertEqual(project_after.generate_code(), 0)
         self.assertEqual(project_after.pio_init(), 0)
         self.assertEqual(project_after.patch(), None)
@@ -43,36 +44,49 @@ def test_config_priorities(self):
         ''')
         cli_parameter_user_value = 'nucleo_f429zi'
 
-        # Create test config
+        # Create test config, ...
         config = configparser.ConfigParser(interpolation=None)
         config.read_dict({
             'project': {
                 'platformio_ini_patch_content': config_parameter_user_value,
-                'board': TEST_PROJECT_BOARD
+                'board': PROJECT_BOARD
             }
         })
         # ... save it
-        with FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name).open(mode='w') as config_file:
+        with STAGE_PATH.joinpath(stm32pio.core.settings.config_file_name).open(mode='w') as config_file:
             config.write(config_file)
 
         # On project creation we should interpret the CLI-provided values as superseding to the saved ones and
-        # saved ones, in turn, as superseding to the default ones
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': cli_parameter_user_value}})
-        project.pio_init()
-        project.patch()
-
-        # Actually, we can parse the platformio.ini via the configparser but this is simpler in our case
-        after_patch_content = FIXTURE_PATH.joinpath('platformio.ini').read_text()
-        self.assertIn(config_parameter_user_value, after_patch_content,
-                      msg="User config parameter has not been prioritized over the default one")
-        self.assertIn(cli_parameter_user_value, after_patch_content,
-                      msg="User CLI parameter has not been prioritized over the saved one")
+        # saved ones, in turn, as superseding to the default ones (BUT only non-empty values)
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, instance_options={'save_on_destruction': True}, parameters={
+            'app': {
+                'cubemx_cmd': ''
+            },
+            'project': {
+                'board': cli_parameter_user_value
+            }
+        })
+        # Side-effect "test": the project should be destroyed immediately and its config should be saved during this
+        del project
+        gc.collect()
+
+        # Parse the resulting stm32pio.ini via the configparser to see
+        saved_config = configparser.ConfigParser(interpolation=None)
+        saved_config.read(str(STAGE_PATH.joinpath('stm32pio.ini')))
+
+        with self.subTest(msg="User's .INI parameter has not been prioritized over the default one"):
+            self.assertEqual(config_parameter_user_value,
+                             saved_config.get('project', 'platformio_ini_patch_content'))
+        with self.subTest(msg="User's CLI parameter has not been prioritized over the .INI one"):
+            self.assertEqual(cli_parameter_user_value, saved_config.get('project', 'board'))
+        with self.subTest(msg="Empty parameter has overwrite the non-empty one"):
+            self.assertNotEqual('', saved_config.get('app', 'cubemx_cmd'))
 
     def test_build(self):
         """
         Initialize a new project and try to build it
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
         project.generate_code()
         project.pio_init()
         project.patch()
@@ -84,7 +98,7 @@ def test_regenerate_code(self):
         Simulate a new project creation, its changing and CubeMX code re-generation (for example, after adding new
         hardware features and some new files by a user)
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
 
         # Generate a new project ...
         project.generate_code()
@@ -92,9 +106,9 @@ def test_regenerate_code(self):
         project.patch()
 
         # ... change it:
-        test_file_1 = FIXTURE_PATH.joinpath('Src', 'main.c')
+        test_file_1 = STAGE_PATH.joinpath('Src', 'main.c')
         test_content_1 = "*** TEST STRING 1 ***\n"
-        test_file_2 = FIXTURE_PATH.joinpath('Inc', 'my_header.h')
+        test_file_2 = STAGE_PATH.joinpath('Inc', 'my_header.h')
         test_content_2 = "*** TEST STRING 2 ***\n"
         #   - add some sample string inside CubeMX' /* BEGIN - END */ block
         main_c_content = test_file_1.read_text()
@@ -118,20 +132,20 @@ def test_current_stage(self):
         Go through the sequence of states emulating the real-life project lifecycle
         """
 
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
 
-        for method, expected_stage in [(None, stm32pio.lib.ProjectStage.EMPTY),
-                                       ('save_config', stm32pio.lib.ProjectStage.INITIALIZED),
-                                       ('generate_code', stm32pio.lib.ProjectStage.GENERATED),
-                                       ('pio_init', stm32pio.lib.ProjectStage.PIO_INITIALIZED),
-                                       ('patch', stm32pio.lib.ProjectStage.PATCHED),
-                                       ('build', stm32pio.lib.ProjectStage.BUILT),
-                                       ('clean', stm32pio.lib.ProjectStage.EMPTY),
-                                       ('pio_init', stm32pio.lib.ProjectStage.UNDEFINED)]:
+        for method, expected_stage in [(None, stm32pio.core.lib.ProjectStage.EMPTY),
+                                       ('save_config', stm32pio.core.lib.ProjectStage.INITIALIZED),
+                                       ('generate_code', stm32pio.core.lib.ProjectStage.GENERATED),
+                                       ('pio_init', stm32pio.core.lib.ProjectStage.PIO_INITIALIZED),
+                                       ('patch', stm32pio.core.lib.ProjectStage.PATCHED),
+                                       ('build', stm32pio.core.lib.ProjectStage.BUILT),
+                                       ('clean', stm32pio.core.lib.ProjectStage.EMPTY),
+                                       ('pio_init', stm32pio.core.lib.ProjectStage.UNDEFINED)]:
             if method is not None:
                 getattr(project, method)()
             self.assertEqual(project.state.current_stage, expected_stage)
-            if expected_stage != stm32pio.lib.ProjectStage.UNDEFINED:
+            if expected_stage != stm32pio.core.lib.ProjectStage.UNDEFINED:
                 self.assertTrue(project.state.is_consistent)
             else:
                 # Should be UNDEFINED when the project is messed up (pio_init() after clean())
@@ -142,17 +156,17 @@ def test_users_files_preservation(self):
         Check that custom user's files and folders will remain untouched throughout all the steps of the project
         """
 
-        users_file = FIXTURE_PATH.joinpath('some_users_file.txt')
+        users_file = STAGE_PATH.joinpath('some_users_file.txt')
         users_file_content = "Sample content that any human can put into a text file"
         users_file.write_text(users_file_content)
-        users_dir = FIXTURE_PATH.joinpath('some_users_directory')
+        users_dir = STAGE_PATH.joinpath('some_users_directory')
         users_dir.mkdir()
 
         def check_preservation():
-            self.assertTrue(all(item in FIXTURE_PATH.iterdir() for item in [users_file, users_dir]))
+            self.assertTrue(all(item in STAGE_PATH.iterdir() for item in [users_file, users_dir]))
             self.assertIn(users_file_content, users_file.read_text())
 
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
 
         for method in ['save_config', 'generate_code', 'pio_init', 'patch', 'build']:
             getattr(project, method)()
diff --git a/tests/test_unit.py b/tests/test_unit.py
index 52dd9ad..7e9a686 100644
--- a/tests/test_unit.py
+++ b/tests/test_unit.py
@@ -5,12 +5,12 @@
 import subprocess
 import time
 
-import stm32pio.lib
-import stm32pio.settings
-import stm32pio.util
+# Provides test constants and definitions
+from tests.common import *
 
-# Provides test constants
-from tests.test import *
+import stm32pio.core.lib
+import stm32pio.core.settings
+import stm32pio.core.util
 
 
 class TestUnit(CustomTestCase):
@@ -24,14 +24,14 @@ def test_generate_code(self):
         """
         Check whether files and folders have been created (by STM32CubeMX)
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
         project.generate_code()
 
         # Assuming that the presence of these files indicating a success
         files_should_be_present = ['Src/main.c', 'Inc/main.h']
         for file in files_should_be_present:
             with self.subTest(msg=f"{file} hasn't been created"):
-                self.assertEqual(FIXTURE_PATH.joinpath(file).is_file(), True)
+                self.assertEqual(STAGE_PATH.joinpath(file).is_file(), True)
 
     def test_pio_init(self):
         """
@@ -39,14 +39,14 @@ def test_pio_init(self):
         There are other artifacts that can be checked too but we are interested only in a 'platformio.ini' anyway. Also,
         check that it is a correct configparser.ConfigParser file and is not empty
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
         result = project.pio_init()
 
         self.assertEqual(result, 0, msg="Non-zero return code")
-        self.assertTrue(FIXTURE_PATH.joinpath('platformio.ini').is_file(), msg="platformio.ini is not there")
+        self.assertTrue(STAGE_PATH.joinpath('platformio.ini').is_file(), msg="platformio.ini is not there")
 
         platformio_ini = configparser.ConfigParser(interpolation=None)
-        self.assertGreater(len(platformio_ini.read(str(FIXTURE_PATH.joinpath('platformio.ini')))), 0,
+        self.assertGreater(len(platformio_ini.read(str(STAGE_PATH.joinpath('platformio.ini')))), 0,
                            msg='platformio.ini is empty')
 
     def test_patch(self):
@@ -54,7 +54,7 @@ def test_patch(self):
         Check that the new parameters have been added, modified ones have been updated and existing parameters didn't
         gone. Also, check for unnecessary folders deletion
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH)
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH)
 
         test_content = inspect.cleandoc('''
             ; This is a test config .ini file
@@ -70,13 +70,13 @@ def test_patch(self):
             test_key1 = test_value1
             test_key2 = 123
         ''') + '\n'
-        FIXTURE_PATH.joinpath('platformio.ini').write_text(test_content)
-        FIXTURE_PATH.joinpath('include').mkdir()
+        STAGE_PATH.joinpath('platformio.ini').write_text(test_content)
+        STAGE_PATH.joinpath('include').mkdir()
 
         project.patch()
 
         with self.subTest():
-            self.assertFalse(FIXTURE_PATH.joinpath('include').is_dir(), msg="'include' has not been deleted")
+            self.assertFalse(STAGE_PATH.joinpath('include').is_dir(), msg="'include' has not been deleted")
 
         original_test_config = configparser.ConfigParser(interpolation=None)
         original_test_config.read_string(test_content)
@@ -85,7 +85,7 @@ def test_patch(self):
         patch_config = configparser.ConfigParser(interpolation=None)
         patch_config.read_string(project.config.get('project', 'platformio_ini_patch_content'))
 
-        self.assertGreater(len(patched_config.read(FIXTURE_PATH.joinpath('platformio.ini'))), 0)
+        self.assertGreater(len(patched_config.read(STAGE_PATH.joinpath('platformio.ini'))), 0)
 
         for patch_section in patch_config.sections():
             self.assertTrue(patched_config.has_section(patch_section), msg=f"{patch_section} is missing")
@@ -107,7 +107,7 @@ def test_build_should_handle_error(self):
         """
         Build an empty project so PlatformIO should return an error
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
         project.pio_init()
 
         with self.assertLogs(level='ERROR') as logs:
@@ -120,7 +120,7 @@ def test_start_editor(self):
         """
         Call the editors. Use subprocess shell=True as it works on all OSes
         """
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH)
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH)
 
         editors = {  # some edotors to check
             'atom': {
@@ -173,10 +173,10 @@ def test_init_path_not_found_should_raise(self):
         """
         path_does_not_exist_name = 'does_not_exist'
 
-        path_does_not_exist = FIXTURE_PATH.joinpath(path_does_not_exist_name)
+        path_does_not_exist = STAGE_PATH.joinpath(path_does_not_exist_name)
         with self.assertRaisesRegex(FileNotFoundError, path_does_not_exist_name,
                                     msg="FileNotFoundError has not been raised or doesn't contain a description"):
-            stm32pio.lib.Stm32pio(path_does_not_exist)
+            stm32pio.core.lib.Stm32pio(path_does_not_exist)
 
     def test_save_config(self):
         """
@@ -184,7 +184,7 @@ def test_save_config(self):
         preserved
         """
         # 'board' is non-default, 'project'-section parameter
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'project': {'board': TEST_PROJECT_BOARD}})
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH, parameters={'project': {'board': PROJECT_BOARD}})
 
         # Merge additional parameters
         retcode = project.save_config({
@@ -194,19 +194,19 @@ def test_save_config(self):
         })
 
         self.assertEqual(retcode, 0, msg="Return code of the method is non-zero")
-        self.assertTrue(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name).is_file(),
-                        msg=f"{stm32pio.settings.config_file_name} file hasn't been created")
+        self.assertTrue(STAGE_PATH.joinpath(stm32pio.core.settings.config_file_name).is_file(),
+                        msg=f"{stm32pio.core.settings.config_file_name} file hasn't been created")
 
         config = configparser.ConfigParser(interpolation=None)
-        self.assertGreater(len(config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name)))), 0,
+        self.assertGreater(len(config.read(str(STAGE_PATH.joinpath(stm32pio.core.settings.config_file_name)))), 0,
                            msg="Config is empty")
-        for section, parameters in stm32pio.settings.config_default.items():
+        for section, parameters in stm32pio.core.settings.config_default.items():
             for option, value in parameters.items():
                 with self.subTest(section=section, option=option,
                                   msg="Section/key is not found in the saved config file"):
                     self.assertNotEqual(config.get(section, option, fallback="Not found"), "Not found")
 
-        self.assertEqual(config.get('project', 'board', fallback="Not found"), TEST_PROJECT_BOARD,
+        self.assertEqual(config.get('project', 'board', fallback="Not found"), PROJECT_BOARD,
                          msg="'board' has not been set")
         self.assertEqual(config.get('project', 'additional_test_key', fallback="Not found"), 'test_value',
                          msg="Merged config is not present in the saved file")
@@ -215,7 +215,7 @@ def test_get_platformio_boards(self):
         """
         PlatformIO identifiers of boards are requested using PlatformIO CLI in JSON format
         """
-        boards = stm32pio.util.get_platformio_boards(platformio_cmd='platformio')
+        boards = stm32pio.core.util.get_platformio_boards()
 
         self.assertIsInstance(boards, collections.abc.MutableSequence)
         self.assertGreater(len(boards), 0, msg="boards list is empty")
@@ -227,11 +227,11 @@ def test_ioc_file_provided(self):
         """
 
         # Create multiple .ioc files
-        shutil.copy(FIXTURE_PATH.joinpath('stm32pio-test-project.ioc'), FIXTURE_PATH.joinpath('42.ioc'))
-        shutil.copy(FIXTURE_PATH.joinpath('stm32pio-test-project.ioc'), FIXTURE_PATH.joinpath('Abracadabra.ioc'))
+        shutil.copy(STAGE_PATH.joinpath(PROJECT_IOC_FILENAME), STAGE_PATH.joinpath('42.ioc'))
+        shutil.copy(STAGE_PATH.joinpath(PROJECT_IOC_FILENAME), STAGE_PATH.joinpath('Abracadabra.ioc'))
 
-        project = stm32pio.lib.Stm32pio(FIXTURE_PATH.joinpath('42.ioc'))  # pick just one
-        self.assertTrue(project.ioc_file.samefile(FIXTURE_PATH.joinpath('42.ioc')),
+        project = stm32pio.core.lib.Stm32pio(STAGE_PATH.joinpath('42.ioc'))  # pick just one
+        self.assertTrue(project.ioc_file.samefile(STAGE_PATH.joinpath('42.ioc')),
                         msg="Provided .ioc file hasn't been chosen")
         self.assertEqual(project.config.get('project', 'ioc_file'), '42.ioc',
                          msg="Provided .ioc file is not in the config")