diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..61a73933c80232109ec75907d0f87781edcc8b76
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,45 @@
+# This file excludes paths from the Docker build context.
+#
+# By default, Docker's build context includes all files (and folders) in the
+# current directory. Even if a file isn't copied into the container it is still sent to
+# the Docker daemon.
+#
+# There are multiple reasons to exclude files from the build context:
+#
+# 1. Prevent nested folders from being copied into the container (ex: exclude
+#    /assets/node_modules when copying /assets)
+# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
+# 3. Avoid sending files containing sensitive information
+#
+# More information on using .dockerignore is available here:
+# https://docs.docker.com/engine/reference/builder/#dockerignore-file
+
+.dockerignore
+
+# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
+#
+# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
+# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
+.git
+!.git/HEAD
+!.git/refs
+
+# Common development/test artifacts
+/cover/
+/doc/
+/test/
+/tmp/
+.elixir_ls
+
+# Mix artifacts
+/_build/
+/deps/
+*.ez
+
+# Generated on crash by the VM
+erl_crash.dump
+
+# Static artifacts - These should be fetched and built inside the Docker image
+/assets/node_modules/
+/priv/static/assets/
+/priv/static/cache_manifest.json
diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000000000000000000000000000000000000..ef8840ce6fe71ab403968d8811eb94774af61cb8
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,6 @@
+[
+  import_deps: [:ecto, :ecto_sql, :phoenix],
+  subdirectories: ["priv/*/migrations"],
+  plugins: [Phoenix.LiveView.HTMLFormatter],
+  inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
+]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..da552ade24228b3e3e29a03a734272c21cb1e67c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Temporary files, for example, from tests.
+/tmp/
+
+# Ignore package tarball (built via "mix hex.build").
+nemo_cloud_services-*.tar
+
+# Ignore assets that are produced by build tools.
+/priv/static/assets/
+
+# Ignore digested assets cache.
+/priv/static/cache_manifest.json
+
+# In case you use Node.js/npm, you want to ignore these.
+npm-debug.log
+/assets/node_modules/
+
+
+# Tomaz
+
+/downloads/
+
+# /firmware_files/
+
+.env
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fa7e62efadab1dc54a9919b9258f074e45acaebc
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,24 @@
+include:
+  - project: 'eclipsefdn/it/releng/gitlab-runner-service/gitlab-ci-templates'
+    file: 'jobs/buildkit.gitlab-ci.yml'
+  - project: 'eclipsefdn/it/releng/gitlab-runner-service/gitlab-ci-templates'
+    file: 'pipeline-autodevops.gitlab-ci.yml'
+
+stages:
+  - build
+  - test
+
+variables:
+  CI_REGISTRY_IMAGE: nemometaos/fota
+
+buildkit:
+  extends: .buildkit
+  
+# unit-test:
+#   stage: test
+#   script:
+#     - echo "Running unit tests... This will take about 10 seconds."
+#     # - docker run $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA /script/to/run/tests
+#     #- docker run $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA python -c "from src import common"
+#     # - sleep 10
+#     - echo "Tests passed succesfully!"
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..bd6aadb52f0bf8b70fcd0ed74046dc42d446a40a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,99 @@
+# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
+# instead of Alpine to avoid DNS resolution issues in production.
+#
+# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
+# https://hub.docker.com/_/ubuntu?tab=tags
+#
+# This file is based on these images:
+#
+#   - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
+#   - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240904-slim - for the release image
+#   - https://pkgs.org/ - resource for finding needed packages
+#   - Ex: hexpm/elixir:1.17.2-erlang-27.0.1-debian-bullseye-20240904-slim
+#
+ARG ELIXIR_VERSION=1.17.2
+ARG OTP_VERSION=27.0.1
+ARG DEBIAN_VERSION=bullseye-20240904-slim
+
+ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
+ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
+
+FROM ${BUILDER_IMAGE} as builder
+
+# install build dependencies
+RUN apt-get update -y && apt-get install -y build-essential git \
+    && apt-get clean && rm -f /var/lib/apt/lists/*_*
+
+# prepare build dir
+WORKDIR /app
+
+# install hex + rebar
+RUN mix local.hex --force && \
+    mix local.rebar --force
+
+# set build ENV
+ENV MIX_ENV="prod"
+ENV DATABASE_URL = "ecto://USER:PASS@HOST/DATABASE"
+ENV SECRET_KEY_BASE = zYRQiZHSoacOEgsM9w0dW4YgMQeIOrHjlM63tkAdlTlHG6lXiCrkls2TD6khDliA
+
+# install mix dependencies
+COPY mix.exs mix.lock ./
+RUN mix deps.get --only $MIX_ENV
+RUN mkdir config
+
+# copy compile-time config files before we compile dependencies
+# to ensure any relevant config change will trigger the dependencies
+# to be re-compiled.
+COPY config/config.exs config/${MIX_ENV}.exs config/
+RUN mix deps.compile
+
+COPY priv priv
+
+COPY lib lib
+
+COPY assets assets
+
+# compile assets
+RUN mix assets.deploy
+
+# Compile the release
+RUN mix compile
+
+# Changes to config/runtime.exs don't require recompiling the code
+COPY config/runtime.exs config/
+
+COPY rel rel
+RUN mix release
+
+# start a new build stage so that the final image will only contain
+# the compiled release and other runtime necessities
+FROM ${RUNNER_IMAGE}
+
+RUN apt-get update -y && \
+  apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
+  && apt-get clean && rm -f /var/lib/apt/lists/*_*
+
+# Set the locale
+RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
+
+ENV LANG en_US.UTF-8
+ENV LANGUAGE en_US:en
+ENV LC_ALL en_US.UTF-8
+
+WORKDIR "/app"
+RUN chown nobody /app
+
+# set runner ENV
+ENV MIX_ENV="prod"
+
+# Only copy the final release from the build stage
+COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/nemo_cloud_services ./
+
+USER nobody
+
+# If using an environment that doesn't automatically reap zombie processes, it is
+# advised to add an init process such as tini via `apt-get install`
+# above and adding an entrypoint. See https://github.com/krallin/tini for details
+# ENTRYPOINT ["/tini", "--"]
+
+CMD ["/app/bin/server"]
diff --git a/README.md b/README.md
index 235102da0d5b10e13a712e6739239d443864a2f0..d6263195bd25e83dcc49473a91d337f19b449176 100644
--- a/README.md
+++ b/README.md
@@ -90,4 +90,4 @@ Show your appreciation to those who have contributed to the project.
 For open source projects, say how it is licensed.
 
 ## Project status
-If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
\ No newline at end of file
diff --git a/assets/css/app.css b/assets/css/app.css
new file mode 100644
index 0000000000000000000000000000000000000000..378c8f90567dfc1424acd71cd9ca632966b0df9c
--- /dev/null
+++ b/assets/css/app.css
@@ -0,0 +1,5 @@
+@import "tailwindcss/base";
+@import "tailwindcss/components";
+@import "tailwindcss/utilities";
+
+/* This file is for your main application CSS */
diff --git a/assets/js/app.js b/assets/js/app.js
new file mode 100644
index 0000000000000000000000000000000000000000..de03b051692a011bd20c47b95e16e7ef2e9b6b44
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,55 @@
+// If you want to use Phoenix channels, run `mix help phx.gen.channel`
+// to get started and then uncomment the line below.
+// import "./user_socket.js"
+
+// You can include dependencies in two ways.
+//
+// The simplest option is to put them in assets/vendor and
+// import them using relative paths:
+//
+//     import "../vendor/some-package.js"
+//
+// Alternatively, you can `npm install some-package --prefix assets` and import
+// them using a path starting with the package name:
+//
+//     import "some-package"
+//
+
+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
+import "phoenix_html"
+// Establish Phoenix Socket and LiveView configuration.
+import {Socket} from "phoenix"
+import {LiveSocket} from "phoenix_live_view"
+import topbar from "../vendor/topbar"
+
+let Hooks = {}
+
+Hooks.FlashMessage = {
+  mounted() {
+    setTimeout(() => {
+      this.pushEvent("clear_flash", { type: "info" })
+    }, 5000)
+  }
+}
+
+let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
+let liveSocket = new LiveSocket("/live", Socket, {
+  longPollFallbackMs: 2500,
+  params: {_csrf_token: csrfToken},
+  hooks: Hooks
+})
+
+// Show progress bar on live navigation and form submits
+topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
+
+// connect if there are any LiveViews on the page
+liveSocket.connect()
+
+// expose liveSocket on window for web console debug logs and latency simulation:
+// >> liveSocket.enableDebug()
+// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session
+// >> liveSocket.disableLatencySim()
+window.liveSocket = liveSocket
+
diff --git a/assets/package-lock.json b/assets/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..e7a8f27a48fc38b5443b035e741c87b2f07ad5eb
--- /dev/null
+++ b/assets/package-lock.json
@@ -0,0 +1,196 @@
+{
+  "name": "assets",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "flowbite": "^2.5.2"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/plugin-node-resolve": {
+      "version": "15.3.0",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
+      "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
+      "dependencies": {
+        "@rollup/pluginutils": "^5.0.1",
+        "@types/resolve": "1.20.2",
+        "deepmerge": "^4.2.2",
+        "is-module": "^1.0.0",
+        "resolve": "^1.22.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^2.78.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz",
+      "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^4.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
+    },
+    "node_modules/@types/resolve": {
+      "version": "1.20.2",
+      "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+      "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
+    },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/flowbite": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz",
+      "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==",
+      "dependencies": {
+        "@popperjs/core": "^2.9.3",
+        "flowbite-datepicker": "^1.3.0",
+        "mini-svg-data-uri": "^1.4.3"
+      }
+    },
+    "node_modules/flowbite-datepicker": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz",
+      "integrity": "sha512-CLVqzuoE2vkUvWYK/lJ6GzT0be5dlTbH3uuhVwyB67+PjqJWABm2wv68xhBf5BqjpBxvTSQ3mrmLHpPJ2tvrSQ==",
+      "dependencies": {
+        "@rollup/plugin-node-resolve": "^15.2.3",
+        "flowbite": "^2.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.15.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
+      "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-module": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
+    },
+    "node_modules/mini-svg-data-uri": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
+      "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
+      "bin": {
+        "mini-svg-data-uri": "cli.js"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+      "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.8",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+      "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+      "dependencies": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    }
+  }
+}
diff --git a/assets/package.json b/assets/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..b91c03aff428fc1f023ab68f88ad07e9c7a30f10
--- /dev/null
+++ b/assets/package.json
@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "flowbite": "^2.5.2"
+  }
+}
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..ee9fe00b8581e60d6a9674782ac8af07dfe281aa
--- /dev/null
+++ b/assets/tailwind.config.js
@@ -0,0 +1,74 @@
+// See the Tailwind configuration guide for advanced usage
+// https://tailwindcss.com/docs/configuration
+
+const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
+
+module.exports = {
+  content: [
+    "./js/**/*.js",
+    "../lib/nemo_cloud_services_web.ex",
+    "../lib/nemo_cloud_services_web/**/*.*ex"
+  ],
+  theme: {
+    extend: {
+      colors: {
+        brand: "#FD4F00",
+      }
+    },
+  },
+  plugins: [
+    require("@tailwindcss/forms"),
+    // Allows prefixing tailwind classes with LiveView classes to add rules
+    // only when LiveView classes are applied, for example:
+    //
+    //     <div class="phx-click-loading:animate-ping">
+    //
+    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
+    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
+    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+    // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+    // See your `CoreComponents.icon/1` for more information.
+    //
+    plugin(function({matchComponents, theme}) {
+      let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+      let values = {}
+      let icons = [
+        ["", "/24/outline"],
+        ["-solid", "/24/solid"],
+        ["-mini", "/20/solid"],
+        ["-micro", "/16/solid"]
+      ]
+      icons.forEach(([suffix, dir]) => {
+        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+          let name = path.basename(file, ".svg") + suffix
+          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+        })
+      })
+      matchComponents({
+        "hero": ({name, fullPath}) => {
+          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+          let size = theme("spacing.6")
+          if (name.endsWith("-mini")) {
+            size = theme("spacing.5")
+          } else if (name.endsWith("-micro")) {
+            size = theme("spacing.4")
+          }
+          return {
+            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+            "-webkit-mask": `var(--hero-${name})`,
+            "mask": `var(--hero-${name})`,
+            "mask-repeat": "no-repeat",
+            "background-color": "currentColor",
+            "vertical-align": "middle",
+            "display": "inline-block",
+            "width": size,
+            "height": size
+          }
+        }
+      }, {values})
+    })
+  ]
+}
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
new file mode 100644
index 0000000000000000000000000000000000000000..41957274d71b29628e6aabe7ca9fd8750eff8a3e
--- /dev/null
+++ b/assets/vendor/topbar.js
@@ -0,0 +1,165 @@
+/**
+ * @license MIT
+ * topbar 2.0.0, 2023-02-04
+ * https://buunguyen.github.io/topbar
+ * Copyright (c) 2021 Buu Nguyen
+ */
+(function (window, document) {
+  "use strict";
+
+  // https://gist.github.com/paulirish/1579671
+  (function () {
+    var lastTime = 0;
+    var vendors = ["ms", "moz", "webkit", "o"];
+    for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
+      window.requestAnimationFrame =
+        window[vendors[x] + "RequestAnimationFrame"];
+      window.cancelAnimationFrame =
+        window[vendors[x] + "CancelAnimationFrame"] ||
+        window[vendors[x] + "CancelRequestAnimationFrame"];
+    }
+    if (!window.requestAnimationFrame)
+      window.requestAnimationFrame = function (callback, element) {
+        var currTime = new Date().getTime();
+        var timeToCall = Math.max(0, 16 - (currTime - lastTime));
+        var id = window.setTimeout(function () {
+          callback(currTime + timeToCall);
+        }, timeToCall);
+        lastTime = currTime + timeToCall;
+        return id;
+      };
+    if (!window.cancelAnimationFrame)
+      window.cancelAnimationFrame = function (id) {
+        clearTimeout(id);
+      };
+  })();
+
+  var canvas,
+    currentProgress,
+    showing,
+    progressTimerId = null,
+    fadeTimerId = null,
+    delayTimerId = null,
+    addEvent = function (elem, type, handler) {
+      if (elem.addEventListener) elem.addEventListener(type, handler, false);
+      else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
+      else elem["on" + type] = handler;
+    },
+    options = {
+      autoRun: true,
+      barThickness: 3,
+      barColors: {
+        0: "rgba(26,  188, 156, .9)",
+        ".25": "rgba(52,  152, 219, .9)",
+        ".50": "rgba(241, 196, 15,  .9)",
+        ".75": "rgba(230, 126, 34,  .9)",
+        "1.0": "rgba(211, 84,  0,   .9)",
+      },
+      shadowBlur: 10,
+      shadowColor: "rgba(0,   0,   0,   .6)",
+      className: null,
+    },
+    repaint = function () {
+      canvas.width = window.innerWidth;
+      canvas.height = options.barThickness * 5; // need space for shadow
+
+      var ctx = canvas.getContext("2d");
+      ctx.shadowBlur = options.shadowBlur;
+      ctx.shadowColor = options.shadowColor;
+
+      var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+      for (var stop in options.barColors)
+        lineGradient.addColorStop(stop, options.barColors[stop]);
+      ctx.lineWidth = options.barThickness;
+      ctx.beginPath();
+      ctx.moveTo(0, options.barThickness / 2);
+      ctx.lineTo(
+        Math.ceil(currentProgress * canvas.width),
+        options.barThickness / 2
+      );
+      ctx.strokeStyle = lineGradient;
+      ctx.stroke();
+    },
+    createCanvas = function () {
+      canvas = document.createElement("canvas");
+      var style = canvas.style;
+      style.position = "fixed";
+      style.top = style.left = style.right = style.margin = style.padding = 0;
+      style.zIndex = 100001;
+      style.display = "none";
+      if (options.className) canvas.classList.add(options.className);
+      document.body.appendChild(canvas);
+      addEvent(window, "resize", repaint);
+    },
+    topbar = {
+      config: function (opts) {
+        for (var key in opts)
+          if (options.hasOwnProperty(key)) options[key] = opts[key];
+      },
+      show: function (delay) {
+        if (showing) return;
+        if (delay) {
+          if (delayTimerId) return;
+          delayTimerId = setTimeout(() => topbar.show(), delay);
+        } else  {
+          showing = true;
+          if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+          if (!canvas) createCanvas();
+          canvas.style.opacity = 1;
+          canvas.style.display = "block";
+          topbar.progress(0);
+          if (options.autoRun) {
+            (function loop() {
+              progressTimerId = window.requestAnimationFrame(loop);
+              topbar.progress(
+                "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+              );
+            })();
+          }
+        }
+      },
+      progress: function (to) {
+        if (typeof to === "undefined") return currentProgress;
+        if (typeof to === "string") {
+          to =
+            (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
+              ? currentProgress
+              : 0) + parseFloat(to);
+        }
+        currentProgress = to > 1 ? 1 : to;
+        repaint();
+        return currentProgress;
+      },
+      hide: function () {
+        clearTimeout(delayTimerId);
+        delayTimerId = null;
+        if (!showing) return;
+        showing = false;
+        if (progressTimerId != null) {
+          window.cancelAnimationFrame(progressTimerId);
+          progressTimerId = null;
+        }
+        (function loop() {
+          if (topbar.progress("+.1") >= 1) {
+            canvas.style.opacity -= 0.05;
+            if (canvas.style.opacity <= 0.05) {
+              canvas.style.display = "none";
+              fadeTimerId = null;
+              return;
+            }
+          }
+          fadeTimerId = window.requestAnimationFrame(loop);
+        })();
+      },
+    };
+
+  if (typeof module === "object" && typeof module.exports === "object") {
+    module.exports = topbar;
+  } else if (typeof define === "function" && define.amd) {
+    define(function () {
+      return topbar;
+    });
+  } else {
+    this.topbar = topbar;
+  }
+}.call(this, window, document));
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 0000000000000000000000000000000000000000..81af14128b49254de8b7317ba62cd5895beacf87
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,99 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Config module.
+#
+# This configuration file is loaded before any dependency and
+# is restricted to this project.
+
+# General application configuration
+import Config
+
+config :nemo_cloud_services,
+  ecto_repos: [NemoCloudServices.Repo],
+  generators: [timestamp_type: :utc_datetime]
+
+# Configures the endpoint
+config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  url: [host: "localhost"],
+  adapter: Bandit.PhoenixAdapter,
+  render_errors: [
+    formats: [html: NemoCloudServicesWeb.ErrorHTML, json: NemoCloudServicesWeb.ErrorJSON],
+    layout: false
+  ],
+  pubsub_server: NemoCloudServices.PubSub,
+  live_view: [signing_salt: "xJe2l6yb"]
+
+# Configures the mailer
+#
+# By default it uses the "Local" adapter which stores the emails
+# locally. You can see the emails in your browser, at "/dev/mailbox".
+#
+# For production it's recommended to configure a different adapter
+# at the `config/runtime.exs`.
+config :nemo_cloud_services, NemoCloudServices.Mailer, adapter: Swoosh.Adapters.Local
+
+# Configure esbuild (the version is required)
+config :esbuild,
+  version: "0.17.11",
+  nemo_cloud_services: [
+    args:
+      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
+    cd: Path.expand("../assets", __DIR__),
+    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+  ]
+
+# Configure tailwind (the version is required)
+config :tailwind,
+  version: "3.4.3",
+  nemo_cloud_services: [
+    args: ~w(
+      --config=tailwind.config.js
+      --input=css/app.css
+      --output=../priv/static/assets/app.css
+    ),
+    cd: Path.expand("../assets", __DIR__)
+  ]
+
+# Configures Elixir's Logger
+config :logger, :console,
+  format: "$time $metadata[$level] $message\n",
+  metadata: [:request_id]
+
+# config :logger,
+#   backends: [{LoggerFileBackend, :info},
+#              {LoggerFileBackend, :error}]
+
+# config :logger, :info,
+#   path: "debug.log",
+#   level: :info
+
+# config :logger, :error,
+#   path: "error.log",
+#   level: :error
+
+# Use Jason for JSON parsing in Phoenix
+config :phoenix, :json_library, Jason
+
+# TOMAZ
+
+config :nemo_cloud_services,
+       :firmware_dir,
+       "firmware_files"
+
+# CHECK THIS FOR mTLS -> https://www.emqx.com/en/blog/enable-two-way-ssl-for-emqx
+config :nemo_cloud_services, :emqtt,
+  host: "192.168.6.115",
+  port: 1883,
+  clientid: System.get_env("MQTT_CLIENTID", "nemo_cloud_services"),
+  # clientid: :dynamic, # TA DYNAMIC DELA TEZAVE, iodata itd. mora biti kao string
+  clean_start: false,
+  name: :emqtt
+
+config :nemo_cloud_services, :interval, 10_000
+
+config :mime, :types, %{
+  "application/vnd.api+json" => ["hex", "fw"]
+}
+
+# Import environment specific config. This must remain at the bottom
+# of this file so it overrides the configuration defined above.
+import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
new file mode 100644
index 0000000000000000000000000000000000000000..6796e601aef5d85568cb7f34ef8ec938781259bf
--- /dev/null
+++ b/config/dev.exs
@@ -0,0 +1,94 @@
+import Config
+
+# Configure your database
+config :nemo_cloud_services, NemoCloudServices.Repo,
+  username: "tomaz",
+  password: "zelosecuregeslo",
+  hostname: "192.168.1.15",
+  database: "nemo_cloud_services_dev",
+  stacktrace: true,
+  show_sensitive_data_on_connection_error: true,
+  pool_size: 10
+
+# For development, we disable any cache and enable
+# debugging and code reloading.
+#
+# The watchers configuration can be used to run external
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
+config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  # Binding to loopback ipv4 address prevents access from other machines.
+  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
+  # http: [ip: {127, 0, 0, 1}, port: 4000],
+  http: [ip: {0, 0, 0, 0}, port: 4000],
+  check_origin: false,
+  code_reloader: true,
+  debug_errors: true,
+  secret_key_base: "xKl0QJpZ77TFrTHFXBUZMAuKlRs/k27gUgggFULudM0UGjjkNueVvpAMI6mWr+hz",
+  watchers: [
+    esbuild: {Esbuild, :install_and_run, [:nemo_cloud_services, ~w(--sourcemap=inline --watch)]},
+    tailwind: {Tailwind, :install_and_run, [:nemo_cloud_services, ~w(--watch)]}
+  ]
+
+# ## SSL Support
+#
+# In order to use HTTPS in development, a self-signed
+# certificate can be generated by running the following
+# Mix task:
+#
+#     mix phx.gen.cert
+#
+# Run `mix help phx.gen.cert` for more information.
+#
+# The `http:` config above can be replaced with:
+#
+#     https: [
+#       port: 4001,
+#       cipher_suite: :strong,
+#       keyfile: "priv/cert/selfsigned_key.pem",
+#       certfile: "priv/cert/selfsigned.pem"
+#     ],
+#
+# If desired, both `http:` and `https:` keys can be
+# configured to run both http and https servers on
+# different ports.
+
+# Watch static and templates for browser reloading.
+config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  live_reload: [
+    patterns: [
+      ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
+      ~r"priv/gettext/.*(po)$",
+      ~r"lib/nemo_cloud_services_web/(controllers|live|components)/.*(ex|heex)$"
+    ]
+  ]
+
+# Enable dev routes for dashboard and mailbox
+config :nemo_cloud_services, dev_routes: true
+
+# Do not include metadata nor timestamps in development logs
+config :logger, :console, format: "[$level] $message\n"
+
+# Set a higher stacktrace during development. Avoid configuring such
+# in production as building large stacktraces may be expensive.
+config :phoenix, :stacktrace_depth, 20
+
+# Initialize plugs at runtime for faster development compilation
+config :phoenix, :plug_init_mode, :runtime
+
+config :phoenix_live_view,
+  # Include HEEx debug annotations as HTML comments in rendered markup
+  debug_heex_annotations: true,
+  # Enable helpful, but potentially expensive runtime checks
+  enable_expensive_runtime_checks: true
+
+# Disable swoosh api client as it is only required for production adapters.
+config :swoosh, :api_client, false
+
+config :nemo_cloud_services, :keycloak,
+  url: "http://192.168.6.123:8080",
+  realm: "dedalus",
+  client_id: "dedalus-comsems-core",
+  client_secret: "wd2MXBhMsQehHsPSrt3uCJd4B9XD9ZaJ"
+
+config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache
diff --git a/config/prod.exs b/config/prod.exs
new file mode 100644
index 0000000000000000000000000000000000000000..18134a3935d9e32ad6e698bbf2ba123c7334c5c0
--- /dev/null
+++ b/config/prod.exs
@@ -0,0 +1,21 @@
+import Config
+
+# Note we also include the path to a cache manifest
+# containing the digested version of static files. This
+# manifest is generated by the `mix assets.deploy` task,
+# which you should run after static files are built and
+# before starting your production server.
+config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  cache_static_manifest: "priv/static/cache_manifest.json"
+
+# Configures Swoosh API Client
+config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: NemoCloudServices.Finch
+
+# Disable Swoosh Local Memory Storage
+config :swoosh, local: false
+
+# Do not print debug messages in production
+config :logger, level: :info
+
+# Runtime production configuration, including reading
+# of environment variables, is done on config/runtime.exs.
diff --git a/config/runtime.exs b/config/runtime.exs
new file mode 100644
index 0000000000000000000000000000000000000000..ff92333860510f4b83914c4b731d3842c4935e96
--- /dev/null
+++ b/config/runtime.exs
@@ -0,0 +1,117 @@
+import Config
+
+# config/runtime.exs is executed for all environments, including
+# during releases. It is executed after compilation and before the
+# system starts, so it is typically used to load production configuration
+# and secrets from environment variables or elsewhere. Do not define
+# any compile-time configuration in here, as it won't be applied.
+# The block below contains prod specific runtime configuration.
+
+# ## Using releases
+#
+# If you use `mix release`, you need to explicitly enable the server
+# by passing the PHX_SERVER=true when you start it:
+#
+#     PHX_SERVER=true bin/nemo_cloud_services start
+#
+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
+# script that automatically sets the env var above.
+if System.get_env("PHX_SERVER") do
+  config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint, server: true
+end
+
+if config_env() == :prod do
+  database_url =
+    System.get_env("DATABASE_URL") ||
+      raise """
+      environment variable DATABASE_URL is missing.
+      For example: ecto://USER:PASS@HOST/DATABASE
+      """
+
+  maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
+
+  config :nemo_cloud_services, NemoCloudServices.Repo,
+    # ssl: true,
+    url: database_url,
+    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
+    socket_options: maybe_ipv6
+
+  # The secret key base is used to sign/encrypt cookies and other secrets.
+  # A default value is used in config/dev.exs and config/test.exs but you
+  # want to use a different value for prod and you most likely don't want
+  # to check this value into version control, so we use an environment
+  # variable instead.
+  secret_key_base =
+    System.get_env("SECRET_KEY_BASE") ||
+      raise """
+      environment variable SECRET_KEY_BASE is missing.
+      You can generate one by calling: mix phx.gen.secret
+      """
+
+  host = System.get_env("PHX_HOST") || "example.com"
+  port = String.to_integer(System.get_env("PORT") || "4000")
+
+  config :nemo_cloud_services, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+  config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+    url: [host: host, port: 443, scheme: "https"],
+    http: [
+      # Enable IPv6 and bind on all interfaces.
+      # Set it to  {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
+      # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
+      # for details about using IPv6 vs IPv4 and loopback vs public addresses.
+      ip: {0, 0, 0, 0, 0, 0, 0, 0},
+      port: port
+    ],
+    secret_key_base: secret_key_base
+
+  # ## SSL Support
+  #
+  # To get SSL working, you will need to add the `https` key
+  # to your endpoint configuration:
+  #
+  #     config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  #       https: [
+  #         ...,
+  #         port: 443,
+  #         cipher_suite: :strong,
+  #         keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
+  #         certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
+  #       ]
+  #
+  # The `cipher_suite` is set to `:strong` to support only the
+  # latest and more secure SSL ciphers. This means old browsers
+  # and clients may not be supported. You can set it to
+  # `:compatible` for wider support.
+  #
+  # `:keyfile` and `:certfile` expect an absolute path to the key
+  # and cert in disk or a relative path inside priv, for example
+  # "priv/ssl/server.key". For all supported SSL configuration
+  # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
+  #
+  # We also recommend setting `force_ssl` in your config/prod.exs,
+  # ensuring no data is ever sent via http, always redirecting to https:
+  #
+  #     config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  #       force_ssl: [hsts: true]
+  #
+  # Check `Plug.SSL` for all available options in `force_ssl`.
+
+  # ## Configuring the mailer
+  #
+  # In production you need to configure the mailer to use a different adapter.
+  # Also, you may need to configure the Swoosh API client of your choice if you
+  # are not using SMTP. Here is an example of the configuration:
+  #
+  #     config :nemo_cloud_services, NemoCloudServices.Mailer,
+  #       adapter: Swoosh.Adapters.Mailgun,
+  #       api_key: System.get_env("MAILGUN_API_KEY"),
+  #       domain: System.get_env("MAILGUN_DOMAIN")
+  #
+  # For this example you need include a HTTP client required by Swoosh API client.
+  # Swoosh supports Hackney and Finch out of the box:
+  #
+  #     config :swoosh, :api_client, Swoosh.ApiClient.Hackney
+  #
+  # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
+end
diff --git a/config/test.exs b/config/test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..a07da0e487699fdce0436cbb7d8b9199aaf122c8
--- /dev/null
+++ b/config/test.exs
@@ -0,0 +1,37 @@
+import Config
+
+# Configure your database
+#
+# The MIX_TEST_PARTITION environment variable can be used
+# to provide built-in test partitioning in CI environment.
+# Run `mix help test` for more information.
+config :nemo_cloud_services, NemoCloudServices.Repo,
+  username: "postgres",
+  password: "postgres",
+  hostname: "localhost",
+  database: "nemo_cloud_services_test#{System.get_env("MIX_TEST_PARTITION")}",
+  pool: Ecto.Adapters.SQL.Sandbox,
+  pool_size: System.schedulers_online() * 2
+
+# We don't run a server during test. If one is required,
+# you can enable the server option below.
+config :nemo_cloud_services, NemoCloudServicesWeb.Endpoint,
+  http: [ip: {127, 0, 0, 1}, port: 4002],
+  secret_key_base: "fKxZWDEMSywwOtK1PPklOcTC9VYX3xCEkRQp3vmUnaCVyuuxI+CMVKzMNYWkJn4n",
+  server: false
+
+# In test we don't send emails
+config :nemo_cloud_services, NemoCloudServices.Mailer, adapter: Swoosh.Adapters.Test
+
+# Disable swoosh api client as it is only required for production adapters
+config :swoosh, :api_client, false
+
+# Print only warnings and errors during test
+config :logger, level: :warning
+
+# Initialize plugs at runtime for faster test compilation
+config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+  enable_expensive_runtime_checks: true
diff --git a/data_flows.md b/data_flows.md
new file mode 100644
index 0000000000000000000000000000000000000000..7373cdd93c928a60eaa2e76295f138b700bcd9a9
--- /dev/null
+++ b/data_flows.md
@@ -0,0 +1,101 @@
+## TODO
+
+- all sceanrios for when GW start (electicity ON/OFF, LTE ON/OFF, LAN ON/OFF?? )
+- Check all this scenarios before deployment of GWs
+- Check supervision tree configuration
+- Check access to GW over ssh/scp/mqtt/ws? 
+- Put in place mTLS for security and use that with all tests required
+
+
+
+
+## 0.) Connectivity
+
+- PMU : GW => TCP LAN
+- GW : Cloud => LTE !!!! a lot of data !!! :)
+
+
+## 1.) After reboot
+
+```mermaid
+sequenceDiagram
+    participant PMU
+    participant GW
+    participant MQTT BROKER
+    participant CLOUD SERVICE
+    PMU <<->> GW: TCP connection over LAN
+    Note right of CLOUD SERVICE: Hosted on NEMO cloud / K8s
+    Note over PMU, GW: c37xy protocol
+    GW->>MQTT BROKER:Connect to broker / mTLS
+    activate MQTT BROKER
+    MQTT BROKER --> GW: Persistent connection established
+    deactivate MQTT BROKER
+    CLOUD SERVICE->>MQTT BROKER: Connect to broker / mTLS 
+    activate MQTT BROKER
+    MQTT BROKER --> CLOUD SERVICE: Persistent connection established
+    
+    Note over GW, MQTT BROKER: Connect to data, admin, admin ack topics
+    Note over CLOUD SERVICE, MQTT BROKER: Connect to data, admin, admin ack topics 
+
+
+```
+
+
+## 2.) Firmware change flow
+
+- Developer of FW builds a new version of firmware and uses a UI to upload it to defined location
+- In UI you can see this new Firmware under list of available firmwares
+- You can choose a device and select a Firmare that this device should have (Perhaps already at it, or downgrade? or upgrade)
+- When new Firmare (FW) is picked a message to MQTT broker topic `admin` should be send with predefined key and firmware id/name should be send. Take into the consideration that not all devices need this message (topic names) [{"firmware_change": {"gw": ["gw1", "gw2", ...], "target_firmware": "new_firmware_v1.2.fw"}}]
+
+!!!! WARNING !!!! this logic has changed. device will only get itself as gateway. it will just double check if it is really for it. Server side is responsible to send correct messages to the right devices. So each device (from the list that came from UI) will get its own message to its own topic
+[{"firmware_change": {"gw": device_id, "target_firmware": "new_firmware_v1.2.fw"}}]
+
+#
+
+- On a Gateway (GW) side functionality should be in place to pattern match this specific message and act accordingly. In this case, a part of code responsible for FW download should get this specific .fw file from predefined location and download it to GW. 
+- Then inplace upgrade should be done
+- !! double check logic for writing and having information about current fw. Perhaps in some config file? This needs to be available for requests from cloud side (another message to admin topic / report this at GW startup) [{"firmware_info": {"version": "current_firmware_v1.2", ...}}]
+- WRITE FW VERSION INTO THE FIRMWARE ITSELF so that it is available when you boot into the new partition so you can report back to /admin/ack
+- GW will reboot after successful migration and when fully operational again it should send `ack` to Cloud so that GW status is updated accordingly in DB. [{"firmware_change_ack": {"status": "successful upgrade", "current_version": "current_firmware_v1.2", ... }}]
+
+
+
+
+## 3.) Show measurement data - last 100 records
+
+- GW will collect data from PMU at 50Hz (baje) and this data should be send further to cloud over MQTT
+- `If needed` data can be pushed further to other destinations. [{"device_id": "nek GW UUID", "timestamp": 12143434356, "data": {payload}}]
+
+
+## 4.) Get detailed measurement data on request from Cloud
+
+- When event is triggered data with +/- delta TIME should be available to push/fetch from a GW to Cloud [{"data_request": {"start_time": 23232323232, "end_time": 434343434343}}]
+- Brainstorm about using /data partition on GW with SCP or MQTT or WS? 
+- Check if request needs to go to both GWs!?!?
+
+
+
+
+## 5.) Event detection and notification of Cloud side
+
+- Implement (Denis) handler for observing and detecting data treshold violations. 
+- If detected -> notify cloud service. This is a requirement for detailed measurement data request (see above 4) [{"check_data_event": {"timestamp": 121213424, "event_at": ["V", ... ]}}]
+
+
+
+
+
+#
+#
+#
+#
+## TESTING
+#
+#
+#
+#
+
+ NemoCloudServices.Conn.MqttClient.send_firmware_change(["nemo_gw_host", "nemo_gw_1"], "zero2wledota-500ms-v0.9.0.fw")
+
+ NemoCloudServices.Conn.MqttClient.send_firmware_change(["nemo_gw_host", "nemo_gw_1"], "zero2wledota-2000ms-v0.8.0.fw")
diff --git a/debug.log b/debug.log
new file mode 100644
index 0000000000000000000000000000000000000000..cbe190af4233165901f9f8382fbaa8e5a0b4546f
--- /dev/null
+++ b/debug.log
@@ -0,0 +1,133 @@
+13:01:27.320 [info] Sent firmware change message to gateway nemo_gw_1 on topic nemo/pilot/nemo_gw_1/admin
+13:01:27.324 [info] Received message on topic: nemo/pilot/nemo_gw_1/admin
+13:01:27.324 [info] Payload: "{\"firmware_change\":{\"gw\":[\"nemo_gw_1\"],\"target_firmware\":\"zero2wledota-500ms-v0.1.0.fw\"}}"
+13:01:27.324 [info] Handling admin message from client nemo_gw_1
+13:02:16.529 [info] GET /firmwares
+13:02:16.607 [info] Sent 200 in 78ms
+13:02:16.629 [info] CONNECTED TO Phoenix.LiveView.Socket in 47µs
+  Transport: :longpoll
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "CiBXMQAdHCY-ImElFwMFGxZlARtBKD0eGh3dwIWbOt1lcwcYbTXlvIx3", "_live_referer" => "undefined", "_mounts" => "1", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:02:17.199 [info] CONNECTED TO Phoenix.LiveView.Socket in 43µs
+  Transport: :websocket
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "AD0wHQYXCBM_JgUbQg4HbzZELRFFGRBnMuTHqCCWNpUR6za-ButfrxUJ", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:03:59.294 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+13:03:59.294 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+13:03:59.656 [info] GET /firmwares
+13:03:59.657 [info] Sent 200 in 1ms
+13:04:00.175 [info] CONNECTED TO Phoenix.LiveView.Socket in 19µs
+  Transport: :websocket
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "eTIdAkQyCA8IICB8RQcHDRt9ChBtMxIf4zyW3fCKyvp51saOoLSgZRW2", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:05:49.993 [error] ** (Bandit.HTTPError) Header read timeout
+13:05:50.110 [error] ** (Bandit.HTTPError) Header read timeout
+13:08:04.131 [info] Sent firmware change message to gateway nemo_gw_2 on topic nemo/pilot/nemo_gw_2/admin
+13:08:04.134 [info] Received message on topic: nemo/pilot/nemo_gw_2/admin
+13:08:04.134 [info] Payload: "{\"firmware_change\":{\"gw\":[\"nemo_gw_2\"],\"target_firmware\":\"zero2wledota-500ms-v0.1.0.fw\"}}"
+13:08:04.134 [info] Handling admin message from client nemo_gw_2
+13:09:23.447 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620354.225357>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:09:23.459 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620355.222122>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:09:23.461 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620355.222140>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:09:23.463 [info] Shutting down 1 sockets in 1 rounds of 2000ms
+13:09:23.471 [info] Shutting down 1 sockets in 1 rounds of 2000ms
+13:09:23.484 [info] Application nemo_cloud_services exited: shutdown
+13:09:59.511 [info] Running NemoCloudServicesWeb.Endpoint with Bandit 1.5.7 at 127.0.0.1:4000 (http)
+13:09:59.522 [info] Access NemoCloudServicesWeb.Endpoint at http://localhost:4000
+13:09:59.668 [info] Subscribed to topics successfully
+13:10:00.354 [info] CONNECTED TO Phoenix.LiveView.Socket in 43µs
+  Transport: :longpoll
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "eTIdAkQyCA8IICB8RQcHDRt9ChBtMxIf4zyW3fCKyvp51saOoLSgZRW2", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:10:17.150 [info] GET /firmwares
+13:10:17.205 [info] Sent 200 in 54ms
+13:10:17.676 [info] CONNECTED TO Phoenix.LiveView.Socket in 2ms
+  Transport: :websocket
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "Pjg-MT8nfgYDEiUbNUEMcwVcFDt6FyR0spZdHs5BrDuRA5j1qmMLMvaY", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:10:29.747 [info] GET /firmwares
+13:10:29.751 [info] Sent 200 in 3ms
+13:10:29.758 [info] GET /firmwares
+13:10:29.759 [info] Sent 200 in 1ms
+13:10:30.151 [info] CONNECTED TO Phoenix.LiveView.Socket in 20µs
+  Transport: :websocket
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "AzgRDRYBGzwnEwANAB0zGz5gGy0OFT1XNpuXaUPxVEPDtiUYJQBZ9txz", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:11:03.083 [info] GET /firmwares
+13:11:03.087 [info] Sent 200 in 4ms
+13:11:03.092 [info] GET /firmwares
+13:11:03.094 [info] Sent 200 in 2ms
+13:11:03.519 [info] CONNECTED TO Phoenix.LiveView.Socket in 47µs
+  Transport: :websocket
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "Gz0RJRI6fTUQCTd6EDALBCJkEhRuNyF4Vuupen6qa_g3dDmFVUKcYVdU", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:11:10.295 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036163.112031>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.307 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036162.108820>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.309 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036161.102981>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.311 [info] Shutting down 1 sockets in 1 rounds of 2000ms
+13:11:10.318 [info] Shutting down 1 sockets in 1 rounds of 2000ms
+13:11:08.210 [info] CONNECTED TO Phoenix.LiveView.Socket in 39µs
+  Transport: :longpoll
+  Serializer: Phoenix.Socket.V2.JSONSerializer
+  Parameters: %{"_csrf_token" => "Gz0RJRI6fTUQCTd6EDALBCJkEhRuNyF4Vuupen6qa_g3dDmFVUKcYVdU", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
+13:11:08.222 [info] Application nemo_cloud_services exited: shutdown
diff --git a/error.log b/error.log
new file mode 100644
index 0000000000000000000000000000000000000000..f3797d2e1e42c8773a2f9c2a36dc5e3a2ed4aea1
--- /dev/null
+++ b/error.log
@@ -0,0 +1,308 @@
+11:02:40.250 [error] ** (Bandit.HTTPError) Header read timeout
+11:11:22.990 [error] GenServer #PID<0.812.0> terminating
+** (KeyError) key :log_lines not found in: %{
+  socket: #Phoenix.LiveView.Socket<
+    id: "phx-GAMzTAF0RP8C-AAH",
+    endpoint: NemoCloudServicesWeb.Endpoint,
+    view: NemoCloudServicesWeb.OTAUpdateLive,
+    parent_pid: nil,
+    root_pid: #PID<0.812.0>,
+    router: NemoCloudServicesWeb.Router,
+    assigns: #Phoenix.LiveView.Socket.AssignsNotInSocket<>,
+    transport_pid: #PID<0.797.0>,
+    ...
+  >,
+  __changed__: %{current_view: true},
+  flash: %{},
+  devices: [
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_description" => "",
+      :version => "v2.1.574-rev",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      :online => true,
+      "b.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_product" => "zero2wledota",
+      "nerves_fw_active" => "a",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "b.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      :id => "nemo_gw_1",
+      "a.nerves_fw_version" => "0.1.0",
+      :name => "NEMO GW 1",
+      "b.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_vcs_identifier" => "",
+      :type => "GW",
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3"
+    },
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_description" => "",
+      :version => "v1.1",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      :online => true,
+      "b.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_product" => "zero2wledota",
+      "nerves_fw_active" => "a",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "b.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      :id => "nemo_gw_2",
+      "a.nerves_fw_version" => "0.1.0",
+      :name => "NEMO GW 2",
+      "b.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_vcs_identifier" => "",
+      :type => "GW",
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3"
+    },
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_description" => "",
+      :version => "v1.1",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      :online => false,
+      "b.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_product" => "zero2wledota",
+      "nerves_fw_active" => "a",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "b.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      :id => "3",
+      "a.nerves_fw_version" => "0.1.0",
+      :name => "Sensor 43QTR",
+      "b.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_vcs_identifier" => "",
+      :type => "MISC",
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3"
+    },
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_description" => "",
+      :version => "v1.1",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      :online => true,
+      "b.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_product" => "zero2wledota",
+      "nerves_fw_active" => "a",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "b.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      :id => "4",
+      "a.nerves_fw_version" => "0.1.0",
+      :name => "Sensor 43QTR",
+      "b.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_vcs_identifier" => "",
+      :type => "MISC",
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3"
+    },
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_description" => "",
+      :version => "v1.1",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      :online => false,
+      "b.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_product" => "zero2wledota",
+      "nerves_fw_active" => "a",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "b.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      :id => "5",
+      "a.nerves_fw_version" => "0.1.0",
+      :name => "Sensor 43QTR",
+      "b.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_vcs_identifier" => "",
+      :type => "MISC",
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3"
+    },
+    %{
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_misc" => "",
+      "nerves_serial_number" => "",
+      :file_size => 118.4,
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "a.nerve (truncated)
+11:13:58.646 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+11:13:58.646 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+11:23:34.243 [error] ** (Bandit.HTTPError) Header read timeout
+12:42:08.277 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.787.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.787.0>, timer: #Reference<0.2505205769.1750335492.202890>, interval: 10000, data_topic: "nemo/pilot/+/data", admin_topic: "nemo/pilot/+/admin", admin_ack_topic: "nemo/pilot/+/admin/ack"}
+12:42:08.330 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.787.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.787.0>, timer: #Reference<0.2505205769.1750335491.188141>, interval: 10000, data_topic: "nemo/pilot/+/data", admin_topic: "nemo/pilot/+/admin", admin_ack_topic: "nemo/pilot/+/admin/ack"}
+12:42:08.331 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.787.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.787.0>, timer: #Reference<0.2505205769.1750335491.188158>, interval: 10000, data_topic: "nemo/pilot/+/data", admin_topic: "nemo/pilot/+/admin", admin_ack_topic: "nemo/pilot/+/admin/ack"}
+13:03:59.294 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+13:03:59.294 [error] ** (Bandit.HTTPError) Header read socket error: :closed
+13:05:49.993 [error] ** (Bandit.HTTPError) Header read timeout
+13:05:50.110 [error] ** (Bandit.HTTPError) Header read timeout
+13:09:23.447 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620354.225357>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:09:23.459 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620355.222122>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:09:23.461 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.676.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.676.0>, timer: #Reference<0.273484736.1227620355.222140>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.295 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036163.112031>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.307 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036162.108820>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
+13:11:10.309 [error] GenServer NemoCloudServices.Conn.MqttClient terminating
+** (stop) exited in: :gen_statem.call(#PID<0.689.0>, {:connect, :emqtt_sock}, :infinity)
+    ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
+    (stdlib 6.0.1) gen.erl:247: :gen.do_call/4
+    (stdlib 6.0.1) gen_statem.erl:2633: :gen_statem.call/3
+    (nemo_cloud_services 0.1.0) lib/nemo_cloud_services/conn/mqtt_client.ex:74: NemoCloudServices.Conn.MqttClient.handle_continue/2
+    (stdlib 6.0.1) gen_server.erl:2163: :gen_server.try_handle_continue/3
+    (stdlib 6.0.1) gen_server.erl:2072: :gen_server.loop/7
+    (stdlib 6.0.1) proc_lib.erl:329: :proc_lib.init_p_do_apply/3
+Last message: {:continue, :start_emqtt}
+State: %{pid: #PID<0.689.0>, timer: #Reference<0.2060872916.3379036161.102981>, interval: 10000, admin_ack_topic: "nemo/pilot/+/admin/ack", admin_topic: "nemo/pilot/+/admin", data_topic: "nemo/pilot/+/data"}
diff --git a/firmware_files/zero2wledota-2000ms-v0.12.0.fw b/firmware_files/zero2wledota-2000ms-v0.12.0.fw
new file mode 100644
index 0000000000000000000000000000000000000000..5cf5e2950fa0b8793ba552b6925a58dff51df568
Binary files /dev/null and b/firmware_files/zero2wledota-2000ms-v0.12.0.fw differ
diff --git a/firmware_files/zero2wledota-2000ms-v0.16.0.fw b/firmware_files/zero2wledota-2000ms-v0.16.0.fw
new file mode 100644
index 0000000000000000000000000000000000000000..4c119ed2dd5910b23aa98592bb1a7a7c70e30039
Binary files /dev/null and b/firmware_files/zero2wledota-2000ms-v0.16.0.fw differ
diff --git a/firmware_files/zero2wledota-2000ms-v0.16.0.txt b/firmware_files/zero2wledota-2000ms-v0.16.0.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e83bcc9c8d330ef43d637536d16074b470cfc6be
--- /dev/null
+++ b/firmware_files/zero2wledota-2000ms-v0.16.0.txt
@@ -0,0 +1 @@
+Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
\ No newline at end of file
diff --git a/firmware_files/zero2wledota-2000ms-v0.2.0.fw b/firmware_files/zero2wledota-2000ms-v0.2.0.fw
new file mode 100644
index 0000000000000000000000000000000000000000..cf3a4ba8b136ed3a0fef97952779ecf0b3d0121d
Binary files /dev/null and b/firmware_files/zero2wledota-2000ms-v0.2.0.fw differ
diff --git a/firmware_files/zero2wledota-500ms-v0.15.0.fw b/firmware_files/zero2wledota-500ms-v0.15.0.fw
new file mode 100644
index 0000000000000000000000000000000000000000..8fb29c26812fae2b39255bd6c1a370c687a9e27b
Binary files /dev/null and b/firmware_files/zero2wledota-500ms-v0.15.0.fw differ
diff --git a/firmware_files/zero2wledota-500ms-v0.16.0.fw b/firmware_files/zero2wledota-500ms-v0.16.0.fw
new file mode 100644
index 0000000000000000000000000000000000000000..9c948f7ffa48ddba2ed5369060ba6f3eb88a7fa8
Binary files /dev/null and b/firmware_files/zero2wledota-500ms-v0.16.0.fw differ
diff --git a/firmware_files/zero2wledota-500ms-v0.16.0.txt b/firmware_files/zero2wledota-500ms-v0.16.0.txt
new file mode 100644
index 0000000000000000000000000000000000000000..88845d9a60c16e2b46813fd815c64eaa101a0294
--- /dev/null
+++ b/firmware_files/zero2wledota-500ms-v0.16.0.txt
@@ -0,0 +1 @@
+TESTING FIRMWARE INFO MODULE
\ No newline at end of file
diff --git a/lib/nemo_cloud_services.ex b/lib/nemo_cloud_services.ex
new file mode 100644
index 0000000000000000000000000000000000000000..c64a5a8b37233eaa856854218cdf4fdef650c966
--- /dev/null
+++ b/lib/nemo_cloud_services.ex
@@ -0,0 +1,9 @@
+defmodule NemoCloudServices do
+  @moduledoc """
+  NemoCloudServices keeps the contexts that define your domain
+  and business logic.
+
+  Contexts are also responsible for managing your data, regardless
+  if it comes from the database, an external API or others.
+  """
+end
diff --git a/lib/nemo_cloud_services/application.ex b/lib/nemo_cloud_services/application.ex
new file mode 100644
index 0000000000000000000000000000000000000000..5fec335d471bad1254db8b362af0d1689fda6ced
--- /dev/null
+++ b/lib/nemo_cloud_services/application.ex
@@ -0,0 +1,46 @@
+defmodule NemoCloudServices.Application do
+  # See https://hexdocs.pm/elixir/Application.html
+  # for more information on OTP Applications
+  @moduledoc false
+
+  use Application
+  require Logger
+
+  # @clenitid "nemo_cloud_services_#{:rand.uniform(1_000_000)}"
+
+  @impl true
+  def start(_type, _args) do
+    # Initialize the ETS table before starting children
+    :ets.new(:active_devices, [:set, :public, :named_table])
+    Logger.info("Started ETS table :active_devices")
+
+    children = [
+      NemoCloudServicesWeb.Telemetry,
+      #NemoCloudServices.Repo,
+      {DNSCluster,
+       query: Application.get_env(:nemo_cloud_services, :dns_cluster_query) || :ignore},
+      {Phoenix.PubSub, name: NemoCloudServices.PubSub},
+      # Start the Finch HTTP client for sending emails
+      {Finch, name: NemoCloudServices.Finch},
+      # Start a worker by calling: NemoCloudServices.Worker.start_link(arg)
+      # {NemoCloudServices.Worker, arg},
+      # Start to serve requests, typically the last entry
+      NemoCloudServicesWeb.Endpoint,
+      #NemoCloudServices.Conn.MqttSupervisor, remove for digital twin demo
+      NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwinSupervisor
+    ]
+
+    # See https://hexdocs.pm/elixir/Supervisor.html
+    # for other strategies and supported options
+    opts = [strategy: :one_for_one, name: NemoCloudServices.Supervisor]
+    Supervisor.start_link(children, opts)
+  end
+
+  # Tell Phoenix to update the endpoint configuration
+  # whenever the application is updated.
+  @impl true
+  def config_change(changed, _new, removed) do
+    NemoCloudServicesWeb.Endpoint.config_change(changed, removed)
+    :ok
+  end
+end
diff --git a/lib/nemo_cloud_services/conn/mqtt_client.ex b/lib/nemo_cloud_services/conn/mqtt_client.ex
new file mode 100644
index 0000000000000000000000000000000000000000..e3284546c3ec5d7c705bbfb31501dab939bf4298
--- /dev/null
+++ b/lib/nemo_cloud_services/conn/mqtt_client.ex
@@ -0,0 +1,562 @@
+defmodule NemoCloudServices.Conn.MqttClient do
+  use GenServer
+  require Logger
+  alias NemoCloudServices.Repo
+  alias NemoCloudServices.Devices
+
+  @persistent_term_key :nemo_mqtt_client_state
+
+  # Public API
+
+  def start_link([]) do
+    GenServer.start_link(__MODULE__, [], name: __MODULE__)
+  end
+
+  @doc """
+  Returns the PID of the MQTT client process.
+  """
+  def get_pid() do
+    GenServer.call(__MODULE__, :get_pid)
+  end
+
+  @doc """
+  Returns the entire state of the GenServer. Useful for debugging.
+  """
+  def get_state() do
+    GenServer.call(__MODULE__, :get_state)
+  end
+
+  # Public function to list active devices with PING / PONG
+  def send_ping_for_active_devices() do
+    GenServer.cast(__MODULE__, :send_ping_for_active_devices)
+  end
+
+  # Gets active devices from ETS table / USE THIS IN UI
+  # def get_active_devices() do
+  #   :ets.tab2list(:active_devices) |> Enum.map(fn {clientid} -> clientid end)
+  # end
+
+  def get_active_devices() do
+    Devices.list_devices()
+  end
+
+  # Publish functions
+
+  # Publish to a specific client by specifying the client ID
+  def publish_data(clientid, message) do
+    GenServer.call(__MODULE__, {:publish, :data, clientid, message})
+  end
+
+  def publish_admin_in(clientid, message) do
+    GenServer.call(__MODULE__, {:publish, :admin_in, clientid, message})
+  end
+
+  def publish_admin_out(clientid, message) do
+    GenServer.call(__MODULE__, {:publish, :admin_out, clientid, message})
+  end
+
+  # New function to send firmware change message
+  def send_firmware_change(gw_list, target_firmware) do
+    GenServer.cast(__MODULE__, {:send_firmware_change, gw_list, target_firmware})
+  end
+
+  # Function to request firmware info from a gateway
+  def request_firmware_info(clientid) do
+    GenServer.call(__MODULE__, {:request_firmware_info, clientid})
+  end
+
+  #
+  #
+  #
+  # GenServer Callbacks
+  #
+  #
+  #
+  #
+
+  def init([]) do
+    state = initial_state()
+    {:ok, set_timer(state), {:continue, :start_emqtt}}
+  end
+
+  def handle_continue(:start_emqtt, state) do
+    case :emqtt.connect(state.pid) do
+      {:ok, _} ->
+        # Successful connection, subscribe to topics
+        subscribe_to_topics(state)
+        {:noreply, state}
+
+      {:error, reason} ->
+        Logger.error("Failed to connect to MQTT broker: #{inspect(reason)}")
+        # Optionally retry the connection after a delay
+        Process.send_after(self(), :retry_connect, 5000)
+        {:noreply, state}
+    end
+  end
+
+  def handle_call(:get_pid, _from, state) do
+    {:reply, state.pid, state}
+  end
+
+  def handle_call(:get_state, _from, state) do
+    {:reply, state, state}
+  end
+
+  def handle_call({:request_firmware_info, clientid}, _from, state) do
+    # Construct the request_firmware_info message
+    message = %{
+      "request_firmware_info" => true
+    }
+
+    # Encode the message as JSON
+    {:ok, json_message} = Jason.encode(message)
+
+    # Publish the message to the gateway's admin topic
+    topic = get_topic_by_type(:admin_in, clientid)
+    :emqtt.publish(state.pid, topic, json_message)
+
+    Logger.info("Sent request_firmware_info message to gateway #{clientid} on topic #{topic}")
+
+    {:reply, :ok, state}
+  end
+
+  def handle_call({:publish, topic_type, clientid, message}, _from, state) do
+    topic = get_topic_by_type(topic_type, clientid)
+    :emqtt.publish(state.pid, topic, message)
+    {:reply, :ok, state}
+  end
+
+  def handle_cast(:send_ping_for_active_devices, state) do
+    Logger.info("Starting ping to all devices...")
+    ping_all_devices(state)
+    {:noreply, state}
+  end
+
+  def handle_cast({:send_firmware_change, gw_list, target_firmware}, state) do
+    # Construct the message without the gw_list
+    message = %{
+      "firmware_change" => %{
+        "target_firmware" => target_firmware
+      }
+    }
+
+    # Encode the message as JSON
+    {:ok, json_message} = Jason.encode(message)
+
+    # Offload the work to a separate Task
+    Task.start(fn ->
+      send_firmware_change_messages(gw_list, json_message, state.pid)
+    end)
+
+    {:noreply, state}
+  end
+
+  def handle_info(:tick, state) do
+    # Example: Periodic task (e.g., report status)
+    Logger.info("Tick event occurred")
+    {:noreply, set_timer(state)}
+  end
+
+  @doc """
+  When message arrives this function is called first. When gateway sent message to cloud on topic admin_out here was processed first.
+  """
+  def handle_info({:publish, %{topic: topic, payload: payload}}, state) do
+    Logger.info("Received message on topic: #{topic} in handle_info publish % topic")
+    Logger.info("Payload: #{inspect(payload)}")
+
+    new_state = process_message(topic, payload, state)
+    {:noreply, new_state}
+  end
+
+  def handle_info(:retry_connect, state) do
+    Logger.info("Retrying connection to MQTT broker...")
+
+    # Attempt to connect again; if the pid is stale, restart emqtt client
+    new_state = restart_emqtt_client(state)
+    {:noreply, new_state, {:continue, :start_emqtt}}
+  end
+
+  defp restart_emqtt_client(state) do
+    # Terminate old pid if needed (best effort)
+    if Process.alive?(state.pid), do: :emqtt.stop(state.pid)
+
+    case :emqtt.start_link(Application.get_env(:nemo_cloud_services, :emqtt, [])) do
+      {:ok, new_pid} ->
+        %{state | pid: new_pid}
+
+      {:error, reason} ->
+        Logger.error("Failed to restart EMQTT client: #{inspect(reason)}")
+        state
+    end
+  end
+
+  def ping_all_devices(state) do
+    devices = get_active_devices()
+
+    Enum.each(devices, fn device ->
+      Logger.info("Sending ping to device: #{device.name}")
+      topic = "nemo/pilot/#{device.name}/admin_in"
+      Logger.info("Topic: #{topic}")
+      message = Jason.encode!(%{"ping" => true})
+
+      case safe_publish(state.pid, topic, message) do
+        :ok -> Logger.info("Ping sent successfully")
+        {:error, _reason} -> Logger.error("Ping failed, check connection")
+      end
+    end)
+
+    Logger.info("Ping sent to all devices.")
+  end
+
+  defp send_firmware_change_messages(gw_list, json_message, pid) do
+    gw_list
+    |> Task.async_stream(
+      fn gw_id ->
+        topic = get_topic_by_type(:admin_in, gw_id)
+
+        case :emqtt.publish(pid, topic, json_message) do
+          :ok ->
+            Logger.info("Sent firmware change message to gateway #{gw_id} on topic #{topic}")
+            :ok
+
+          {:error, reason} ->
+            Logger.error("Failed to send message to gateway #{gw_id}: #{inspect(reason)}")
+            {:error, reason}
+        end
+      end,
+      max_concurrency: 10,
+      timeout: :infinity
+    )
+    # Collect the results
+    |> Enum.to_list()
+    |> Enum.each(fn
+      {:ok, :ok} ->
+        :ok
+
+      # Already logged
+      {:ok, {:error, _reason}} ->
+        :ok
+
+      {:exit, reason} ->
+        Logger.error("Task exited with reason: #{inspect(reason)}")
+    end)
+  end
+
+  # Helper Functions
+
+  defp set_timer(state) when is_nil(state) or state == %{} do
+    Logger.error("Timer not set. State is nil.")
+    state
+  end
+
+  defp set_timer(state) do
+    state =
+      if state == :ok do
+        load_state() || initial_state()
+      else
+        state
+      end
+
+    if state.timer do
+      Process.cancel_timer(state.timer)
+    end
+
+    timer = Process.send_after(self(), :tick, state.interval)
+    Logger.debug("Timer set to #{state.interval} milliseconds")
+    new_state = %{state | timer: timer}
+    save_state(new_state)
+    new_state
+  end
+
+  # from handle_info which first process the message it goes here
+  defp process_message(topic, payload, state) do
+    # Extract clientid and message type from the topic
+    case String.split(topic, "/") do
+      ["nemo", "pilot", clientid, "data"] ->
+        handle_data_message(clientid, payload, state)
+
+      ["nemo", "pilot", clientid, "admin_in"] ->
+        handle_admin_in_message(clientid, payload, state)
+
+      ["nemo", "pilot", clientid, "admin_out"] ->
+        handle_admin_out_message(clientid, payload, state)
+
+      # For messages from EMQX rules - for disconnect / connect status
+      ["nemo", "pilot", "clients", "status"] ->
+        handle_status_message(payload, state)
+
+      _ ->
+        Logger.warning("Received message on unknown topic: #{topic}")
+        state
+    end
+  end
+
+  # To support messages from EMQX rules - for disconnect / connect status
+  defp handle_status_message(payload, state) do
+    Logger.info("Handling status message")
+    Logger.info("Payload: #{inspect(payload)}")
+
+    # Clean the payload by replacing 'undefined' with 'null'
+    cleaned_payload = String.replace(payload, "undefined", "null")
+
+    # Parse the cleaned JSON payload
+    case Jason.decode(cleaned_payload) do
+      {:ok, %{"clientid" => clientid} = status_info} ->
+        # Broadcast the status change via PubSub using "status" topic
+        Phoenix.PubSub.broadcast(
+          NemoCloudServices.PubSub,
+          "status",
+          {"status", clientid, status_info}
+        )
+
+        # Update active devices based on connection status
+        handle_status_update(clientid, status_info)
+        state
+
+      {:error, error} ->
+        Logger.error("Failed to decode status message payload: #{inspect(error)}")
+        state
+    end
+  end
+
+  # To support messages from EMQX rules - for disconnect / connect status
+  defp handle_status_update(clientid, %{"connected_at" => connected_at})
+       when not is_nil(connected_at) do
+    # Client connected
+    Logger.info("Client #{clientid} connected at #{connected_at}")
+    # Add to active devices
+    update_active_devices(clientid)
+  end
+
+  defp handle_status_update(clientid, %{"disconnected_at" => disconnected_at})
+       when not is_nil(disconnected_at) do
+    # Client disconnected
+    Logger.info("Client #{clientid} disconnected at #{disconnected_at}")
+    # Remove from active devices
+    :ets.delete(:active_devices, clientid)
+    Logger.info("Device #{clientid} removed from active devices.")
+  end
+
+  defp handle_status_update(_clientid, _status_info) do
+    # Unhandled status info
+    :ok
+  end
+
+  defp handle_data_message(clientid, payload, state) do
+    Phoenix.PubSub.broadcast(NemoCloudServices.PubSub, "data", {clientid, payload})
+    Logger.info("Handling data message from client #{clientid}")
+    Logger.info("Payload: #{inspect(payload)}")
+    # Process payload
+    track_client(clientid, payload)
+    state
+  end
+
+  # Track client in ETS table if it doesn't already exist
+  # defp track_client(clientid) do
+  #   if clientid == "" do
+  #     Logger.warning("Client ID is empty")
+  #   end
+
+  #   if clientid != "" and :ets.lookup(:active_devices, clientid) == [] do
+  #     :ets.insert(:active_devices, {clientid})
+  #     Logger.info("New client recognized: #{clientid}")
+  #   end
+  # end
+
+  defp track_client(clientid, payload) do
+    case Jason.decode(payload) do
+      {:ok, decoded_payload} ->
+        if clientid == "" do
+          Logger.warning("Client ID is empty")
+        else
+          case Repo.get_by(NemoCloudServices.Devices.Device, name: clientid) do
+            nil ->
+              attributes = %{
+                name: clientid,
+                state: "active",
+                fw_version: decoded_payload["a.nerves_fw_version"] || "unknown",
+                fw_filename: "#{clientid}_firmware.fw",
+                fw_updated_date: DateTime.utc_now(),
+                active_partition: decoded_payload["nerves_fw_active"] || "unknown",
+                last_ssh_session: DateTime.utc_now(),
+                ssh_state: false,
+                last_alive: DateTime.utc_now()
+              }
+
+              IO.inspect(attributes, label: "Parsed Attributes")
+
+              case Devices.create_device(attributes) do
+                {:ok, _device} ->
+                  Logger.info("New client recognized and added: #{clientid}")
+
+                {:error, changeset} ->
+                  Logger.error("Failed to create device: #{inspect(changeset)}")
+              end
+
+            _device ->
+              Logger.info("Client already tracked: #{clientid}")
+          end
+        end
+
+      {:error, _reason} ->
+        Logger.error("Failed to decode payload for client: #{clientid}")
+    end
+  end
+
+  defp update_active_devices(clientid) do
+    if clientid == "" do
+      Logger.warning("Client ID is empty")
+    end
+
+    if clientid != "" and :ets.lookup(:active_devices, clientid) == [] do
+      :ets.insert(:active_devices, {clientid})
+      Logger.info("Device #{clientid} added to active devices.")
+    end
+  end
+
+  defp handle_admin_in_message(clientid, payload, state) do
+    Phoenix.PubSub.broadcast(
+      NemoCloudServices.PubSub,
+      "admin_in",
+      {clientid, payload}
+    )
+
+    case Jason.decode(payload) do
+      _ ->
+        Logger.info("Received unrecognized admin message from #{clientid}")
+        state
+        # existing message handling
+    end
+  end
+
+  defp handle_admin_out_message(clientid, payload, state) do
+    # track_client(clientid, payload)
+    Logger.info("Handling admin out message from client #{clientid}")
+
+    case Jason.decode(payload) do
+      {:ok, %{"ssh_tunnel" => _state}} ->
+        Phoenix.PubSub.broadcast(
+          NemoCloudServices.PubSub,
+          "admin_out_ssh",
+          {"admin_out_ssh", {clientid, payload}}
+        )
+
+      {:ok, %{"reason" => _reason, "ssh_tunnel" => "error"}} ->
+        Phoenix.PubSub.broadcast(
+          NemoCloudServices.PubSub,
+          "admin_out_ssh",
+          {"admin_out_ssh", {clientid, payload}}
+        )
+
+      {:ok, %{"pong" => true}} ->
+        Phoenix.PubSub.broadcast(
+          NemoCloudServices.PubSub,
+          "admin_out_pingpong",
+          {"admin_out_pingpong", {clientid, payload}}
+        )
+
+        Logger.info("Received pong from #{clientid}")
+        # update_active_devices(clientid)
+        state
+
+      {:ok, payload} ->
+        Phoenix.PubSub.broadcast(
+          NemoCloudServices.PubSub,
+          "admin_out_other",
+          {"admin_out_other", {clientid, payload}}
+        )
+
+        Logger.info("Received firmware info from #{clientid} / real GATEWAY")
+        Logger.info("Payload: #{inspect(payload)}")
+
+        state
+
+      _ ->
+        Logger.info("Received unrecognized admin ack message from #{clientid}")
+        state
+    end
+  end
+
+  defp get_topic_by_type(:data, clientid), do: "nemo/pilot/#{clientid}/data"
+
+  # messages from cloud to gateway
+  defp get_topic_by_type(:admin_in, clientid), do: "nemo/pilot/#{clientid}/admin_in"
+
+  # messages from gateway to cloud
+  defp get_topic_by_type(:admin_out, clientid), do: "nemo/pilot/#{clientid}/admin_out"
+
+  defp initial_state() do
+    interval = Application.get_env(:nemo_cloud_services, :interval, 5_000)
+    emqtt_opts = Application.get_env(:nemo_cloud_services, :emqtt, [])
+
+    # Subscription topics with wildcard '+'
+    data_topic = "nemo/pilot/+/data"
+    admin_in_topic = "nemo/pilot/+/admin_in"
+    admin_out_topic = "nemo/pilot/+/admin_out"
+    status_topic = "nemo/pilot/clients/status"
+
+    case :emqtt.start_link(emqtt_opts) do
+      {:ok, pid} ->
+        %{
+          interval: interval,
+          timer: nil,
+          data_topic: data_topic,
+          admin_in_topic: admin_in_topic,
+          admin_out_topic: admin_out_topic,
+          status_topic: status_topic,
+          pid: pid
+        }
+
+      {:error, reason} ->
+        Logger.error("Failed to start EMQTT client: #{inspect(reason)}")
+        # Handle failure gracefully (e.g., retry or return a default state)
+        raise "Failed to start EMQTT client"
+    end
+  end
+
+  defp subscribe_to_topics(state) do
+    # Subscribe to topics with wildcard '+'
+    topics = [
+      {state.data_topic, 1},
+      {state.admin_in_topic, 1},
+      {state.admin_out_topic, 1},
+      {state.status_topic, 1}
+    ]
+
+    case :emqtt.subscribe(state.pid, topics) do
+      {:ok, _packet_id, _topics} ->
+        Logger.info("Subscribed to topics successfully")
+        :ok
+
+      {:error, reason} ->
+        Logger.error("Failed to subscribe to topics: #{inspect(reason)}")
+    end
+  end
+
+  defp save_state(state) do
+    :persistent_term.put(@persistent_term_key, state)
+  end
+
+  defp load_state() do
+    case :persistent_term.get(@persistent_term_key, nil) do
+      nil -> initial_state()
+      state -> state
+    end
+  end
+
+  def safe_publish(pid, topic, message) do
+    case :emqtt.publish(pid, topic, message) do
+      :ok ->
+        :ok
+
+      {:error, :not_connected} ->
+        Logger.error("MQTT client is not connected. Retrying...")
+        # Optionally implement retry logic or re-establish connection here
+        {:error, :not_connected}
+
+      {:error, reason} ->
+        Logger.error("Failed to publish due to #{inspect(reason)}")
+        {:error, reason}
+    end
+  end
+end
diff --git a/lib/nemo_cloud_services/conn/mqtt_supervisor.ex b/lib/nemo_cloud_services/conn/mqtt_supervisor.ex
new file mode 100644
index 0000000000000000000000000000000000000000..3aa1d693e7a145977f74424b0b03c8a88c894911
--- /dev/null
+++ b/lib/nemo_cloud_services/conn/mqtt_supervisor.ex
@@ -0,0 +1,20 @@
+defmodule NemoCloudServices.Conn.MqttSupervisor do
+  use Supervisor
+
+  require Logger
+
+  def start_link([]) do
+    Supervisor.start_link(__MODULE__, [], name: __MODULE__)
+  end
+
+  @impl true
+  def init([]) do
+    Logger.info("Starting MQTT Supervisor")
+
+    children = [
+      {NemoCloudServices.Conn.MqttClient, []}
+    ]
+
+    Supervisor.init(children, strategy: :one_for_one, max_restarts: 3, max_seconds: 3600)
+  end
+end
diff --git a/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_1.ex b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_1.ex
new file mode 100644
index 0000000000000000000000000000000000000000..fac573054c792b7d3cb8696e3126ba8d61812d8d
--- /dev/null
+++ b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_1.ex
@@ -0,0 +1,262 @@
+defmodule NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1 do
+  use GenServer
+
+  @moduledoc """
+  A GenServer that represents a device's digital twin.
+
+  ## Responsibilities
+  - Mimic device functionalities and lifecycle.
+  - Handle firmware changes.
+  - Provide info such as firmware version, device status, etc.
+  - Simulate reboots and other device events.
+  """
+
+  # ------------------------------------------------------------------
+  # Public API
+  # ------------------------------------------------------------------
+
+  @doc """
+  Starts the DeviceTwin GenServer.
+  """
+def start_link(initial_state \\ %{}) do
+  GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
+end
+
+@doc """
+Requests a firmware change.
+
+`firmware_change_message` is expected to be a JSON string, for example:
+{ "firmware_change": { "gw": ["gw1", "gw2"], "target_firmware": "new_firmware_v1.2.fw" } }
+"""
+def change_firmware(firmware_change_message) do
+GenServer.call(__MODULE__, {:change_firmware, firmware_change_message})
+end
+
+@doc """
+Gets the currently active firmware version.
+"""
+def get_firmware_version() do
+GenServer.call(__MODULE__, :get_firmware_version)
+end
+
+@doc """
+Retrieves the current device status.
+"""
+def get_last_data() do
+GenServer.call(__MODULE__, :get_last_data)
+end
+
+@doc """
+Retrieves device info (model, manufacturer, hardware details, etc.).
+"""
+def get_device() do
+GenServer.call(__MODULE__, :get_device)
+end
+
+@doc """
+Gets the device logs.
+"""
+def get_device_logs() do
+GenServer.call(__MODULE__, :get_device_logs)
+end
+
+@doc """
+Gets recent device events.
+"""
+def get_device_events() do
+GenServer.call(__MODULE__, :get_device_events)
+end
+
+# ------------------------------------------------------------------
+# GenServer Callbacks
+# ------------------------------------------------------------------
+
+@impl true
+def init(initial_state) do
+  # Set up any initial variables or timers here.
+  # For example, keep track of the initial firmware version:
+  state =
+    Map.merge(
+      %{
+        id: 1,
+        name: "DT_nemo_1",
+        fw_version: "0.15.0",
+        state: "active",
+        fw_filename: "nemo_fw_v0.15.0.fw",
+        fw_updated_date: ~U[2024-12-09 13:31:15Z],
+        active_partition: "a",
+        last_ssh_session: nil,
+        ssh_state: false,
+        last_alive: ~U[2024-12-09 13:31:15Z],
+        prev_fw_version: "0.11.0",
+        inserted_at: ~U[2024-12-09 13:31:15Z],
+        updated_at: ~U[2024-12-18 09:40:56Z],
+        nerves_fw_active: "a",
+        data: %{
+          flags: 64,
+          name: "Station-1",
+          actual_frequency: 50.01,
+          analogs: [],
+          digitals: [],
+          phasors: [
+            %{
+                name: "IL1-0",
+                unit: "amper",
+                angle: 169.16,
+                magnitude: 0.00
+            },
+            %{
+                name: "IL2-0",
+                unit: "amper",
+                angle: 21.53,
+                magnitude: 3.30
+            },
+            %{
+                name: "IL3-0",
+                unit: "amper",
+                angle: -161.85,
+                magnitude: 0.00
+            },
+            %{
+                name: "VL1-0",
+                unit: "volt",
+                angle: -170.88,
+                magnitude: 236.52
+            },
+            %{
+                name: "VL2-0",
+                unit: "volt",
+                angle: -50.54,
+                magnitude: 236.62
+            },
+            %{
+                name: "VL3-0",
+                unit: "volt",
+                angle: 69.52,
+                magnitude: 237.32
+            },
+            %{
+                name: "I_ZERO_SEQ",
+                unit: "amper",
+                angle: 21.53,
+                magnitude: 1.10
+            },
+            %{
+                name: "V_ZERO_SEQ",
+                unit: "volt",
+                angle: 93.57,
+                magnitude: 0.71
+            }
+          ],
+          rate_of_change_of_frequency: 0.05
+        }
+      },
+      initial_state
+    )
+
+
+  {:ok, state}
+end
+
+@impl true
+def handle_call({:change_firmware, firmware_change_msg}, _from, state) do
+
+  new_firmware_version = firmware_change_msg
+
+  # Schedule a "reboot" in 5-10 seconds:
+  reboot_delay = Enum.random(5_000..10_000)
+  Process.send_after(self(), {:reboot, new_firmware_version}, reboot_delay)
+
+  {:reply, :ok, state}
+end
+
+@impl true
+def handle_call(:get_firmware_version, _from, state) do
+  {:reply, state.firmware_version, state}
+end
+
+@impl true
+def handle_call(:get_last_data, _from, state) do
+  updated_phasors = Enum.map(state.data.phasors, fn phasor ->
+    %{
+      phasor
+      | angle: Float.round((phasor.angle + :rand.uniform() * 10 - 5), 2),
+        magnitude: Float.round((phasor.magnitude + :rand.uniform() * 0.1 - 0.05), 2)
+    }
+  end)
+
+  new_data = Map.put(state.data, :phasors, updated_phasors)
+  new_state = Map.put(state, :data, new_data)
+  {:reply, new_state.data, state}
+end
+
+@impl true
+def handle_call(:get_device, _from, state) do
+  {:reply, state, state}
+end
+
+@impl true
+def handle_call(:get_device_logs, _from, state) do
+  {:reply, state.device_logs, state}
+end
+
+@impl true
+def handle_call(:get_device_events, _from, state) do
+  {:reply, state.device_events, state}
+end
+
+@impl true
+def handle_info({:reboot, new_firmware_version}, state) do
+
+  regex = ~r/v(\d+\.\d+\.\d+)/
+
+  new_fw_version=
+    case Regex.run(regex, new_firmware_version) do
+      [_, version] -> version
+      _ -> "1.0.1"
+    end
+
+  prev_version = Map.get(state, :fw_version)
+  fw_version = new_fw_version
+  active_partition = Map.get(state, :nerves_fw_active)
+
+  new_active_partition =
+    case active_partition do
+      "a" -> "b"
+      "b" -> "a"
+      _ -> active_partition
+    end
+
+  new_state =
+    state
+    |> Map.put(:state, "active")
+    |> Map.put(:nerves_fw_active, new_active_partition)
+
+  updated_payload =
+    case active_partition do
+      "a" ->
+        %{
+          "a.nerves_fw_version" => prev_version,
+          "b.nerves_fw_version" => fw_version,
+          "nerves_fw_active" => "b",
+        }
+      "b" ->
+        %{
+          "a.nerves_fw_version" => fw_version,
+          "b.nerves_fw_version" => prev_version,
+          "nerves_fw_active" => "a",
+        }
+      _ ->
+        %{}
+    end
+
+  Phoenix.PubSub.broadcast(
+    NemoCloudServices.PubSub,
+    "admin_out_other",
+    {"admin_out_other", {Map.get(state, :name), updated_payload}}
+  )
+
+  {:noreply, new_state}
+end
+
+end
diff --git a/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_2.ex b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_2.ex
new file mode 100644
index 0000000000000000000000000000000000000000..e8ee1a8608b3843837d61f3c1360da8870dd0d4d
--- /dev/null
+++ b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_2.ex
@@ -0,0 +1,263 @@
+defmodule NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2 do
+    use GenServer
+
+    @moduledoc """
+    A GenServer that represents a device's digital twin.
+
+    ## Responsibilities
+    - Mimic device functionalities and lifecycle.
+    - Handle firmware changes.
+    - Provide info such as firmware version, device status, etc.
+    - Simulate reboots and other device events.
+    """
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+
+    @doc """
+    Starts the DeviceTwin GenServer.
+    """
+    def start_link(initial_state \\ %{}) do
+      GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
+    end
+
+    @doc """
+    Requests a firmware change.
+
+    `firmware_change_message` is expected to be a JSON string, for example:
+    { "firmware_change": { "gw": ["gw1", "gw2"], "target_firmware": "new_firmware_v1.2.fw" } }
+    """
+  def change_firmware(firmware_change_message) do
+    GenServer.call(__MODULE__, {:change_firmware, firmware_change_message})
+  end
+
+  @doc """
+  Gets the currently active firmware version.
+  """
+  def get_firmware_version() do
+    GenServer.call(__MODULE__, :get_firmware_version)
+  end
+
+  @doc """
+  Retrieves the current device status.
+  """
+  def get_last_data() do
+    GenServer.call(__MODULE__, :get_last_data)
+  end
+
+  @doc """
+  Retrieves device info (model, manufacturer, hardware details, etc.).
+  """
+  def get_device() do
+    GenServer.call(__MODULE__, :get_device)
+  end
+
+  @doc """
+  Gets the device logs.
+  """
+  def get_device_logs() do
+    GenServer.call(__MODULE__, :get_device_logs)
+  end
+
+  @doc """
+  Gets recent device events.
+  """
+  def get_device_events() do
+    GenServer.call(__MODULE__, :get_device_events)
+  end
+
+
+  # ------------------------------------------------------------------
+  # GenServer Callbacks
+  # ------------------------------------------------------------------
+
+  @impl true
+  def init(initial_state) do
+    # Set up any initial variables or timers here.
+    # For example, keep track of the initial firmware version:
+    state =
+      Map.merge(
+        %{
+          id: 2,
+          name: "DT_nemo_2",
+          fw_version: "0.15.0",
+          state: "active",
+          fw_filename: "nemo_fw_v0.15.0.fw",
+          fw_updated_date: ~U[2024-12-09 13:31:15Z],
+          active_partition: "a",
+          last_ssh_session: nil,
+          ssh_state: false,
+          last_alive: ~U[2024-12-09 13:31:15Z],
+          prev_fw_version: "0.11.0",
+          inserted_at: ~U[2024-12-09 13:31:15Z],
+          updated_at: ~U[2024-12-18 09:40:56Z],
+          nerves_fw_active: "a",
+          data: %{
+            flags: 64,
+            name: "Station-1",
+            actual_frequency: 50.01,
+            analogs: [],
+            digitals: [],
+            phasors: [
+              %{
+                  name: "IL1-0",
+                  unit: "amper",
+                  angle: 169.16,
+                  magnitude: 0.00
+              },
+              %{
+                  name: "IL2-0",
+                  unit: "amper",
+                  angle: 21.53,
+                  magnitude: 3.30
+              },
+              %{
+                  name: "IL3-0",
+                  unit: "amper",
+                  angle: -161.85,
+                  magnitude: 0.00
+              },
+              %{
+                  name: "VL1-0",
+                  unit: "volt",
+                  angle: -170.88,
+                  magnitude: 236.52
+              },
+              %{
+                  name: "VL2-0",
+                  unit: "volt",
+                  angle: -50.54,
+                  magnitude: 236.62
+              },
+              %{
+                  name: "VL3-0",
+                  unit: "volt",
+                  angle: 69.52,
+                  magnitude: 237.32
+              },
+              %{
+                  name: "I_ZERO_SEQ",
+                  unit: "amper",
+                  angle: 21.53,
+                  magnitude: 1.10
+              },
+              %{
+                  name: "V_ZERO_SEQ",
+                  unit: "volt",
+                  angle: 93.57,
+                  magnitude: 0.71
+              }
+            ],
+            rate_of_change_of_frequency: 0.05
+          }
+        },
+        initial_state
+      )
+
+
+    {:ok, state}
+  end
+
+  @impl true
+  def handle_call({:change_firmware, firmware_change_msg}, _from, state) do
+
+    new_firmware_version = firmware_change_msg
+
+    # Schedule a "reboot" in 5-10 seconds:
+    reboot_delay = Enum.random(5_000..10_000)
+    Process.send_after(self(), {:reboot, new_firmware_version}, reboot_delay)
+
+    {:reply, :ok, state}
+  end
+
+  @impl true
+  def handle_call(:get_firmware_version, _from, state) do
+    {:reply, state.firmware_version, state}
+  end
+
+  @impl true
+  def handle_call(:get_last_data, _from, state) do
+    updated_phasors = Enum.map(state.data.phasors, fn phasor ->
+      %{
+        phasor
+        | angle: Float.round((phasor.angle + :rand.uniform() * 10 - 5), 2),
+          magnitude: Float.round((phasor.magnitude + :rand.uniform() * 0.1 - 0.05), 2)
+      }
+    end)
+
+    new_data = Map.put(state.data, :phasors, updated_phasors)
+    new_state = Map.put(state, :data, new_data)
+    {:reply, new_state.data, state}
+  end
+
+  @impl true
+  def handle_call(:get_device, _from, state) do
+    {:reply, state, state}
+  end
+
+  @impl true
+  def handle_call(:get_device_logs, _from, state) do
+    {:reply, state.device_logs, state}
+  end
+
+  @impl true
+  def handle_call(:get_device_events, _from, state) do
+    {:reply, state.device_events, state}
+  end
+
+  @impl true
+  def handle_info({:reboot, new_firmware_version}, state) do
+
+    regex = ~r/v(\d+\.\d+\.\d+)/
+
+    new_fw_version=
+      case Regex.run(regex, new_firmware_version) do
+        [_, version] -> version
+        _ -> "1.0.1"
+      end
+
+    prev_version = Map.get(state, :fw_version)
+    fw_version = new_fw_version
+    active_partition = Map.get(state, :nerves_fw_active)
+
+    new_active_partition =
+      case active_partition do
+        "a" -> "b"
+        "b" -> "a"
+        _ -> active_partition
+      end
+
+    new_state =
+      state
+      |> Map.put(:state, "active")
+      |> Map.put(:nerves_fw_active, new_active_partition)
+
+    updated_payload =
+      case active_partition do
+        "a" ->
+          %{
+            "a.nerves_fw_version" => prev_version,
+            "b.nerves_fw_version" => fw_version,
+            "nerves_fw_active" => "b",
+          }
+        "b" ->
+          %{
+            "a.nerves_fw_version" => fw_version,
+            "b.nerves_fw_version" => prev_version,
+            "nerves_fw_active" => "a",
+          }
+        _ ->
+          %{}
+      end
+
+    Phoenix.PubSub.broadcast(
+      NemoCloudServices.PubSub,
+      "admin_out_other",
+      {"admin_out_other", {Map.get(state, :name), updated_payload}}
+    )
+
+    {:noreply, new_state}
+  end
+
+  end
diff --git a/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_supervisor.ex b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_supervisor.ex
new file mode 100644
index 0000000000000000000000000000000000000000..1c0e4e847f45714d8a57bc3cc3ca0bdad7951d6a
--- /dev/null
+++ b/lib/nemo_cloud_services/device_digital_twin/device_digital_twin_supervisor.ex
@@ -0,0 +1,23 @@
+defmodule NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwinSupervisor do
+  use Supervisor
+
+  @moduledoc """
+  Supervisor for the device twin GenServer.
+  """
+
+  def start_link(opts) do
+    Supervisor.start_link(__MODULE__, :ok, opts)
+  end
+
+  @impl true
+  def init(:ok) do
+    children = [
+      # Here we start our DeviceTwin with a default state of %{}.
+      {NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1, %{}},
+      {NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2, %{}}
+    ]
+
+    # We use :one_for_one so if the DeviceTwin crashes, only that process is restarted.
+    Supervisor.init(children, strategy: :one_for_one)
+  end
+end
diff --git a/lib/nemo_cloud_services/devices.ex b/lib/nemo_cloud_services/devices.ex
new file mode 100644
index 0000000000000000000000000000000000000000..9e7c1ac87fc8d058b8f7742b0161641079c80c82
--- /dev/null
+++ b/lib/nemo_cloud_services/devices.ex
@@ -0,0 +1,104 @@
+defmodule NemoCloudServices.Devices do
+  @moduledoc """
+  The Devices context.
+  """
+
+  import Ecto.Query, warn: false
+  alias NemoCloudServices.Repo
+
+  alias NemoCloudServices.Devices.Device
+
+  @doc """
+  Returns the list of devices.
+
+  ## Examples
+
+      iex> list_devices()
+      [%Device{}, ...]
+
+  """
+  def list_devices do
+    Repo.all(Device)
+  end
+
+  @doc """
+  Gets a single device.
+
+  Raises `Ecto.NoResultsError` if the Device does not exist.
+
+  ## Examples
+
+      iex> get_device!(123)
+      %Device{}
+
+      iex> get_device!(456)
+      ** (Ecto.NoResultsError)
+
+  """
+  def get_device!(id), do: Repo.get!(Device, id)
+
+  @doc """
+  Creates a device.
+
+  ## Examples
+
+      iex> create_device(%{field: value})
+      {:ok, %Device{}}
+
+      iex> create_device(%{field: bad_value})
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def create_device(attrs \\ %{}) do
+    %Device{}
+    |> Device.changeset(attrs)
+    |> Repo.insert()
+  end
+
+  @doc """
+  Updates a device.
+
+  ## Examples
+
+      iex> update_device(device, %{field: new_value})
+      {:ok, %Device{}}
+
+      iex> update_device(device, %{field: bad_value})
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def update_device(%Device{} = device, attrs) do
+    device
+    |> Device.changeset(attrs)
+    |> Repo.update()
+  end
+
+  @doc """
+  Deletes a device.
+
+  ## Examples
+
+      iex> delete_device(device)
+      {:ok, %Device{}}
+
+      iex> delete_device(device)
+      {:error, %Ecto.Changeset{}}
+
+  """
+  def delete_device(%Device{} = device) do
+    Repo.delete(device)
+  end
+
+  @doc """
+  Returns an `%Ecto.Changeset{}` for tracking device changes.
+
+  ## Examples
+
+      iex> change_device(device)
+      %Ecto.Changeset{data: %Device{}}
+
+  """
+  def change_device(%Device{} = device, attrs \\ %{}) do
+    Device.changeset(device, attrs)
+  end
+end
diff --git a/lib/nemo_cloud_services/devices/device.ex b/lib/nemo_cloud_services/devices/device.ex
new file mode 100644
index 0000000000000000000000000000000000000000..1138e3991c98343dc3c77456f51dfe6206ea787a
--- /dev/null
+++ b/lib/nemo_cloud_services/devices/device.ex
@@ -0,0 +1,27 @@
+defmodule NemoCloudServices.Devices.Device do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  schema "devices" do
+    field :name, :string
+    field :state, :string
+    field :fw_version, :string
+    field :fw_filename, :string
+    field :fw_updated_date, :utc_datetime
+    field :active_partition, :string
+    field :last_ssh_session, :utc_datetime
+    field :ssh_state, :boolean, default: false
+    field :last_alive, :utc_datetime
+    field :prev_fw_version, :string
+
+    timestamps(type: :utc_datetime)
+  end
+
+  @doc false
+  def changeset(device, attrs) do
+    device
+    |> cast(attrs, [:name, :state, :fw_version, :fw_filename, :fw_updated_date, :active_partition, :last_ssh_session, :ssh_state, :last_alive, :prev_fw_version])
+    |> validate_required([:name, :state, :fw_version, :fw_filename, :fw_updated_date, :active_partition, :ssh_state])
+    |> unique_constraint(:name)
+  end
+end
diff --git a/lib/nemo_cloud_services/gateway_poc/MAC_address.ex b/lib/nemo_cloud_services/gateway_poc/MAC_address.ex
new file mode 100644
index 0000000000000000000000000000000000000000..6d3caca8d7aafbf2ec83ba54be537c6253b42e96
--- /dev/null
+++ b/lib/nemo_cloud_services/gateway_poc/MAC_address.ex
@@ -0,0 +1,43 @@
+defmodule NemoCloudServices.GatewayPoc.MACAddress do
+  @moduledoc """
+
+  This module provides a function to get the MAC address of a network interface.
+  Later for production this will have to be switched for something else like LTE Modem IMEI or a combination of this with some additional information.
+  """
+  def get_mac_address_string(interface_name) do
+    get_mac_address(interface_name)
+    |> String.split(":")
+    |> Enum.join()
+    |> String.trim()
+  end
+
+  def get_mac_address(interface_name) do
+    :inet.getifaddrs()
+    |> case do
+      {:ok, ifaddrs} ->
+        ifaddrs
+        |> Enum.find_value(fn {interface, info} ->
+          if String.to_charlist(interface_name) == interface do
+            Keyword.get(info, :hwaddr)
+          else
+            nil
+          end
+        end)
+        |> case do
+          nil -> "unknown_mac"
+          mac -> format_mac(mac)
+        end
+
+      _ ->
+        "unknown_mac"
+    end
+  end
+
+  defp format_mac(mac) do
+    mac
+    |> Enum.map(&Integer.to_string(&1, 16))
+    |> Enum.map(&String.pad_leading(&1, 2, "0"))
+    |> Enum.join(":")
+    |> String.downcase()
+  end
+end
diff --git a/lib/nemo_cloud_services/gateway_poc/client.ex b/lib/nemo_cloud_services/gateway_poc/client.ex
new file mode 100644
index 0000000000000000000000000000000000000000..07cb34658a11ce3bad6e7f6f651a9dc35b7ba8a0
--- /dev/null
+++ b/lib/nemo_cloud_services/gateway_poc/client.ex
@@ -0,0 +1,44 @@
+defmodule NemoCloudServices.GatewayPoc.Client do
+  @moduledoc """
+  Module for downloading firmware files using the Req HTTP client.
+  """
+
+  @doc """
+  Downloads a firmware file from the given URL and saves it to the specified destination path.
+
+  Returns `:ok` on success or `{:error, reason}` on failure.
+
+  ## Parameters
+
+    - `url`: The URL of the firmware file to download.
+    - `dest_path`: The path where the downloaded file will be saved.
+
+  ## Examples
+
+      iex> GatewayPoc.Client.download_firmware("http://localhost:4000/firmwares/download/outro.mp4", "downloads/outro.mp4")
+      :ok
+  """
+  def download_firmware(url, dest_path) do
+    # Ensure the destination directory exists
+    dest_dir = Path.dirname(dest_path)
+
+    with :ok <- File.mkdir_p(dest_dir) do
+      file_stream = File.stream!(dest_path, [:write, :binary])
+
+      # Use Req to download the file and stream into the file stream
+      case Req.get(url,
+             receive_timeout: :infinity,
+             into: file_stream
+           ) do
+        {:ok, _response} ->
+          :ok
+
+        {:error, reason} ->
+          {:error, reason}
+      end
+    else
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+end
diff --git a/lib/nemo_cloud_services/gateway_poc/mqtt_client.ex b/lib/nemo_cloud_services/gateway_poc/mqtt_client.ex
new file mode 100644
index 0000000000000000000000000000000000000000..401bc5dc225851a9b78037c6b1e0febc31d2ba91
--- /dev/null
+++ b/lib/nemo_cloud_services/gateway_poc/mqtt_client.ex
@@ -0,0 +1,154 @@
+# defmodule NemoCloudServices.GatewayPoc.MqttClient do
+#   @moduledoc false
+
+#   use GenServer
+#   alias Jason
+
+#   @persistent_term_key :gw_mqtt_client_state
+
+#   def start_link([]) do
+#     GenServer.start_link(__MODULE__, [], name: __MODULE__)
+#   end
+
+#   def init([]) do
+#     st = load_state() || initial_state()
+#     {:ok, set_timer(st), {:continue, :start_emqtt}}
+#   end
+
+#   def handle_continue(:start_emqtt, %{pid: pid} = st) do
+#     {:ok, _} = :emqtt.connect(pid)
+
+#     emqtt_opts = Application.get_env(:nemo_mqtt_client, :emqtt)
+#     clientid = emqtt_opts[:clientid]
+#     {:ok, _, _} = :emqtt.subscribe(pid, {"nemo/pilot/#{clientid}/#", 1})
+#     {:noreply, st}
+#   end
+
+#   def handle_info(:tick, %{report_topic: topic, pid: pid} = st) do
+#     report_temperature(pid, topic)
+#     {:noreply, set_timer(st)}
+#   end
+
+#   def handle_info({:publish, %{topic: topic, payload: payload}}, st) do
+#     IO.puts("Received message on topic: #{topic}")
+#     IO.puts("Payload: #{payload}")
+#     new_st = process_command(topic, payload, st)
+#     {:noreply, new_st}
+#   end
+
+#   def handle_call({:test_publish, payload}, _from, %{report_topic: topic, pid: pid} = st) do
+#     :emqtt.publish(pid, topic, payload)
+#     {:reply, :ok, st}
+#   end
+
+#   def handle_call({:process_test_message, message}, _from, st) do
+#     new_st = process_command("commands/gwclient/process_test_message", message, st)
+#     {:reply, :ok, new_st}
+#   end
+
+#   def handle_call(:report_genserver_state, _from, st) do
+#     report_genserver_state(st)
+#     {:reply, :ok, st}
+#   end
+
+#   defp set_timer(st) do
+#     if st.timer do
+#       Process.cancel_timer(st.timer)
+#     end
+
+#     timer = Process.send_after(self(), :tick, st.interval)
+#     IO.puts("Timer set to #{st.interval} milliseconds")
+#     new_st = %{st | timer: timer}
+#     save_state(new_st)
+#     new_st
+#   end
+
+#   defp report_temperature(pid, topic) do
+#     temperature = 10.0 + 2.0 * :rand.normal()
+
+#     message = %{
+#       timestamp: System.system_time(:millisecond),
+#       temperature: temperature
+#     }
+
+#     payload = Jason.encode!(message)
+#     :emqtt.publish(pid, topic, payload)
+#   end
+
+#   defp report_genserver_state(st) do
+#     state_info = %{
+#       interval: st.interval,
+#       timer: inspect(st.timer),
+#       report_topic: st.report_topic,
+#       pid: inspect(st.pid)
+#     }
+
+#     payload = Jason.encode!(state_info)
+#     :emqtt.publish(st.pid, st.ack_topic, payload)
+#   end
+
+#   def test_publish(message) do
+#     GenServer.call(__MODULE__, {:test_publish, message})
+#   end
+
+#   def process_test_message(message) do
+#     GenServer.call(__MODULE__, {:process_test_message, message})
+#   end
+
+#   def request_state_report() do
+#     GenServer.call(__MODULE__, :report_genserver_state)
+#   end
+
+#   defp process_command(topic, message, st) do
+#     IO.puts("Processing command from topic: #{topic}")
+#     IO.puts("Processing command: #{message}")
+
+#     case Jason.decode(message) do
+#       {:ok, %{"command" => "get_gw_state"}} ->
+#         IO.puts("Command: get_gw_state")
+#         report_genserver_state(st)
+#         st
+
+#       {:ok, %{"command" => "set_interval", "value" => value}} ->
+#         IO.puts("Command: set_interval with value #{value}")
+#         new_st = %{st | interval: value}
+#         IO.puts("Interval set to #{value} milliseconds")
+#         set_timer(new_st)
+
+#       {:ok, decoded} ->
+#         IO.puts("Unknown command in the message: #{inspect(decoded)}")
+#         st
+
+#       {:error, reason} ->
+#         IO.puts("Failed to decode JSON message: #{reason}")
+#         st
+#     end
+#   end
+
+#   defp initial_state() do
+#     interval = Application.get_env(:gwclient, :interval)
+#     emqtt_opts = Application.get_env(:gwclient, :emqtt)
+#     report_topic = "ece/gw/#{emqtt_opts[:clientid]}/data"
+#     command_topic = "commands/#{emqtt_opts[:clientid]}"
+#     IO.inspect(command_topic, label: "command_topic")
+#     ack_topic = "commands_ack/#{emqtt_opts[:clientid]}"
+#     {:ok, pid} = :emqtt.start_link(emqtt_opts)
+
+#     %{
+#       interval: interval,
+#       timer: nil,
+#       report_topic: report_topic,
+#       command_topic: command_topic,
+#       ack_topic: ack_topic,
+#       pid: pid
+#     }
+#   end
+
+#   defp save_state(st) do
+#     :persistent_term.put(@persistent_term_key, st)
+#   end
+
+#   defp load_state() do
+#     :persistent_term.get(@persistent_term_key, nil)
+#   end
+# end
diff --git a/lib/nemo_cloud_services/logs.ex b/lib/nemo_cloud_services/logs.ex
new file mode 100644
index 0000000000000000000000000000000000000000..b41e706c40b93b5a397ab7b7ff0b0a39af2722ff
--- /dev/null
+++ b/lib/nemo_cloud_services/logs.ex
@@ -0,0 +1,26 @@
+defmodule NemoCloudServices.Logs do
+  @moduledoc """
+  The Logs context.
+  """
+
+  import Ecto.Query, warn: false
+  alias NemoCloudServices.Repo
+
+  alias NemoCloudServices.Logs.Logs
+
+  def list_messages do
+    Repo.all(Logs)
+  end
+
+  def get_message!(id), do: Repo.get!(Logs, id)
+
+  def create_message(attrs \\ %{}) do
+    %Logs{}
+    |> Logs.changeset(attrs)
+    |> Repo.insert()
+  end
+
+  def delete_message(%Logs{} = logs) do
+    Repo.delete(logs)
+  end
+end
diff --git a/lib/nemo_cloud_services/logs/logs.ex b/lib/nemo_cloud_services/logs/logs.ex
new file mode 100644
index 0000000000000000000000000000000000000000..9e47ce287ebef08a4a3ef4ee82cad6a8ed246830
--- /dev/null
+++ b/lib/nemo_cloud_services/logs/logs.ex
@@ -0,0 +1,20 @@
+defmodule NemoCloudServices.Logs.Logs do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  schema "logs" do
+    field :message, :string
+    field :type, :string
+    field :device, :string
+
+
+    timestamps(type: :utc_datetime)
+  end
+
+  @doc false
+  def changeset(logs, attrs) do
+    logs
+    |> cast(attrs, [:message, :type, :device])
+    |> validate_required([:message, :type])
+  end
+end
diff --git a/lib/nemo_cloud_services/mailer.ex b/lib/nemo_cloud_services/mailer.ex
new file mode 100644
index 0000000000000000000000000000000000000000..693592313443652d46935f658e20997bb5eb38d7
--- /dev/null
+++ b/lib/nemo_cloud_services/mailer.ex
@@ -0,0 +1,3 @@
+defmodule NemoCloudServices.Mailer do
+  use Swoosh.Mailer, otp_app: :nemo_cloud_services
+end
diff --git a/lib/nemo_cloud_services/release.ex b/lib/nemo_cloud_services/release.ex
new file mode 100644
index 0000000000000000000000000000000000000000..5c35e2cd3c6296f1c83962f2d41f8aaaceefa77d
--- /dev/null
+++ b/lib/nemo_cloud_services/release.ex
@@ -0,0 +1,28 @@
+defmodule NemoCloudServices.Release do
+  @moduledoc """
+  Used for executing DB release tasks when run in production without Mix
+  installed.
+  """
+  @app :nemo_cloud_services
+
+  def migrate do
+    load_app()
+
+    for repo <- repos() do
+      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
+    end
+  end
+
+  def rollback(repo, version) do
+    load_app()
+    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
+  end
+
+  defp repos do
+    Application.fetch_env!(@app, :ecto_repos)
+  end
+
+  defp load_app do
+    Application.load(@app)
+  end
+end
diff --git a/lib/nemo_cloud_services/repo.ex b/lib/nemo_cloud_services/repo.ex
new file mode 100644
index 0000000000000000000000000000000000000000..2872b9e1300f9bea039625c98c470989c43f4edb
--- /dev/null
+++ b/lib/nemo_cloud_services/repo.ex
@@ -0,0 +1,5 @@
+defmodule NemoCloudServices.Repo do
+  use Ecto.Repo,
+    otp_app: :nemo_cloud_services,
+    adapter: Ecto.Adapters.Postgres
+end
diff --git a/lib/nemo_cloud_services/utils/device_info.ex b/lib/nemo_cloud_services/utils/device_info.ex
new file mode 100644
index 0000000000000000000000000000000000000000..b848fc05514d729d739d7a18107cc8c4276646b3
--- /dev/null
+++ b/lib/nemo_cloud_services/utils/device_info.ex
@@ -0,0 +1,77 @@
+defmodule NemoCloudServices.Utils.DeviceInfo do
+  @moduledoc """
+    All device information related functions
+
+    For this
+
+    iex(4)> info = Nerves.Runtime.KV.get_all
+  %{
+    "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+    "a.nerves_fw_application_part0_fstype" => "ext4",
+    "a.nerves_fw_application_part0_target" => "/root",
+    "a.nerves_fw_architecture" => "arm",
+    "a.nerves_fw_author" => "The Nerves Team",
+    "a.nerves_fw_description" => "",
+    "a.nerves_fw_misc" => "",
+    "a.nerves_fw_platform" => "rpi3a",
+    "a.nerves_fw_product" => "zero2wledota",
+    "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+    "a.nerves_fw_vcs_identifier" => "",
+    "a.nerves_fw_version" => "0.1.0",
+    "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+    "b.nerves_fw_application_part0_fstype" => "ext4",
+    "b.nerves_fw_application_part0_target" => "/root",
+    "b.nerves_fw_architecture" => "arm",
+    "b.nerves_fw_author" => "The Nerves Team",
+    "b.nerves_fw_description" => "",
+    "b.nerves_fw_misc" => "",
+    "b.nerves_fw_platform" => "rpi3a",
+    "b.nerves_fw_product" => "zero2wledota",
+    "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+    "b.nerves_fw_vcs_identifier" => "",
+    "b.nerves_fw_version" => "0.1.0",
+    "nerves_fw_active" => "a",
+    "nerves_fw_devpath" => "/dev/mmcblk0",
+    "nerves_serial_number" => ""
+  }
+
+  payload will be in README.md
+  """
+
+  def get_device_info(_device_id) do
+    # Call api for device id and get it's information
+
+    # Information example
+    api_response = %{
+      "a.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      "a.nerves_fw_application_part0_fstype" => "ext4",
+      "a.nerves_fw_application_part0_target" => "/root",
+      "a.nerves_fw_architecture" => "arm",
+      "a.nerves_fw_author" => "The Nerves Team",
+      "a.nerves_fw_description" => "",
+      "a.nerves_fw_misc" => "",
+      "a.nerves_fw_platform" => "rpi3a",
+      "a.nerves_fw_product" => "zero2wledota",
+      "a.nerves_fw_uuid" => "0129700c-66bf-5097-7725-014698e9c855",
+      "a.nerves_fw_vcs_identifier" => "",
+      "a.nerves_fw_version" => "0.1.0",
+      "b.nerves_fw_application_part0_devpath" => "/dev/mmcblk0p3",
+      "b.nerves_fw_application_part0_fstype" => "ext4",
+      "b.nerves_fw_application_part0_target" => "/root",
+      "b.nerves_fw_architecture" => "arm",
+      "b.nerves_fw_author" => "The Nerves Team",
+      "b.nerves_fw_description" => "",
+      "b.nerves_fw_misc" => "",
+      "b.nerves_fw_platform" => "rpi3a",
+      "b.nerves_fw_product" => "zero2wledota",
+      "b.nerves_fw_uuid" => "650297be-77f1-5e16-64bc-6828169c39ba",
+      "b.nerves_fw_vcs_identifier" => "",
+      "b.nerves_fw_version" => "0.1.0",
+      "nerves_fw_active" => "a",
+      "nerves_fw_devpath" => "/dev/mmcblk0",
+      "nerves_serial_number" => ""
+    }
+
+    api_response
+  end
+end
diff --git a/lib/nemo_cloud_services/utils/devices_update.ex b/lib/nemo_cloud_services/utils/devices_update.ex
new file mode 100644
index 0000000000000000000000000000000000000000..131b85b0b6dc64eb83f3bba5ff167f5c3bc76ed1
--- /dev/null
+++ b/lib/nemo_cloud_services/utils/devices_update.ex
@@ -0,0 +1,5 @@
+defmodule NemoCloudServices.Utils.DevicesUpdate do
+  def trigger_update(device_id, firmware) do
+    NemoCloudServices.Conn.MqttClient.send_firmware_change(device_id, firmware)
+  end
+end
diff --git a/lib/nemo_cloud_services/utils/logs_loger.ex b/lib/nemo_cloud_services/utils/logs_loger.ex
new file mode 100644
index 0000000000000000000000000000000000000000..e3767e97cedc268ae6ba5a9ee840a4d38a3664a3
--- /dev/null
+++ b/lib/nemo_cloud_services/utils/logs_loger.ex
@@ -0,0 +1,11 @@
+defmodule NemoCloudServices.Utils.LogsLoger do
+  alias NemoCloudServices.Logs
+
+  def write(message, type, device) do
+    Logs.create_message(%{message: message, type: type, device: device})
+  end
+
+  def get_all() do
+    Logs.list_messages()
+  end
+end
diff --git a/lib/nemo_cloud_services/utils/mqtt_utils.ex b/lib/nemo_cloud_services/utils/mqtt_utils.ex
new file mode 100644
index 0000000000000000000000000000000000000000..793406f4d4e63b024a8551c851dad103856847a8
--- /dev/null
+++ b/lib/nemo_cloud_services/utils/mqtt_utils.ex
@@ -0,0 +1,51 @@
+defmodule NemoCloudServices.Utils.MqttUtils do
+  @moduledoc """
+  MQTT utility functions
+
+  This module contains utility functions for working with MQTT.
+  """
+
+  @doc """
+  Based on the activity in nemo/pilot/clients topic create and keep the state of available nemo clients
+
+  This list_available_clients function is used to create and keep the state of available nemo clients.
+
+  State of the clients should be keept in the ETS table :available_clients.
+
+  ## Example of payload in above topic
+
+  ### Connected client
+  ```
+  {
+  "clientid": "nemo_gw_1",
+  "connected_at": 1730184892658,
+  "disconnected_at": undefined
+  }
+  ```
+
+  ### Disconnected client
+  ```
+  {
+  "clientid": "nemo_gw_1",
+  "connected_at": undefined,
+  "disconnected_at": 1730184892700
+  }
+  ```
+  """
+  require Logger
+
+  def list_available_clients do
+  end
+
+  # Function to remove a specific device from the active devices list
+  def remove_active_device(clientid) do
+    if :ets.lookup(:active_devices, clientid) != [] do
+      :ets.delete(:active_devices, clientid)
+      Logger.info("Device #{clientid} removed from active devices.")
+      :ok
+    else
+      Logger.warning("Device #{clientid} not found in active devices.")
+      {:error, :not_found}
+    end
+  end
+end
diff --git a/lib/nemo_cloud_services/utils/workbook_loger.ex b/lib/nemo_cloud_services/utils/workbook_loger.ex
new file mode 100644
index 0000000000000000000000000000000000000000..843ac219fde6b8a3aaf952afd2dd59b15b2f2697
--- /dev/null
+++ b/lib/nemo_cloud_services/utils/workbook_loger.ex
@@ -0,0 +1,11 @@
+defmodule NemoCloudServices.Utils.WorkbookLoger do
+  alias NemoCloudServices.Workbook
+
+  def write(message, user) do
+    Workbook.create_message(%{message: message, user: user})
+  end
+
+  def get_all() do
+    Workbook.list_messages()
+  end
+end
diff --git a/lib/nemo_cloud_services/workbook.ex b/lib/nemo_cloud_services/workbook.ex
new file mode 100644
index 0000000000000000000000000000000000000000..de02ed52d09a23390053e37679b8e985698da539
--- /dev/null
+++ b/lib/nemo_cloud_services/workbook.ex
@@ -0,0 +1,26 @@
+defmodule NemoCloudServices.Workbook do
+  @moduledoc """
+  The LWorkbookogs context.
+  """
+
+  import Ecto.Query, warn: false
+  alias NemoCloudServices.Repo
+
+  alias NemoCloudServices.Workbook.Workbook
+
+  def list_messages do
+    Repo.all(Workbook)
+  end
+
+  def get_message!(id), do: Repo.get!(Workbook, id)
+
+  def create_message(attrs \\ %{}) do
+    %Workbook{}
+    |> Workbook.changeset(attrs)
+    |> Repo.insert()
+  end
+
+  def delete_message(%Workbook{} = workbook) do
+    Repo.delete(workbook)
+  end
+end
diff --git a/lib/nemo_cloud_services/workbook/workbook.ex b/lib/nemo_cloud_services/workbook/workbook.ex
new file mode 100644
index 0000000000000000000000000000000000000000..defa3ef64b49534ee84bd2d3f577cc81fade7d2b
--- /dev/null
+++ b/lib/nemo_cloud_services/workbook/workbook.ex
@@ -0,0 +1,18 @@
+defmodule NemoCloudServices.Workbook.Workbook do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  schema "workbook" do
+    field :message, :string
+    field :user, :string
+
+    timestamps(type: :utc_datetime)
+  end
+
+  @doc false
+  def changeset(workbook, attrs) do
+    workbook
+    |> cast(attrs, [:message, :user])
+    |> validate_required([:message, :user])
+  end
+end
diff --git a/lib/nemo_cloud_services_web.ex b/lib/nemo_cloud_services_web.ex
new file mode 100644
index 0000000000000000000000000000000000000000..5a404193b4851c503207174a0ddbf7d82a850229
--- /dev/null
+++ b/lib/nemo_cloud_services_web.ex
@@ -0,0 +1,113 @@
+defmodule NemoCloudServicesWeb do
+  @moduledoc """
+  The entrypoint for defining your web interface, such
+  as controllers, components, channels, and so on.
+
+  This can be used in your application as:
+
+      use NemoCloudServicesWeb, :controller
+      use NemoCloudServicesWeb, :html
+
+  The definitions below will be executed for every controller,
+  component, etc, so keep them short and clean, focused
+  on imports, uses and aliases.
+
+  Do NOT define functions inside the quoted expressions
+  below. Instead, define additional modules and import
+  those modules here.
+  """
+
+  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+  def router do
+    quote do
+      use Phoenix.Router, helpers: false
+
+      # Import common connection and controller functions to use in pipelines
+      import Plug.Conn
+      import Phoenix.Controller
+      import Phoenix.LiveView.Router
+    end
+  end
+
+  def channel do
+    quote do
+      use Phoenix.Channel
+    end
+  end
+
+  def controller do
+    quote do
+      use Phoenix.Controller,
+        formats: [:html, :json],
+        layouts: [html: NemoCloudServicesWeb.Layouts]
+
+      import Plug.Conn
+      import NemoCloudServicesWeb.Gettext
+
+      unquote(verified_routes())
+    end
+  end
+
+  def live_view do
+    quote do
+      use Phoenix.LiveView,
+        layout: {NemoCloudServicesWeb.Layouts, :app}
+
+      unquote(html_helpers())
+    end
+  end
+
+  def live_component do
+    quote do
+      use Phoenix.LiveComponent
+
+      unquote(html_helpers())
+    end
+  end
+
+  def html do
+    quote do
+      use Phoenix.Component
+
+      # Import convenience functions from controllers
+      import Phoenix.Controller,
+        only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+      # Include general helpers for rendering HTML
+      unquote(html_helpers())
+    end
+  end
+
+  defp html_helpers do
+    quote do
+      # HTML escaping functionality
+      import Phoenix.HTML
+      # Core UI components and translation
+      import NemoCloudServicesWeb.CoreComponents
+      import NemoCloudServicesWeb.Gettext
+
+      # Shortcut for generating JS commands
+      alias Phoenix.LiveView.JS
+
+      # Routes generation with the ~p sigil
+      unquote(verified_routes())
+    end
+  end
+
+  def verified_routes do
+    quote do
+      use Phoenix.VerifiedRoutes,
+        endpoint: NemoCloudServicesWeb.Endpoint,
+        router: NemoCloudServicesWeb.Router,
+        statics: NemoCloudServicesWeb.static_paths()
+    end
+  end
+
+  @doc """
+  When used, dispatch to the appropriate controller/live_view/etc.
+  """
+  defmacro __using__(which) when is_atom(which) do
+    apply(__MODULE__, which, [])
+  end
+end
diff --git a/lib/nemo_cloud_services_web/api_spec.ex b/lib/nemo_cloud_services_web/api_spec.ex
new file mode 100644
index 0000000000000000000000000000000000000000..c44f069afeddfbc50c967a76507f687d6f463614
--- /dev/null
+++ b/lib/nemo_cloud_services_web/api_spec.ex
@@ -0,0 +1,22 @@
+defmodule NemoCloudServicesWeb.ApiSpec do
+  alias OpenApiSpex.{Info, OpenApi, Paths, Server}
+  alias NemoCloudServicesWeb.{Endpoint, Router}
+  @behaviour OpenApi
+
+  @impl OpenApi
+  def spec do
+    %OpenApi{
+      servers: [
+        # Populate the Server info from a phoenix endpoint
+        Server.from_endpoint(Endpoint)
+      ],
+      info: %Info{
+        title: "Nemo APP",
+        version: "1.0"
+      },
+      # Populate the paths from a phoenix router
+      paths: Paths.from_router(Router)
+    }
+    |> OpenApiSpex.resolve_schema_modules() # Discover request/response schemas from path specs
+  end
+end
diff --git a/lib/nemo_cloud_services_web/components/core_components.ex b/lib/nemo_cloud_services_web/components/core_components.ex
new file mode 100644
index 0000000000000000000000000000000000000000..12061664d396dae87a8f5da38e8928a81a7f1b6e
--- /dev/null
+++ b/lib/nemo_cloud_services_web/components/core_components.ex
@@ -0,0 +1,681 @@
+defmodule NemoCloudServicesWeb.CoreComponents do
+  @moduledoc """
+  Provides core UI components.
+
+  At first glance, this module may seem daunting, but its goal is to provide
+  core building blocks for your application, such as modals, tables, and
+  forms. The components consist mostly of markup and are well-documented
+  with doc strings and declarative assigns. You may customize and style
+  them in any way you want, based on your application growth and needs.
+
+  The default components use Tailwind CSS, a utility-first CSS framework.
+  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+  how to customize them or feel free to swap in another framework altogether.
+
+  Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+  """
+  use Phoenix.Component
+
+  alias Phoenix.LiveView.JS
+  # import NemoCloudServicesWeb.Gettext
+  use Gettext, backend: NemoCloudServicesWeb.Gettext
+
+  @doc """
+  Renders a modal.
+
+  ## Examples
+
+      <.modal id="confirm-modal">
+        This is a modal.
+      </.modal>
+
+  JS commands may be passed to the `:on_cancel` to configure
+  the closing/cancel event, for example:
+
+      <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+        This is another modal.
+      </.modal>
+
+  """
+  attr :id, :string, required: true
+  attr :show, :boolean, default: false
+  attr :on_cancel, JS, default: %JS{}
+  slot :inner_block, required: true
+
+  def modal(assigns) do
+    ~H"""
+    <div
+      id={@id}
+      phx-mounted={@show && show_modal(@id)}
+      phx-remove={hide_modal(@id)}
+      data-cancel={JS.exec(@on_cancel, "phx-remove")}
+      class="relative z-50 hidden"
+    >
+      <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
+      <div
+        class="fixed inset-0 overflow-y-auto"
+        aria-labelledby={"#{@id}-title"}
+        aria-describedby={"#{@id}-description"}
+        role="dialog"
+        aria-modal="true"
+        tabindex="0"
+      >
+        <div class="flex min-h-full items-center justify-center">
+          <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
+            <.focus_wrap
+              id={"#{@id}-container"}
+              phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
+              phx-key="escape"
+              phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
+              class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
+            >
+              <div class="absolute top-6 right-5">
+                <button
+                  phx-click={JS.exec("data-cancel", to: "##{@id}")}
+                  type="button"
+                  class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
+                  aria-label={gettext("close")}
+                >
+                  <.icon name="hero-x-mark-solid" class="h-5 w-5" />
+                </button>
+              </div>
+              <div id={"#{@id}-content"}>
+                <%= render_slot(@inner_block) %>
+              </div>
+            </.focus_wrap>
+          </div>
+        </div>
+      </div>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders flash notices.
+
+  ## Examples
+
+      <.flash kind={:info} flash={@flash} />
+      <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
+  """
+  attr :id, :string, doc: "the optional id of flash container"
+  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+  attr :title, :string, default: nil
+  attr :kind, :atom, values: [:info, :error, :updating], doc: "used for styling and flash lookup"
+  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+  slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+  def flash(assigns) do
+    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+    ~H"""
+    <div
+      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
+      id={@id}
+      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
+      phx-hook="FlashMessage"
+      role="alert"
+      class={[
+        "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
+        @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
+        @kind == :updating && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
+        @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+      ]}
+      {@rest}
+    >
+      <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
+        <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
+        <.icon :if={@kind == :updating} name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+        <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
+        <%= @title %>
+      </p>
+      <p class="mt-2 text-sm leading-5"><%= msg %></p>
+      <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
+        <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
+      </button>
+    </div>
+    """
+  end
+
+  @doc """
+  Shows the flash group with standard titles and content.
+
+  ## Examples
+
+      <.flash_group flash={@flash} />
+  """
+  attr :flash, :map, required: true, doc: "the map of flash messages"
+  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+  def flash_group(assigns) do
+    ~H"""
+    <div id={@id}>
+      <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
+      <.flash kind={:error} title={gettext("Error!")} flash={@flash} />
+      <.flash kind={:updating} title={gettext("Updating")} flash={@flash} />
+      <.flash
+        id="client-error"
+        kind={:error}
+        title={gettext("We can't find the internet")}
+        phx-disconnected={show(".phx-client-error #client-error")}
+        phx-connected={hide("#client-error")}
+        hidden
+      >
+        <%= gettext("Attempting to reconnect") %>
+        <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+      </.flash>
+
+      <.flash
+        id="server-error"
+        kind={:error}
+        title={gettext("Something went wrong!")}
+        phx-disconnected={show(".phx-server-error #server-error")}
+        phx-connected={hide("#server-error")}
+        hidden
+      >
+        <%= gettext("Hang in there while we get back on track") %>
+        <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+      </.flash>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a simple form.
+
+  ## Examples
+
+      <.simple_form for={@form} phx-change="validate" phx-submit="save">
+        <.input field={@form[:email]} label="Email"/>
+        <.input field={@form[:username]} label="Username" />
+        <:actions>
+          <.button>Save</.button>
+        </:actions>
+      </.simple_form>
+  """
+  attr :for, :any, required: true, doc: "the data structure for the form"
+  attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
+
+  attr :rest, :global,
+    include: ~w(autocomplete name rel action enctype method novalidate target multipart),
+    doc: "the arbitrary HTML attributes to apply to the form tag"
+
+  slot :inner_block, required: true
+  slot :actions, doc: "the slot for form actions, such as a submit button"
+
+  def simple_form(assigns) do
+    ~H"""
+    <.form :let={f} for={@for} as={@as} {@rest}>
+      <div class="mt-10 space-y-8 bg-white">
+        <%= render_slot(@inner_block, f) %>
+        <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
+          <%= render_slot(action, f) %>
+        </div>
+      </div>
+    </.form>
+    """
+  end
+
+  @doc """
+  Renders a button.
+
+  ## Examples
+
+      <.button>Send!</.button>
+      <.button phx-click="go" class="ml-2">Send!</.button>
+  """
+  attr :type, :string, default: nil
+  attr :class, :string, default: nil
+  attr :rest, :global, include: ~w(disabled form name value)
+
+  slot :inner_block, required: true
+
+  def button(assigns) do
+    ~H"""
+    <button
+      type={@type}
+      class={[
+        "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
+        "text-sm font-semibold leading-6 text-white active:text-white/80",
+        @class
+      ]}
+      {@rest}
+    >
+      <%= render_slot(@inner_block) %>
+    </button>
+    """
+  end
+
+  @doc """
+  Renders an input with label and error messages.
+
+  A `Phoenix.HTML.FormField` may be passed as argument,
+  which is used to retrieve the input name, id, and values.
+  Otherwise all attributes may be passed explicitly.
+
+  ## Types
+
+  This function accepts all HTML input types, considering that:
+
+    * You may also set `type="select"` to render a `<select>` tag
+
+    * `type="checkbox"` is used exclusively to render boolean values
+
+    * For live file uploads, see `Phoenix.Component.live_file_input/1`
+
+  See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
+  for more information. Unsupported types, such as hidden and radio,
+  are best written directly in your templates.
+
+  ## Examples
+
+      <.input field={@form[:email]} type="email" />
+      <.input name="my-input" errors={["oh no!"]} />
+  """
+  attr :id, :any, default: nil
+  attr :name, :any
+  attr :label, :string, default: nil
+  attr :value, :any
+
+  attr :type, :string,
+    default: "text",
+    values: ~w(checkbox color date datetime-local email file month number password
+               range search select tel text textarea time url week)
+
+  attr :field, Phoenix.HTML.FormField,
+    doc: "a form field struct retrieved from the form, for example: @form[:email]"
+
+  attr :errors, :list, default: []
+  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
+  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
+  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
+  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
+
+  attr :rest, :global,
+    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
+                multiple pattern placeholder readonly required rows size step)
+
+  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
+    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
+
+    assigns
+    |> assign(field: nil, id: assigns.id || field.id)
+    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
+    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
+    |> assign_new(:value, fn -> field.value end)
+    |> input()
+  end
+
+  def input(%{type: "checkbox"} = assigns) do
+    assigns =
+      assign_new(assigns, :checked, fn ->
+        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+      end)
+
+    ~H"""
+    <div>
+      <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
+        <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
+        <input
+          type="checkbox"
+          id={@id}
+          name={@name}
+          value="true"
+          checked={@checked}
+          class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
+          {@rest}
+        />
+        <%= @label %>
+      </label>
+      <.error :for={msg <- @errors}><%= msg %></.error>
+    </div>
+    """
+  end
+
+  def input(%{type: "select"} = assigns) do
+    ~H"""
+    <div>
+      <.label for={@id}><%= @label %></.label>
+      <select
+        id={@id}
+        name={@name}
+        class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
+        multiple={@multiple}
+        {@rest}
+      >
+        <option :if={@prompt} value=""><%= @prompt %></option>
+        <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
+      </select>
+      <.error :for={msg <- @errors}><%= msg %></.error>
+    </div>
+    """
+  end
+
+  def input(%{type: "textarea"} = assigns) do
+    ~H"""
+    <div>
+      <.label for={@id}><%= @label %></.label>
+      <textarea
+        id={@id}
+        name={@name}
+        class={[
+          "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
+          @errors == [] && "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
+        ]}
+        {@rest}
+      ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
+      <.error :for={msg <- @errors}><%= msg %></.error>
+    </div>
+    """
+  end
+
+  # All other inputs text, datetime-local, url, password, etc. are handled here...
+  def input(assigns) do
+    ~H"""
+    <div>
+      <.label for={@id}><%= @label %></.label>
+      <input
+        type={@type}
+        name={@name}
+        id={@id}
+        value={Phoenix.HTML.Form.normalize_value(@type, @value)}
+        class={[
+          "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
+          @errors == [] && "border-zinc-300 focus:border-zinc-400",
+          @errors != [] && "border-rose-400 focus:border-rose-400"
+        ]}
+        {@rest}
+      />
+      <.error :for={msg <- @errors}><%= msg %></.error>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a label.
+  """
+  attr :for, :string, default: nil
+  slot :inner_block, required: true
+
+  def label(assigns) do
+    ~H"""
+    <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
+      <%= render_slot(@inner_block) %>
+    </label>
+    """
+  end
+
+  @doc """
+  Generates a generic error message.
+  """
+  slot :inner_block, required: true
+
+  def error(assigns) do
+    ~H"""
+    <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
+      <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
+      <%= render_slot(@inner_block) %>
+    </p>
+    """
+  end
+
+  @doc """
+  Renders a header with title.
+  """
+  attr :class, :string, default: nil
+
+  slot :inner_block, required: true
+  slot :subtitle
+  slot :actions
+
+  def header(assigns) do
+    ~H"""
+    <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
+      <div>
+        <h1 class="text-lg font-semibold leading-8 text-zinc-800">
+          <%= render_slot(@inner_block) %>
+        </h1>
+        <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
+          <%= render_slot(@subtitle) %>
+        </p>
+      </div>
+      <div class="flex-none"><%= render_slot(@actions) %></div>
+    </header>
+    """
+  end
+
+  @doc ~S"""
+  Renders a table with generic styling.
+
+  ## Examples
+
+      <.table id="users" rows={@users}>
+        <:col :let={user} label="id"><%= user.id %></:col>
+        <:col :let={user} label="username"><%= user.username %></:col>
+      </.table>
+  """
+  attr :id, :string, required: true
+  attr :rows, :list, required: true
+  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
+  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
+
+  attr :row_item, :any,
+    default: &Function.identity/1,
+    doc: "the function for mapping each row before calling the :col and :action slots"
+
+  slot :col, required: true do
+    attr :label, :string
+  end
+
+  slot :action, doc: "the slot for showing user actions in the last table column"
+
+  def table(assigns) do
+    assigns =
+      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
+        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
+      end
+
+    ~H"""
+    <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
+      <table class="w-[40rem] mt-11 sm:w-full">
+        <thead class="text-sm text-left leading-6 text-zinc-500">
+          <tr>
+            <th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
+            <th :if={@action != []} class="relative p-0 pb-4">
+              <span class="sr-only"><%= gettext("Actions") %></span>
+            </th>
+          </tr>
+        </thead>
+        <tbody
+          id={@id}
+          phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
+          class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
+        >
+          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
+            <td
+              :for={{col, i} <- Enum.with_index(@col)}
+              phx-click={@row_click && @row_click.(row)}
+              class={["relative p-0", @row_click && "hover:cursor-pointer"]}
+            >
+              <div class="block py-4 pr-6">
+                <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
+                <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
+                  <%= render_slot(col, @row_item.(row)) %>
+                </span>
+              </div>
+            </td>
+            <td :if={@action != []} class="relative w-14 p-0">
+              <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
+                <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
+                <span
+                  :for={action <- @action}
+                  class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
+                >
+                  <%= render_slot(action, @row_item.(row)) %>
+                </span>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a data list.
+
+  ## Examples
+
+      <.list>
+        <:item title="Title"><%= @post.title %></:item>
+        <:item title="Views"><%= @post.views %></:item>
+      </.list>
+  """
+  slot :item, required: true do
+    attr :title, :string, required: true
+  end
+
+  def list(assigns) do
+    ~H"""
+    <div class="mt-14">
+      <dl class="-my-4 divide-y divide-zinc-100">
+        <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
+          <dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
+          <dd class="text-zinc-700"><%= render_slot(item) %></dd>
+        </div>
+      </dl>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a back navigation link.
+
+  ## Examples
+
+      <.back navigate={~p"/posts"}>Back to posts</.back>
+  """
+  attr :navigate, :any, required: true
+  slot :inner_block, required: true
+
+  def back(assigns) do
+    ~H"""
+    <div class="mt-16">
+      <.link
+        navigate={@navigate}
+        class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
+      >
+        <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
+        <%= render_slot(@inner_block) %>
+      </.link>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a [Heroicon](https://heroicons.com).
+
+  Heroicons come in three styles – outline, solid, and mini.
+  By default, the outline style is used, but solid and mini may
+  be applied by using the `-solid` and `-mini` suffix.
+
+  You can customize the size and colors of the icons by setting
+  width, height, and background color classes.
+
+  Icons are extracted from the `deps/heroicons` directory and bundled within
+  your compiled app.css by the plugin in your `assets/tailwind.config.js`.
+
+  ## Examples
+
+      <.icon name="hero-x-mark-solid" />
+      <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
+  """
+  attr :name, :string, required: true
+  attr :class, :string, default: nil
+
+  def icon(%{name: "hero-" <> _} = assigns) do
+    ~H"""
+    <span class={[@name, @class]} />
+    """
+  end
+
+  ## JS Commands
+
+  def show(js \\ %JS{}, selector) do
+    JS.show(js,
+      to: selector,
+      time: 300,
+      transition:
+        {"transition-all transform ease-out duration-300",
+         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+         "opacity-100 translate-y-0 sm:scale-100"}
+    )
+  end
+
+  def hide(js \\ %JS{}, selector) do
+    JS.hide(js,
+      to: selector,
+      time: 200,
+      transition:
+        {"transition-all transform ease-in duration-200",
+         "opacity-100 translate-y-0 sm:scale-100",
+         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+    )
+  end
+
+  def show_modal(js \\ %JS{}, id) when is_binary(id) do
+    js
+    |> JS.show(to: "##{id}")
+    |> JS.show(
+      to: "##{id}-bg",
+      time: 300,
+      transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
+    )
+    |> show("##{id}-container")
+    |> JS.add_class("overflow-hidden", to: "body")
+    |> JS.focus_first(to: "##{id}-content")
+  end
+
+  def hide_modal(js \\ %JS{}, id) do
+    js
+    |> JS.hide(
+      to: "##{id}-bg",
+      transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
+    )
+    |> hide("##{id}-container")
+    |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
+    |> JS.remove_class("overflow-hidden", to: "body")
+    |> JS.pop_focus()
+  end
+
+  @doc """
+  Translates an error message using gettext.
+  """
+  def translate_error({msg, opts}) do
+    # When using gettext, we typically pass the strings we want
+    # to translate as a static argument:
+    #
+    #     # Translate the number of files with plural rules
+    #     dngettext("errors", "1 file", "%{count} files", count)
+    #
+    # However the error messages in our forms and APIs are generated
+    # dynamically, so we need to translate them by calling Gettext
+    # with our gettext backend as first argument. Translations are
+    # available in the errors.po file (as we use the "errors" domain).
+    if count = opts[:count] do
+      Gettext.dngettext(NemoCloudServicesWeb.Gettext, "errors", msg, msg, count, opts)
+    else
+      Gettext.dgettext(NemoCloudServicesWeb.Gettext, "errors", msg, opts)
+    end
+  end
+
+  @doc """
+  Translates the errors for a field from a keyword list of errors.
+  """
+  def translate_errors(errors, field) when is_list(errors) do
+    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+  end
+end
diff --git a/lib/nemo_cloud_services_web/components/custom_components.ex b/lib/nemo_cloud_services_web/components/custom_components.ex
new file mode 100644
index 0000000000000000000000000000000000000000..b3ece9833d9ef881de6dcde431d5e373a19ced86
--- /dev/null
+++ b/lib/nemo_cloud_services_web/components/custom_components.ex
@@ -0,0 +1,546 @@
+defmodule NemoCloudServicesWeb.CustomComponents do
+  use Phoenix.Component
+
+  attr :number_of_firmwares, :string, required: false, doc: "Number of uploaded firmwares"
+  def topbar(assigns) do
+    ~H"""
+      <nav class="fixed top-0 z-40 w-full border-b border-gray-200 bg-gray-800 border-gray-700">
+        <div class="px-3 py-3 lg:px-5 lg:pl-3">
+          <div class="flex items-center justify-between">
+            <div class="flex items-center justify-start rtl:justify-end">
+              <button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar" type="button" class="inline-flex items-center p-2 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
+                  <span class="sr-only">Open sidebar</span>
+                  <svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
+                    <path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
+                  </svg>
+              </button>
+              <a href="https://flowbite.com" class="flex ms-2 md:me-24">
+                <img src="https://flowbite.com/docs/images/logo.svg" class="h-8 me-3" alt="FlowBite Logo" />
+                <span class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap text-white">OTA Update</span>
+              </a>
+            </div>
+            <div class="flex items-center">
+                <div class="flex items-center ms-3">
+                  <div>
+                    <button type="button" class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" aria-expanded="false" data-dropdown-toggle="dropdown-user">
+                      <span class="sr-only">Open user menu</span>
+                      <img class="w-8 h-8 rounded-full" src="https://flowbite.com/docs/images/people/profile-picture-5.jpg" alt="user photo">
+                    </button>
+                  </div>
+                  <div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600" id="dropdown-user">
+                    <div class="px-4 py-3" role="none">
+                      <p class="text-sm text-gray-900 dark:text-white" role="none">
+                        Neil Sims
+                      </p>
+                      <p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
+                        neil.sims@flowbite.com
+                      </p>
+                    </div>
+                    <ul class="py-1" role="none">
+                      <li>
+                        <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white" role="menuitem">Sign out</a>
+                      </li>
+                    </ul>
+                  </div>
+                </div>
+              </div>
+          </div>
+        </div>
+      </nav>
+    """
+  end
+
+  attr :number_of_firmwares, :string, required: true, doc: "Number of uploaded firmwares"
+  def sidebar(assigns) do
+    ~H"""
+      <aside id="logo-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen mt-14 transition-transform -translate-x-full bg-white border-r border-gray-200 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700" aria-label="Sidebar">
+        <div class="h-full px-3 pt-6 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
+            <ul class="space-y-2 font-medium">
+              <li>
+                  <a phx-click="change_view" phx-value-button="dashboard" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
+                    <svg class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 22 21">
+                        <path d="M16.975 11H10V4.025a1 1 0 0 0-1.066-.998 8.5 8.5 0 1 0 9.039 9.039.999.999 0 0 0-1-1.066h.002Z"/>
+                        <path d="M12.5 0c-.157 0-.311.01-.565.027A1 1 0 0 0 11 1.02V10h8.975a1 1 0 0 0 1-.935c.013-.188.028-.374.028-.565A8.51 8.51 0 0 0 12.5 0Z"/>
+                    </svg>
+                    <span class="ms-3">Dashboard</span>
+                  </a>
+              </li>
+              <li>
+                  <a phx-click="change_view" phx-value-button="firmwares" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
+                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="flex-shrink-0 w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
+                      <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
+                    </svg>
+                    <span class="flex-1 ms-3 whitespace-nowrap">Firmwares</span>
+                    <span class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300"><%= @number_of_firmwares %></span>
+                  </a>
+              </li>
+              <li>
+                  <a phx-click="change_view" phx-value-button="bulk_update" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
+                    <svg class="flex-shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 18">
+                        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 1v11m0 0 4-4m-4 4L4 8m11 4v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3"></path>
+                    </svg>
+                    <span class="flex-1 ms-3 whitespace-nowrap">Bulk update</span>
+                  </a>
+              </li>
+              <li>
+                  <a phx-click="change_view" phx-value-button="logs" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
+                    <svg class="flex-shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 19 20">
+                        <path d="M16.025 15H14.91c.058-.33.088-.665.09-1v-1h2a1 1 0 0 0 0-2h-2.09a5.97 5.97 0 0 0-.26-1h.375a2 2 0 0 0 2-2V6a1 1 0 0 0-2 0v2H13.46a6.239 6.239 0 0 0-.46-.46V6a3.963 3.963 0 0 0-.986-2.6l.693-.693A1 1 0 0 0 13 2V1a1 1 0 0 0-2 0v.586l-.661.661a3.753 3.753 0 0 0-2.678 0L7 1.586V1a1 1 0 0 0-2 0v1a1 1 0 0 0 .293.707l.693.693A3.963 3.963 0 0 0 5 6v1.54a6.239 6.239 0 0 0-.46.46H3V6a1 1 0 0 0-2 0v2a2 2 0 0 0 2 2h.35a5.97 5.97 0 0 0-.26 1H1a1 1 0 0 0 0 2h2v1a6 6 0 0 0 .09 1H2a2 2 0 0 0-2 2v2a1 1 0 1 0 2 0v-2h1.812A6.012 6.012 0 0 0 8 19.907V10a1 1 0 0 1 2 0v9.907A6.011 6.011 0 0 0 14.188 17h1.837v2a1 1 0 0 0 2 0v-2a2 2 0 0 0-2-2ZM11 6.35a5.922 5.922 0 0 0-.941-.251l-.111-.017a5.52 5.52 0 0 0-1.9 0l-.111.017A5.924 5.924 0 0 0 7 6.35V6a2 2 0 1 1 4 0v.35Z"></path>
+                    </svg>
+                    <span class="flex-1 ms-3 whitespace-nowrap">Logs</span>
+                  </a>
+              </li>
+              <li>
+                  <a phx-click="change_view" phx-value-button="workbook" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
+                    <svg class="flex-shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
+                        <path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.96 2.96 0 0 0 .13 5H5Z"/>
+                        <path d="M6.737 11.061a2.961 2.961 0 0 1 .81-1.515l6.117-6.116A4.839 4.839 0 0 1 16 2.141V2a1.97 1.97 0 0 0-1.933-2H7v5a2 2 0 0 1-2 2H0v11a1.969 1.969 0 0 0 1.933 2h12.134A1.97 1.97 0 0 0 16 18v-3.093l-1.546 1.546c-.413.413-.94.695-1.513.81l-3.4.679a2.947 2.947 0 0 1-1.85-.227 2.96 2.96 0 0 1-1.635-3.257l.681-3.397Z"/>
+                        <path d="M8.961 16a.93.93 0 0 0 .189-.019l3.4-.679a.961.961 0 0 0 .49-.263l6.118-6.117a2.884 2.884 0 0 0-4.079-4.078l-6.117 6.117a.96.96 0 0 0-.263.491l-.679 3.4A.961.961 0 0 0 8.961 16Zm7.477-9.8a.958.958 0 0 1 .68-.281.961.961 0 0 1 .682 1.644l-.315.315-1.36-1.36.313-.318Zm-5.911 5.911 4.236-4.236 1.359 1.359-4.236 4.237-1.7.339.341-1.699Z"/>
+                    </svg>
+                    <span class="flex-1 ms-3 whitespace-nowrap">Workbook</span>
+                  </a>
+              </li>
+            </ul>
+        </div>
+      </aside>
+    """
+  end
+
+
+  attr :uploaded_files, :map, required: true, doc: "Number of uploaded firmwares"
+  attr :device_id, :string, required: true, doc: "Number of uploaded firmwares"
+  attr :selected_firmware, :string, required: true, doc: "Number of uploaded firmwares"
+  def update_modal(assigns) do
+    ~H"""
+      <div class="fixed inset-0 flex items-center justify-center z-50">
+        <div class="bg-white p-6 rounded-lg shadow-lg w-1/2">
+          <h2 class="text-lg font-bold mb-4">Device: <%= @device_id %></h2>
+          <h2 class="text-sm font-bold mb-4">Select firmware to update</h2>
+          <div class="flex items-center my-4 rounded overflow-scroll">
+            <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
+              <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
+                <tr>
+                    <th scope="col" class="px-6 py-3">
+
+                    </th>
+                    <th scope="col" class="px-6 py-3">
+                        File name
+                    </th>
+                    <th scope="col" class="px-6 py-3">
+                        File size
+                    </th>
+                    <th scope="col" class="px-6 py-3">
+                        File date
+                    </th>
+                </tr>
+              </thead>
+              <tbody>
+                <%= for file <- @uploaded_files do %>
+                  <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
+                      <td class="w-4 p-4">
+                          <div class="flex items-center">
+                              <input id={file.name} value="" type="radio" phx-value-filename={file.name} phx-click="selected_firmware" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
+                              <label for="checkbox-table-search-2" class="sr-only">checkbox</label>
+                          </div>
+                      </td>
+                      <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                        <%= file.name %>
+                      </th>
+                      <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                        <%= file.size %> kB
+                      </th>
+                      <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                        <%= file.upload_date %>
+                      </th>
+                  </tr>
+                <% end %>
+              </tbody>
+            </table>
+          </div>
+          <button class="mt-4 bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-lg" phx-click="close_update_modal">Close</button>
+          <%= if @device_id == "DT_nemo_1" or @device_id == "DT_nemo_2" do %>
+            <button class="mt-4 bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-lg" phx-click="fake_update_device" phx-value-id={@device_id} phx-value-firmware={@selected_firmware}>Update</button>
+          <% else %>
+            <button class="mt-4 bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-lg" phx-click="update_device" phx-value-id={@device_id} phx-value-firmware={@selected_firmware}>Update</button>
+          <% end %>
+        </div>
+        <div class="fixed inset-0 bg-black opacity-50 -z-10"></div>
+      </div>
+    """
+  end
+
+  attr :selected_device, :map, required: true, doc: "selected device for more info"
+  attr :last_data_dt_nemo_1, :string, required: true, doc: "last data"
+  attr :last_data_dt_nemo_2, :string, required: true, doc: "last data"
+  def info_modal(assigns) do
+    ~H"""
+      <div class="fixed inset-0 flex items-center justify-center z-50">
+        <div class="bg-white p-6 rounded-lg shadow-lg w-1/2">
+          <div class="flex w-full justify-between">
+            <div class="flex items-center">
+              <h2 class="text-lg font-bold">Device Details (ID: <%= @selected_device.name %>)</h2>
+              <%= if @selected_device.state == "active" do%>
+                <span class="inline-flex items-center bg-green-100 text-green-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-green-500 rounded-full"></span>
+                  Available
+                </span>
+              <% else %>
+                <span class="inline-flex items-center bg-green-100 text-green-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-green-500 rounded-full"></span>
+                  Unavalible
+                </span>
+              <% end %>
+            </div>
+            <button class="bg-red-500 hover:bg-red-600 text-white py-1 px-4 rounded-lg" phx-click="close_modal">X</button>
+          </div>
+
+          <div class="flex mb-4">
+            <p class="mr-3"><strong>Name:</strong> <%= @selected_device.name %></p>
+            <p class="mr-3"><strong>Active partition:</strong> <%= @selected_device.active_partition %></p>
+          </div>
+
+          <div class={"flex gap-4 #{if @selected_device.active_partition == "b" do "flex-row-reverse" end}"}>
+            <div class={"p-6 bg-gray-100 rounded-lg w-1/2 #{if @selected_device.active_partition == "b" do "opacity-50" end}"}>
+              <h2 class="text-lg font-bold mb-3">Partition A</h2>
+              <dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
+                <div class="flex flex-col">
+                    <dt class="mb-1 text-gray-500  dark:text-gray-400">Firmware version</dt>
+                    <dd class="font-semibold text-gray-900"><%= @selected_device.fw_filename %></dd>
+                </div>
+              </dl>
+            </div>
+
+            <div class={"p-6 bg-gray-100 rounded-lg w-1/2 #{if @selected_device.active_partition == "a" do "opacity-50" end}"}>
+              <h2 class="text-lg font-bold mb-3">Partition B</h2>
+              <dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
+                <div class="flex flex-col">
+                    <dt class="mb-1 text-gray-500  dark:text-gray-400">Firmware version</dt>
+                    <dd class="font-semibold text-gray-900"><%= @selected_device.fw_filename %></dd>
+                </div>
+              </dl>
+            </div>
+          </div>
+          <%= if @selected_device.name == "DT_nemo_2"  or  @selected_device.name == "DT_nemo_1" do%>
+            <div class="p-6 mt-4 bg-gray-100 rounded-lg w-full">
+              <div class="flex justify-between">
+                <h2 class="text-lg font-bold mb-3">Phasor data</h2>
+                <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-1 px-3 rounded-lg mr-2" phx-click="get_latest_data" phx-value-id={@selected_device.name}>Latest data</button>
+              </div>
+
+              <%= if @selected_device.name == "DT_nemo_1" do %>
+                <div class="grid grid-cols-3 gap-3 overflow-x-auto mb-6">
+                  <%= for phasor <- @last_data_dt_nemo_1[:phasors] do %>
+                    <div class="p-2 bg-gray-100 rounded-lg overflow-x-auto">
+                      <dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
+                        <div class="flex flex-col py-1">
+                            <dd class="font-semibold"><%= phasor[:name] %></dd>
+                        </div>
+                        <div class="flex divide-x divide-gray-200 d">
+                          <div class="flex flex-col pt-2">
+                              <dt class="mb-1 text-gray-500  dark:text-gray-400">Angle</dt>
+                              <dd class="font-semibold pr-6"><%= phasor[:angle] %></dd>
+                          </div>
+                          <div class="flex flex-col pt-2">
+                              <dt class="mb-1 text-gray-500  dark:text-gray-400 pl-3">Magnitude</dt>
+                              <dd class="font-semibold pl-3"><%= phasor[:magnitude] %></dd>
+                          </div>
+                        </div>
+                      </dl>
+                    </div>
+                  <% end %>
+                </div>
+              <% else %>
+                <div class="grid grid-cols-3 gap-3 overflow-x-auto mb-6">
+                  <%= for phasor <- @last_data_dt_nemo_2[:phasors] do %>
+                    <div class="p-2 bg-gray-100 rounded-lg overflow-x-auto">
+                      <dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
+                        <div class="flex flex-col py-1">
+                            <dd class="font-semibold"><%= phasor[:name] %></dd>
+                        </div>
+                        <div class="flex divide-x divide-gray-200 d">
+                          <div class="flex flex-col pt-2">
+                              <dt class="mb-1 text-gray-500  dark:text-gray-400">Angle</dt>
+                              <dd class="font-semibold pr-6"><%= phasor[:angle] %></dd>
+                          </div>
+                          <div class="flex flex-col pt-2">
+                              <dt class="mb-1 text-gray-500  dark:text-gray-400 pl-3">Magnitude</dt>
+                              <dd class="font-semibold pl-3"><%= phasor[:magnitude] %></dd>
+                          </div>
+                        </div>
+                      </dl>
+                    </div>
+                  <% end %>
+                </div>
+              <% end %>
+            </div>
+          <% end %>
+
+        </div>
+        <div class="fixed inset-0 bg-black opacity-50 -z-10"></div>
+      </div>
+    """
+  end
+
+  attr :value, :string, required: true, doc: "Main value in card"
+  attr :desc, :string, required: true, doc: "Description"
+  def card(assigns) do
+    ~H"""
+      <div class="max-w-sm w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6">
+        <div class="flex justify-between">
+          <div>
+            <h5 class="leading-none text-2xl font-bold text-gray-900 dark:text-white pb-2"><%= @value %></h5>
+            <p class="text-base font-normal text-gray-500 dark:text-gray-400"><%= @desc %></p>
+          </div>
+        </div>
+      </div>
+    """
+  end
+
+
+  attr :search_term, :string, required: true, doc: "search term"
+  def serch(assigns) do
+    ~H"""
+    <div class="relative">
+      <div class="absolute inset-y-0 left-0 rtl:inset-r-0 rtl:right-0 flex items-center ps-3 pointer-events-none">
+        <svg class="w-5 h-5 text-gray-500 dark:text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"></path></svg>
+      </div>
+      <form phx-change="search_device">
+      <input type="text" id="table-search" name="search_term"
+            class="block p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
+            placeholder="Search for devices" value={@search_term}>
+      </form>
+    </div>
+    """
+  end
+
+  attr :filter_online, :string, required: true, doc: "Filter online devices input"
+  def online_toggle(assigns) do
+    ~H"""
+    <div class="flex items-center ps-3">
+      <input id="online-checkbox" type="checkbox" phx-click="toggle_online_filter" checked={@filter_online} value="" class="w-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500">
+      <label for="online-checkbox" class="w-full py-3 ms-2 text-sm font-medium border-gray-300">Show only online devices</label>
+    </div>
+    """
+  end
+
+  attr :filter_type, :string, required: true, doc: "Filter type input"
+  def filter_form(assigns) do
+    ~H"""
+      <form phx-change="filter_type" class="w-1/2">
+        <ul class="items-center text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg sm:flex dark:bg-gray-700 dark:border-gray-600 dark:text-white">
+          <li class="w-full border-b border-gray-200 sm:border-b-0 sm:border-r dark:border-gray-600">
+              <div class="flex items-center ps-3">
+                  <input id="gw-checkbox-list" type="checkbox" name="filter_type[]" value="GW" phx-change="filter_type" checked={if "GW" in @filter_type, do: "checked", else: nil} class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500">
+                  <label for="gw-checkbox-list" class="w-full py-3 ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">GW</label>
+              </div>
+          </li>
+          <li class="w-full border-b border-gray-200 sm:border-b-0 sm:border-r dark:border-gray-600">
+              <div class="flex items-center ps-3">
+                  <input id="asm-checkbox-list" type="checkbox" name="filter_type[]" value="ASM" phx-change="filter_type" checked={if "ASM" in @filter_type, do: "checked", else: nil} class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500">
+                  <label for="asm-checkbox-list" class="w-full py-3 ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">ASM</label>
+              </div>
+          </li>
+        </ul>
+      </form>
+    """
+  end
+
+  attr :devices, :map, required: true, doc: "Filter type input"
+  attr :filter_online, :string, required: true, doc: "Filter type input"
+  attr :filter_type, :string, required: true, doc: "Filter type input"
+  attr :search_term, :map, required: true, doc: "Filter type input"
+  def device_table(assigns) do
+    ~H"""
+      <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
+        <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
+          <tr>
+              <th scope="col" class="px-6 py-3">
+                  Device name
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  State
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Version
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Partition
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  SSH
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Action
+              </th>
+          </tr>
+        </thead>
+        <tbody>
+          <%= for device <- filter_devices(@devices, @filter_online, @filter_type, @search_term) do %>
+            <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
+                <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                  <%= device.name %>
+                </th>
+                <td class="px-6 py-4">
+                  <%= if device.state == "active" do %>
+                    <span class="inline-flex items-center bg-green-200 text-green-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                      <span class="w-2 h-2 me-1 bg-green-500 rounded-full"></span>
+                      Online
+                    </span>
+                  <% end %>
+                  <%= if device.state == "offline" do%>
+                    <span class="inline-flex items-center bg-red-200 text-red-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                      <span class="w-2 h-2 me-1 bg-red-500 rounded-full"></span>
+                      Offline
+                    </span>
+                  <% end %>
+                  <%= if device.state == "updating" do%>
+                    <span class="inline-flex items-center bg-gray-200 text-gray-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                      <span class="w-2 h-2 me-1 bg-gray-500 rounded-full"></span>
+                      Updating
+                    </span>
+                  <% end %>
+                </td>
+                <td class="px-6 py-4">
+                <%= if device.fw_version do %>
+                  <%= device.fw_version %>
+                <% else %>
+                    No version available
+                <% end %>
+                </td>
+                <td class="px-6 py-4 uppercase">
+                  <%= device.active_partition %>
+                </td>
+                <td class="px-6 py-4 uppercase">
+                <%= if device.state == "active" do %>
+                  <label class="inline-flex items-center me-5 cursor-pointer">
+                    <input type="checkbox" class="sr-only peer" phx-click="ssh_connect" phx-value-id={device.name} checked={device.ssh_state}>
+                    <div class="relative w-11 h-6 bg-gray-200 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-green-400"></div>
+                  </label>
+                <% else %>
+                  <label class="inline-flex items-center me-5 cursor-pointer">
+                    <input type="checkbox" disabled class="sr-only peer" phx-click="ssh_connect" phx-value-id={device.name} checked={device.ssh_state}>
+                    <div class="relative w-11 h-6 bg-gray-200 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-green-400"></div>
+                  </label>
+                <% end %>
+                </td>
+                <td class="px-6 py-4">
+                  <%= if device.state == "active" do %>
+                    <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-1 px-3 rounded-lg mr-2" phx-click="show_update_modal" phx-value-id={device.name}>Update</button>
+                    <a phx-click="edit_device" phx-value-id={device.name} class="font-medium text-blue-600 dark:text-blue-500 hover:underline">Show more</a>
+                  <% else %>
+                    <button disabled class="bg-gray-500 text-white font-semibold py-1 px-3 rounded-lg mr-2" phx-click="show_update_modal" phx-value-id={device.name}>Update</button>
+                    <a class="font-medium text-gray-600 dark:text-gray-500">Show more</a>
+                  <% end %>
+                </td>
+            </tr>
+          <% end %>
+        </tbody>
+      </table>
+    """
+  end
+
+  attr :selected_firmware, :map, required: true, doc: "selected firmware for more info"
+  def firmware_modal(assigns) do
+    ~H"""
+      <div class="fixed inset-0 flex items-center justify-center z-50">
+        <div class="bg-white p-6 rounded-lg shadow-lg w-1/2">
+          <div class="flex w-full justify-between">
+            <h2 class="text-lg font-bold"><%= @selected_firmware.name %></h2>
+            <button class="bg-red-500 hover:bg-red-600 text-white py-1 px-4 rounded-lg" phx-click="close_firmware_modal">X</button>
+          </div>
+          <h2 class="text-lg font-bold">Firmware description</h2>
+            <%= @selected_firmware.info %>
+          <h2 class="text-lg font-bold">Upload date</h2>
+            <%= Calendar.strftime(@selected_firmware.upload_date, "%d %b %Y, %H:%M %p") %>
+        </div>
+        <div class="fixed inset-0 bg-black opacity-50 -z-10"></div>
+      </div>
+    """
+  end
+
+  attr :uploaded_files, :map, required: true, doc: "Filter type input"
+  attr :editing_firmware, :map, required: true, doc: "Filter type input"
+  def firmware_table(assigns) do
+    ~H"""
+      <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
+        <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
+          <tr>
+              <th scope="col" class="p-4">
+                  <div class="flex items-center">
+                      <input id="checkbox-all-search" type="checkbox" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
+                      <label for="checkbox-all-search" class="sr-only">checkbox</label>
+                  </div>
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  File name
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  File size
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Upload date
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Description
+              </th>
+              <th scope="col" class="px-6 py-3">
+                  Action
+              </th>
+          </tr>
+        </thead>
+        <tbody>
+          <%= for file <- @uploaded_files do %>
+            <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
+                <td class="w-4 p-4">
+                    <div class="flex items-center">
+                        <input id="checkbox-table-search-2" type="checkbox" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
+                        <label for="checkbox-table-search-2" class="sr-only">checkbox</label>
+                    </div>
+                </td>
+                <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                  <%= file.name %>
+                </th>
+
+                <td class="px-6 py-4">
+                    <%= Float.round(file.size, 2) %> MB
+                </td>
+                <td class="px-6 py-4">
+                    <%= file.upload_date %>
+                </td>
+                <td class="px-6 py-4">
+                <%= if @editing_firmware && @editing_firmware.name == file.name do %>
+                  <form phx-submit="save_firmware_info">
+                    <input type="hidden" name="name" value={file.name} />
+                    <textarea name="firmware_info" class="form-textarea mt-1 block w-full" rows="3"><%= file.info %></textarea>
+                    <button type="submit" class="mt-2 text-white bg-blue-600 px-3 py-1 rounded">Save</button>
+                  </form>
+                <% else %>
+                  <%= if String.length(file.info) > 100 do %>
+                    <%= String.slice(file.info, 0..99) %>...
+                  <% else %>
+                    <%= file.info %>
+                  <% end %>
+                <% end %>
+                </td>
+                <td class="px-6 py-4">
+                  <a phx-click="delete_firmware" phx-value-name={file.name} class="font-medium text-red-600 dark:text-red-500 hover:underline mr-2">Delete</a>
+                  <a phx-click="show_firmware" phx-value-name={file.name} class="font-medium text-blue-600 dark:text-blue-500 hover:underline">Show more</a>
+                  <a phx-click="edit_firmware" phx-value-name={file.name} class="font-medium text-green-600 dark:text-green-500 hover:underline">Edit</a>
+                </td>
+            </tr>
+          <% end %>
+        </tbody>
+      </table>
+    """
+  end
+
+
+  defp filter_devices(devices, filter_online, _filter_types, search_term) do
+    devices
+    |> Enum.filter(fn device ->
+      (not filter_online or device.state == "active") and
+      #(filter_types == [] or device.type in filter_types) and
+      (String.contains?(String.downcase(device.name), String.downcase(search_term)))
+    end)
+  end
+end
diff --git a/lib/nemo_cloud_services_web/components/layouts.ex b/lib/nemo_cloud_services_web/components/layouts.ex
new file mode 100644
index 0000000000000000000000000000000000000000..04f1d616ee7ab0a4f786340c14ec7f5b1a3b3cbc
--- /dev/null
+++ b/lib/nemo_cloud_services_web/components/layouts.ex
@@ -0,0 +1,14 @@
+defmodule NemoCloudServicesWeb.Layouts do
+  @moduledoc """
+  This module holds different layouts used by your application.
+
+  See the `layouts` directory for all templates available.
+  The "root" layout is a skeleton rendered as part of the
+  application router. The "app" layout is set as the default
+  layout on both `use NemoCloudServicesWeb, :controller` and
+  `use NemoCloudServicesWeb, :live_view`.
+  """
+  use NemoCloudServicesWeb, :html
+
+  embed_templates "layouts/*"
+end
diff --git a/lib/nemo_cloud_services_web/components/layouts/app.html.heex b/lib/nemo_cloud_services_web/components/layouts/app.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..65fedf70c37a77cec444d9a495980c44357f2186
--- /dev/null
+++ b/lib/nemo_cloud_services_web/components/layouts/app.html.heex
@@ -0,0 +1,6 @@
+<main>
+  <div class="mt-14">
+    <.flash_group flash={@flash}/>
+    <%= @inner_content %>
+  </div>
+</main>
diff --git a/lib/nemo_cloud_services_web/components/layouts/root.html.heex b/lib/nemo_cloud_services_web/components/layouts/root.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..9055471eba5b85010a8a65f53bfb64519d3769dd
--- /dev/null
+++ b/lib/nemo_cloud_services_web/components/layouts/root.html.heex
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en" class="[scrollbar-gutter:stable]">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="csrf-token" content={get_csrf_token()} />
+    <.live_title suffix=" · Phoenix Framework">
+      <%= assigns[:page_title] || "NemoCloudServices" %>
+    </.live_title>
+    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
+    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
+    </script>
+  </head>
+  <body class="bg-white">
+    <%= @inner_content %>
+  </body>
+</html>
diff --git a/lib/nemo_cloud_services_web/controllers/api/dt_controller.ex b/lib/nemo_cloud_services_web/controllers/api/dt_controller.ex
new file mode 100644
index 0000000000000000000000000000000000000000..ffd962fda6c687a82699c535eb1439a4d30852de
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/api/dt_controller.ex
@@ -0,0 +1,206 @@
+defmodule NemoCloudServicesWeb.Api.DtController do
+  use NemoCloudServicesWeb, :controller
+  use OpenApiSpex.ControllerSpecs
+  @doc """
+  Get device information by device ID.
+  """
+  tags ["Device"]
+  operation :get_device_info,
+  summary: "Get device's information",
+  parameters: [
+    device_id: [in: :path, description: "Device ID", type: :integer, example: 1001]
+  ],
+  responses: [
+    ok: {
+      "Device Info",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          id: %OpenApiSpex.Schema{type: :string, description: "Device ID"},
+          status: %OpenApiSpex.Schema{type: :string, description: "Device status"},
+          data: %OpenApiSpex.Schema{type: :object, description: "Device data"}
+        },
+        required: [:id, :status, :data]
+      }
+    },
+    not_found: {
+      "Error",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          error: %OpenApiSpex.Schema{type: :string, description: "Error message"}
+        },
+        required: [:error]
+      }
+    }
+  ]
+
+  def get_device_info(conn, %{"device_id" => id}) do
+    results =
+      case id do
+        "1" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1, :get_device)
+        "2" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2, :get_device)
+        _ ->
+          conn
+            |> put_status(:not_found)
+            |> json(%{error: "Device not found"})
+      end
+
+    json(conn, results)
+  end
+
+
+  @doc """
+  Get device's last data.
+  """
+  operation :get_device_data,
+  summary: "Get device's last data",
+  parameters: [
+    device_id: [in: :path, description: "Device ID", type: :integer, example: 1001]
+  ],
+  responses: [
+    ok: {
+      "Device Info",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          id: %OpenApiSpex.Schema{type: :string, description: "Device ID"},
+          status: %OpenApiSpex.Schema{type: :string, description: "Device status"},
+          data: %OpenApiSpex.Schema{type: :object, description: "Device data"}
+        },
+        required: [:id, :status, :data]
+      }
+    },
+    not_found: {
+      "Error",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          error: %OpenApiSpex.Schema{type: :string, description: "Error message"}
+        },
+        required: [:error]
+      }
+    }
+  ]
+
+  def get_device_data(conn, %{"device_id" => id}) do
+    results =
+      case id do
+        "1" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1, :get_last_data)
+        "2" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2, :get_last_data)
+        _ ->
+          conn
+            |> put_status(:not_found)
+            |> json(%{error: "Device not found"})
+      end
+
+    json(conn, results)
+  end
+
+  @doc """
+  Change device's firmware.
+  """
+  operation :change_firmware,
+  summary: "Update device firmware",
+  parameters: [
+    device_id: [in: :path, description: "Device ID", type: :integer, example: 1001]
+  ],
+  request_body: {
+    "Firmware filename",
+    "application/json",
+    %OpenApiSpex.Schema{
+      type: :object,
+      properties: %{
+        filename: %OpenApiSpex.Schema{type: :string, description: "Device ID", example: "zero2wledota-2000ms-v0.16.0.fw"}
+      },
+      required: [:filename]
+  }},
+  responses: [
+    ok: {
+      "Device Info",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          id: %OpenApiSpex.Schema{type: :string, description: "Device ID"},
+          status: %OpenApiSpex.Schema{type: :string, description: "Device status"},
+          data: %OpenApiSpex.Schema{type: :object, description: "Device data"}
+        },
+        required: [:id, :status, :data]
+      }
+    },
+    not_found: {
+      "Error",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          error: %OpenApiSpex.Schema{type: :string, description: "Error message"}
+        },
+        required: [:error]
+      }
+    }
+  ]
+
+  def change_firmware(conn, %{"device_id" => id} = params) do
+    results =
+      case id do
+        "1" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1, {:change_firmware, Map.get(params, "filename")})
+        "2" -> GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2, {:change_firmware, Map.get(params, "filename")})
+        _ ->
+          conn
+            |> put_status(:not_found)
+            |> json(%{error: "Device not found"})
+      end
+
+    conn
+      |> put_status(:ok)
+      |> json(results)
+  end
+
+  @doc """
+  List all devices.
+  """
+  operation :list_devices,
+  summary: "List all devices",
+  parameters: [],
+  responses: [
+    ok: {
+      "Device Info",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          id: %OpenApiSpex.Schema{type: :string, description: "Device ID"},
+          status: %OpenApiSpex.Schema{type: :string, description: "Device status"},
+          data: %OpenApiSpex.Schema{type: :object, description: "Device data"}
+        },
+        required: [:id, :status, :data]
+      }
+    },
+    not_found: {
+      "Error",
+      "application/json",
+      %OpenApiSpex.Schema{
+        type: :object,
+        properties: %{
+          error: %OpenApiSpex.Schema{type: :string, description: "Error message"}
+        },
+        required: [:error]
+      }
+    }
+  ]
+  def list_devices(conn, _params) do
+    devices = [
+      GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1, :get_device),
+      GenServer.call(NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2, :get_device)
+    ]
+
+    json(conn, devices)
+  end
+
+end
diff --git a/lib/nemo_cloud_services_web/controllers/device_controller.ex b/lib/nemo_cloud_services_web/controllers/device_controller.ex
new file mode 100644
index 0000000000000000000000000000000000000000..88b00133120b10c8a4f60318f4a68ad0e9f2d9ee
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_controller.ex
@@ -0,0 +1,62 @@
+defmodule NemoCloudServicesWeb.DeviceController do
+  use NemoCloudServicesWeb, :controller
+
+  alias NemoCloudServices.Devices
+  alias NemoCloudServices.Devices.Device
+
+  def index(conn, _params) do
+    devices = Devices.list_devices()
+    render(conn, :index, devices: devices)
+  end
+
+  def new(conn, _params) do
+    changeset = Devices.change_device(%Device{})
+    render(conn, :new, changeset: changeset)
+  end
+
+  def create(conn, %{"device" => device_params}) do
+    case Devices.create_device(device_params) do
+      {:ok, device} ->
+        conn
+        |> put_flash(:info, "Device created successfully.")
+        |> redirect(to: ~p"/devices/#{device}")
+
+      {:error, %Ecto.Changeset{} = changeset} ->
+        render(conn, :new, changeset: changeset)
+    end
+  end
+
+  def show(conn, %{"id" => id}) do
+    device = Devices.get_device!(id)
+    render(conn, :show, device: device)
+  end
+
+  def edit(conn, %{"id" => id}) do
+    device = Devices.get_device!(id)
+    changeset = Devices.change_device(device)
+    render(conn, :edit, device: device, changeset: changeset)
+  end
+
+  def update(conn, %{"id" => id, "device" => device_params}) do
+    device = Devices.get_device!(id)
+
+    case Devices.update_device(device, device_params) do
+      {:ok, device} ->
+        conn
+        |> put_flash(:info, "Device updated successfully.")
+        |> redirect(to: ~p"/devices/#{device}")
+
+      {:error, %Ecto.Changeset{} = changeset} ->
+        render(conn, :edit, device: device, changeset: changeset)
+    end
+  end
+
+  def delete(conn, %{"id" => id}) do
+    device = Devices.get_device!(id)
+    {:ok, _device} = Devices.delete_device(device)
+
+    conn
+    |> put_flash(:info, "Device deleted successfully.")
+    |> redirect(to: ~p"/devices")
+  end
+end
diff --git a/lib/nemo_cloud_services_web/controllers/device_html.ex b/lib/nemo_cloud_services_web/controllers/device_html.ex
new file mode 100644
index 0000000000000000000000000000000000000000..22bc84f1e198e067919035218ae96762930a11c7
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html.ex
@@ -0,0 +1,13 @@
+defmodule NemoCloudServicesWeb.DeviceHTML do
+  use NemoCloudServicesWeb, :html
+
+  embed_templates "device_html/*"
+
+  @doc """
+  Renders a device form.
+  """
+  attr :changeset, Ecto.Changeset, required: true
+  attr :action, :string, required: true
+
+  def device_form(assigns)
+end
diff --git a/lib/nemo_cloud_services_web/controllers/device_html/device_form.html.heex b/lib/nemo_cloud_services_web/controllers/device_html/device_form.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..edf3fd6cbe5721a32a2c49f0b3213ec399469f79
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html/device_form.html.heex
@@ -0,0 +1,17 @@
+<.simple_form :let={f} for={@changeset} action={@action}>
+  <.error :if={@changeset.action}>
+    Oops, something went wrong! Please check the errors below.
+  </.error>
+  <.input field={f[:name]} type="text" label="Name" />
+  <.input field={f[:state]} type="text" label="State" />
+  <.input field={f[:fw_version]} type="text" label="Fw version" />
+  <.input field={f[:fw_filename]} type="text" label="Fw filename" />
+  <.input field={f[:fw_updated_date]} type="datetime-local" label="Fw updated date" />
+  <.input field={f[:active_partition]} type="text" label="Active partition" />
+  <.input field={f[:last_ssh_session]} type="datetime-local" label="Last ssh session" />
+  <.input field={f[:ssh_state]} type="checkbox" label="Ssh state" />
+  <.input field={f[:last_alive]} type="datetime-local" label="Last alive" />
+  <:actions>
+    <.button>Save Device</.button>
+  </:actions>
+</.simple_form>
diff --git a/lib/nemo_cloud_services_web/controllers/device_html/edit.html.heex b/lib/nemo_cloud_services_web/controllers/device_html/edit.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..c9311a1ec82f672ac3d97cb73be18d11e0ff22f8
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html/edit.html.heex
@@ -0,0 +1,8 @@
+<.header>
+  Edit Device <%= @device.id %>
+  <:subtitle>Use this form to manage device records in your database.</:subtitle>
+</.header>
+
+<.device_form changeset={@changeset} action={~p"/devices/#{@device}"} />
+
+<.back navigate={~p"/devices"}>Back to devices</.back>
diff --git a/lib/nemo_cloud_services_web/controllers/device_html/index.html.heex b/lib/nemo_cloud_services_web/controllers/device_html/index.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..ef79de446c7960024e421bf1b352b7aa1649e021
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html/index.html.heex
@@ -0,0 +1,31 @@
+<.header>
+  Listing Devices
+  <:actions>
+    <.link href={~p"/devices/new"}>
+      <.button>New Device</.button>
+    </.link>
+  </:actions>
+</.header>
+
+<.table id="devices" rows={@devices} row_click={&JS.navigate(~p"/devices/#{&1}")}>
+  <:col :let={device} label="Name"><%= device.name %></:col>
+  <:col :let={device} label="State"><%= device.state %></:col>
+  <:col :let={device} label="Fw version"><%= device.fw_version %></:col>
+  <:col :let={device} label="Fw filename"><%= device.fw_filename %></:col>
+  <:col :let={device} label="Fw updated date"><%= device.fw_updated_date %></:col>
+  <:col :let={device} label="Active partition"><%= device.active_partition %></:col>
+  <:col :let={device} label="Last ssh session"><%= device.last_ssh_session %></:col>
+  <:col :let={device} label="Ssh state"><%= device.ssh_state %></:col>
+  <:col :let={device} label="Last alive"><%= device.last_alive %></:col>
+  <:action :let={device}>
+    <div class="sr-only">
+      <.link navigate={~p"/devices/#{device}"}>Show</.link>
+    </div>
+    <.link navigate={~p"/devices/#{device}/edit"}>Edit</.link>
+  </:action>
+  <:action :let={device}>
+    <.link href={~p"/devices/#{device}"} method="delete" data-confirm="Are you sure?">
+      Delete
+    </.link>
+  </:action>
+</.table>
diff --git a/lib/nemo_cloud_services_web/controllers/device_html/new.html.heex b/lib/nemo_cloud_services_web/controllers/device_html/new.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..3c250d09a9fb6e17c2b50eeffdb288b0da74aec3
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html/new.html.heex
@@ -0,0 +1,8 @@
+<.header>
+  New Device
+  <:subtitle>Use this form to manage device records in your database.</:subtitle>
+</.header>
+
+<.device_form changeset={@changeset} action={~p"/devices"} />
+
+<.back navigate={~p"/devices"}>Back to devices</.back>
diff --git a/lib/nemo_cloud_services_web/controllers/device_html/show.html.heex b/lib/nemo_cloud_services_web/controllers/device_html/show.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..2869295f1fd0cc68565d6c0c4862b5af3adf6394
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/device_html/show.html.heex
@@ -0,0 +1,23 @@
+<.header>
+  Device <%= @device.id %>
+  <:subtitle>This is a device record from your database.</:subtitle>
+  <:actions>
+    <.link href={~p"/devices/#{@device}/edit"}>
+      <.button>Edit device</.button>
+    </.link>
+  </:actions>
+</.header>
+
+<.list>
+  <:item title="Name"><%= @device.name %></:item>
+  <:item title="State"><%= @device.state %></:item>
+  <:item title="Fw version"><%= @device.fw_version %></:item>
+  <:item title="Fw filename"><%= @device.fw_filename %></:item>
+  <:item title="Fw updated date"><%= @device.fw_updated_date %></:item>
+  <:item title="Active partition"><%= @device.active_partition %></:item>
+  <:item title="Last ssh session"><%= @device.last_ssh_session %></:item>
+  <:item title="Ssh state"><%= @device.ssh_state %></:item>
+  <:item title="Last alive"><%= @device.last_alive %></:item>
+</.list>
+
+<.back navigate={~p"/devices"}>Back to devices</.back>
diff --git a/lib/nemo_cloud_services_web/controllers/error_html.ex b/lib/nemo_cloud_services_web/controllers/error_html.ex
new file mode 100644
index 0000000000000000000000000000000000000000..2690c09f96ef541b3cf05ce715ae3b8c387ca702
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/error_html.ex
@@ -0,0 +1,24 @@
+defmodule NemoCloudServicesWeb.ErrorHTML do
+  @moduledoc """
+  This module is invoked by your endpoint in case of errors on HTML requests.
+
+  See config/config.exs.
+  """
+  use NemoCloudServicesWeb, :html
+
+  # If you want to customize your error pages,
+  # uncomment the embed_templates/1 call below
+  # and add pages to the error directory:
+  #
+  #   * lib/nemo_cloud_services_web/controllers/error_html/404.html.heex
+  #   * lib/nemo_cloud_services_web/controllers/error_html/500.html.heex
+  #
+  # embed_templates "error_html/*"
+
+  # The default is to render a plain text page based on
+  # the template name. For example, "404.html" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    Phoenix.Controller.status_message_from_template(template)
+  end
+end
diff --git a/lib/nemo_cloud_services_web/controllers/error_json.ex b/lib/nemo_cloud_services_web/controllers/error_json.ex
new file mode 100644
index 0000000000000000000000000000000000000000..e061caeac27c5a9611a5e78abbdd4f20057de605
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/error_json.ex
@@ -0,0 +1,21 @@
+defmodule NemoCloudServicesWeb.ErrorJSON do
+  @moduledoc """
+  This module is invoked by your endpoint in case of errors on JSON requests.
+
+  See config/config.exs.
+  """
+
+  # If you want to customize a particular status code,
+  # you may add your own clauses, such as:
+  #
+  # def render("500.json", _assigns) do
+  #   %{errors: %{detail: "Internal Server Error"}}
+  # end
+
+  # By default, Phoenix returns the status message from
+  # the template name. For example, "404.json" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+  end
+end
diff --git a/lib/nemo_cloud_services_web/controllers/firmware_controller.ex b/lib/nemo_cloud_services_web/controllers/firmware_controller.ex
new file mode 100644
index 0000000000000000000000000000000000000000..db9615465dae05686e36365931f22b2d622c395d
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/firmware_controller.ex
@@ -0,0 +1,23 @@
+defmodule NemoCloudServicesWeb.FirmwareController do
+  use NemoCloudServicesWeb, :controller
+
+  def download(conn, %{"filename" => filename}) do
+    firmware_dir =
+      Application.get_env(:my_app, :firmware_dir) ||
+        "firmware_files"
+
+    sanitized_filename = Path.basename(filename)
+    file_path = Path.join(firmware_dir, sanitized_filename)
+
+    if File.exists?(file_path) do
+      conn
+      |> put_resp_content_type("application/octet-stream")
+      |> put_resp_header("content-disposition", ~s[attachment; filename="#{sanitized_filename}"])
+      |> send_file(200, file_path)
+    else
+      conn
+      |> put_status(:not_found)
+      |> text("File not found")
+    end
+  end
+end
diff --git a/lib/nemo_cloud_services_web/controllers/page_controller.ex b/lib/nemo_cloud_services_web/controllers/page_controller.ex
new file mode 100644
index 0000000000000000000000000000000000000000..19434f6511a792dbadd4537199616b1f8f1eac50
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/page_controller.ex
@@ -0,0 +1,9 @@
+defmodule NemoCloudServicesWeb.PageController do
+  use NemoCloudServicesWeb, :controller
+
+  def home(conn, _params) do
+    # The home page is often custom made,
+    # so skip the default app layout.
+    render(conn, :home, layout: false)
+  end
+end
diff --git a/lib/nemo_cloud_services_web/controllers/page_html.ex b/lib/nemo_cloud_services_web/controllers/page_html.ex
new file mode 100644
index 0000000000000000000000000000000000000000..015fb12ad08dd002f019042b02db2966ea6f9939
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/page_html.ex
@@ -0,0 +1,10 @@
+defmodule NemoCloudServicesWeb.PageHTML do
+  @moduledoc """
+  This module contains pages rendered by PageController.
+
+  See the `page_html` directory for all templates available.
+  """
+  use NemoCloudServicesWeb, :html
+
+  embed_templates "page_html/*"
+end
diff --git a/lib/nemo_cloud_services_web/controllers/page_html/home.html.heex b/lib/nemo_cloud_services_web/controllers/page_html/home.html.heex
new file mode 100644
index 0000000000000000000000000000000000000000..dc1820b11e8488b6f670e52e91d8974569bef7b0
--- /dev/null
+++ b/lib/nemo_cloud_services_web/controllers/page_html/home.html.heex
@@ -0,0 +1,222 @@
+<.flash_group flash={@flash} />
+<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
+  <svg
+    viewBox="0 0 1480 957"
+    fill="none"
+    aria-hidden="true"
+    class="absolute inset-0 h-full w-full"
+    preserveAspectRatio="xMinYMid slice"
+  >
+    <path fill="#EE7868" d="M0 0h1480v957H0z" />
+    <path
+      d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
+      fill="#FF9F92"
+    />
+    <path
+      d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
+      fill="#FA8372"
+    />
+    <path
+      d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
+      fill="#E96856"
+      fill-opacity=".6"
+    />
+    <path
+      d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
+      fill="#C42652"
+      fill-opacity=".2"
+    />
+    <path
+      d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
+      fill="#A41C42"
+      fill-opacity=".2"
+    />
+    <path
+      d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
+      fill="#A41C42"
+      fill-opacity=".2"
+    />
+  </svg>
+</div>
+<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
+  <div class="mx-auto max-w-xl lg:mx-0">
+    <svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
+      <path
+        d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
+        fill="#FD4F00"
+      />
+    </svg>
+    <h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
+      Phoenix Framework
+      <small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
+        v<%= Application.spec(:phoenix, :vsn) %>
+      </small>
+    </h1>
+    <p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
+      Peace of mind from prototype to production.
+    </p>
+    <p class="mt-4 text-base leading-7 text-zinc-600">
+      Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
+    </p>
+    <div class="flex">
+      <div class="w-full sm:w-auto">
+        <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
+          <a
+            href="https://hexdocs.pm/phoenix/overview.html"
+            class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
+          >
+            <span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
+            </span>
+            <span class="relative flex items-center gap-4 sm:flex-col">
+              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
+                <path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
+                <path
+                  d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
+                  stroke="#18181B"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                />
+              </svg>
+              Guides &amp; Docs
+            </span>
+          </a>
+          <a
+            href="https://github.com/phoenixframework/phoenix"
+            class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
+          >
+            <span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
+            </span>
+            <span class="relative flex items-center gap-4 sm:flex-col">
+              <svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
+                <path
+                  fill-rule="evenodd"
+                  clip-rule="evenodd"
+                  d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
+                  fill="#18181B"
+                />
+              </svg>
+              Source Code
+            </span>
+          </a>
+          <a
+            href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
+            class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
+          >
+            <span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
+            </span>
+            <span class="relative flex items-center gap-4 sm:flex-col">
+              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
+                <path
+                  d="M12 1v6M12 17v6"
+                  stroke="#18181B"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                />
+                <circle
+                  cx="12"
+                  cy="12"
+                  r="4"
+                  fill="#18181B"
+                  fill-opacity=".15"
+                  stroke="#18181B"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                />
+              </svg>
+              Changelog
+            </span>
+          </a>
+        </div>
+        <div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
+          <div>
+            <a
+              href="https://twitter.com/elixirphoenix"
+              class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
+            >
+              <svg
+                viewBox="0 0 16 16"
+                aria-hidden="true"
+                class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
+              >
+                <path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
+              </svg>
+              Follow on Twitter
+            </a>
+          </div>
+          <div>
+            <a
+              href="https://elixirforum.com"
+              class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
+            >
+              <svg
+                viewBox="0 0 16 16"
+                aria-hidden="true"
+                class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
+              >
+                <path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
+              </svg>
+              Discuss on the Elixir Forum
+            </a>
+          </div>
+          <div>
+            <a
+              href="https://web.libera.chat/#elixir"
+              class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
+            >
+              <svg
+                viewBox="0 0 16 16"
+                aria-hidden="true"
+                class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
+              >
+                <path
+                  fill-rule="evenodd"
+                  clip-rule="evenodd"
+                  d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
+                />
+                <path
+                  fill-rule="evenodd"
+                  clip-rule="evenodd"
+                  d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
+                />
+              </svg>
+              Chat on Libera IRC
+            </a>
+          </div>
+          <div>
+            <a
+              href="https://discord.gg/elixir"
+              class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
+            >
+              <svg
+                viewBox="0 0 16 16"
+                aria-hidden="true"
+                class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
+              >
+                <path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
+              </svg>
+              Join our Discord server
+            </a>
+          </div>
+          <div>
+            <a
+              href="https://fly.io/docs/elixir/getting-started/"
+              class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
+            >
+              <svg
+                viewBox="0 0 20 20"
+                aria-hidden="true"
+                class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
+              >
+                <path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
+              </svg>
+              Deploy your application
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/lib/nemo_cloud_services_web/endpoint.ex b/lib/nemo_cloud_services_web/endpoint.ex
new file mode 100644
index 0000000000000000000000000000000000000000..371b1462947c68fe618a0837e0a2c1ceea38072f
--- /dev/null
+++ b/lib/nemo_cloud_services_web/endpoint.ex
@@ -0,0 +1,53 @@
+defmodule NemoCloudServicesWeb.Endpoint do
+  use Phoenix.Endpoint, otp_app: :nemo_cloud_services
+
+  # The session will be stored in the cookie and signed,
+  # this means its contents can be read but not tampered with.
+  # Set :encryption_salt if you would also like to encrypt it.
+  @session_options [
+    store: :cookie,
+    key: "_nemo_cloud_services_key",
+    signing_salt: "XuHDCi9S",
+    same_site: "Lax"
+  ]
+
+  socket "/live", Phoenix.LiveView.Socket,
+    websocket: [connect_info: [session: @session_options]],
+    longpoll: [connect_info: [session: @session_options]]
+
+  # Serve at "/" the static files from "priv/static" directory.
+  #
+  # You should set gzip to true if you are running phx.digest
+  # when deploying your static files in production.
+  plug Plug.Static,
+    at: "/",
+    from: :nemo_cloud_services,
+    gzip: false,
+    only: NemoCloudServicesWeb.static_paths()
+
+  # Code reloading can be explicitly enabled under the
+  # :code_reloader configuration of your endpoint.
+  if code_reloading? do
+    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+    plug Phoenix.LiveReloader
+    plug Phoenix.CodeReloader
+    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :nemo_cloud_services
+  end
+
+  plug Phoenix.LiveDashboard.RequestLogger,
+    param_key: "request_logger",
+    cookie_key: "request_logger"
+
+  plug Plug.RequestId
+  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+  plug Plug.Parsers,
+    parsers: [:urlencoded, :multipart, :json],
+    pass: ["*/*"],
+    json_decoder: Phoenix.json_library()
+
+  plug Plug.MethodOverride
+  plug Plug.Head
+  plug Plug.Session, @session_options
+  plug NemoCloudServicesWeb.Router
+end
diff --git a/lib/nemo_cloud_services_web/gettext.ex b/lib/nemo_cloud_services_web/gettext.ex
new file mode 100644
index 0000000000000000000000000000000000000000..eb6adb7cb4278ce3e92a0d07a12138fb335e3ced
--- /dev/null
+++ b/lib/nemo_cloud_services_web/gettext.ex
@@ -0,0 +1,24 @@
+defmodule NemoCloudServicesWeb.Gettext do
+  @moduledoc """
+  A module providing Internationalization with a gettext-based API.
+
+  By using [Gettext](https://hexdocs.pm/gettext),
+  your module gains a set of macros for translations, for example:
+
+      import NemoCloudServicesWeb.Gettext
+
+      # Simple translation
+      gettext("Here is the string to translate")
+
+      # Plural translation
+      ngettext("Here is the string to translate",
+               "Here are the strings to translate",
+               3)
+
+      # Domain-based translation
+      dgettext("errors", "Here is the error message to translate")
+
+  See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
+  """
+  use Gettext.Backend, otp_app: :nemo_cloud_services
+end
diff --git a/lib/nemo_cloud_services_web/live/dt_live/ota_update_live.ex b/lib/nemo_cloud_services_web/live/dt_live/ota_update_live.ex
new file mode 100644
index 0000000000000000000000000000000000000000..3e7fb0c8236dbaefd4d5b1cf5d44b27b9a34f2db
--- /dev/null
+++ b/lib/nemo_cloud_services_web/live/dt_live/ota_update_live.ex
@@ -0,0 +1,698 @@
+defmodule NemoCloudServicesWeb.DtLive.OtaUpdateLive do
+  require Logger
+  #alias NemoCloudServices.Logs
+  #alias NemoCloudServices.Workbook
+  use NemoCloudServicesWeb, :live_view
+  alias NemoCloudServices.Utils.DevicesUpdate
+  alias NemoCloudServicesWeb.CustomComponents
+  #alias NemoCloudServices.Conn.MqttClient
+  #alias NemoCloudServices.Repo
+  #alias NemoCloudServices.Devices
+  alias NemoCloudServices.Utils.LogsLoger
+  #alias NemoCloudServices.Utils.WorkbookLoger
+
+  #alias NemoCloudServices.Devices.Device
+
+    @spec mount(any(), any(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
+    def mount(_params, _session, socket) do
+      if connected?(socket) do
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_other")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_pingpong")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_ssh")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "status")
+      end
+
+      #active_devices = MqttClient.get_active_devices()
+
+      active_devices = [NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1.get_device()]
+      active_devices = [NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2.get_device() |  active_devices]
+
+
+
+      socket =
+        socket
+        |> assign(:devices, active_devices)
+        |> assign(:uploaded_files, fetch_firmwares())
+        |> assign(:show_modal, false)
+        |> assign(:selected_device, nil)
+        |> assign(:selected_firmware, nil)
+        |> assign(:show_firmware_modal, false)
+        |> assign(:show_update_modal, false)
+        |> assign(:filter_online, false)
+        |> assign(:filter_type, ["GW", "ASM"])
+        |> assign(:current_view, "dashboard")
+        |> assign(:search_term, "")
+        |> assign(:bulk_ids, [])
+        |> assign(:is_loading, true)
+        |> assign(:is_updating, false)
+        |> assign(:editing_firmware, nil)
+        |> assign(:last_data_dt_nemo_1, NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1.get_last_data())
+        |> assign(:last_data_dt_nemo_2, NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2.get_last_data())
+        #|> assign(:workbook_messages, Workbook.list_messages())
+        |> assign(:workbook_messages, [])
+        |> allow_upload(:firmware, accept: ~w(.bin .hex .jpg .fw), max_entries: 1, max_file_size: 50_000_000)
+        |> allow_upload(:csv_file, auto_upload: true, accept: ~w(.csv), max_entries: 1, progress: &handle_progress/3)
+
+      {:ok, socket}
+    end
+
+    def render(assigns) do
+      ~H"""
+      <CustomComponents.topbar />
+
+      <CustomComponents.sidebar number_of_firmwares={Enum.count(@uploaded_files)} />
+
+      <%= if @current_view == "dashboard" do %>
+        <div id="dashboard" class="p-4 sm:ml-64">
+          <%= if @show_modal do %>
+            <CustomComponents.info_modal selected_device={@selected_device} last_data_dt_nemo_1={@last_data_dt_nemo_1} last_data_dt_nemo_2={@last_data_dt_nemo_2}/>
+          <% end %>
+
+          <%= if @show_update_modal do %>
+            <CustomComponents.update_modal
+              uploaded_files={@uploaded_files}
+              device_id={@selected_device.name}
+              selected_firmware={@selected_firmware}
+            />
+          <% end %>
+
+          <div class="p-4">
+            <div class="grid grid-cols-3 gap-4 mb-6">
+              <CustomComponents.card value={"#{Enum.count(@devices)}"} desc="Number of devices"/>
+              <CustomComponents.card value={"#{Enum.count(@devices, fn device -> Map.get(device, :state) == "active" end)}"} desc="Online devices"/>
+              <CustomComponents.card value={"#{Enum.count(@devices, fn device -> Map.get(device, :state) == "updating" end)}"} desc="Curently updating"/>
+            </div>
+
+            <div class="flex items-center justify-center mb-4 mt-10">
+              <div class="relative overflow-x-auto sm:rounded-lg w-full">
+
+
+                <div class="flex flex-column sm:flex-row space-y-4 sm:space-y-0 items-center justify-between pb-4">
+                  <button
+                    class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mr-10"
+                    phx-click="manual_ping"
+                  >
+                  Manual ping
+                  </button>
+
+                  <CustomComponents.serch search_term={@search_term} />
+
+                  <CustomComponents.online_toggle filter_online={@filter_online} />
+
+                  <CustomComponents.filter_form filter_type={@filter_type} />
+                </div>
+
+                <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
+
+                  <CustomComponents.device_table
+                    devices={@devices}
+                    filter_online={@filter_online}
+                    filter_type={@filter_type}
+                    search_term={@search_term}
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "firmwares" do %>
+        <div id="firmwares" class="p-4 sm:ml-64">
+          <%= if @show_firmware_modal do %>
+            <CustomComponents.firmware_modal selected_firmware={@selected_firmware} />
+          <% end %>
+          <div class="p-4">
+            <form
+              id="upload-form"
+              phx-submit="upload_firmware"
+              phx-change="validate"
+              phx-drop-target={@uploads.firmware.ref}
+              enctype="multipart/form-data"
+              class="mb-6 flex flex-col w-1/2"
+            >
+              <label for="file_upload" class="block mb-2 text-sm font-medium text-gray-900">Upload file (Size limit: 50 MB)</label>
+              <div
+                id="drag-drop-area"
+                class="border-2 border-dashed border-gray-300 rounded-lg text-center"
+                phx-drop-target={@uploads.firmware.ref}
+              >
+                <.live_file_input
+                  upload={@uploads.firmware}
+                  id="file_upload"
+                  class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
+                />
+              </div>
+              <label for="firmware_info" class="block mb-2 text-sm font-medium text-gray-900 mt-4">Firmware info</label>
+              <textarea id="firmware_info" name="firmware_info" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Write firmware info..."></textarea>
+              <button
+                class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-6 w-full"
+                type="submit"
+                phx-disable-with="Uploading..."
+              >
+                Upload firmware
+              </button>
+            </form>
+
+            <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
+              <CustomComponents.firmware_table uploaded_files={@uploaded_files} editing_firmware={@editing_firmware}/>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "bulk_update" do %>
+        <div id="bulk_update" class="p-4 sm:ml-64">
+          <div class="p-4">
+
+            <form id="upgrade-form" phx-submit="bulk_update_device" phx-change="validate" phx-value-firmware={@selected_firmware} enctype="multipart/form-data">
+              <div class="w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6">
+                <div class="flex gap-12">
+                  <div class="w-1/2">
+                    <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Step 1:</strong> Upload list of devices</p>
+                    <div
+                      class="border-2 border-dashed border-gray-300 rounded-lg text-center mt-3"
+                      id="drag-drop-area"
+                      phx-drop-target={@uploads.csv_file.ref}
+                    >
+                      <.live_file_input upload={@uploads.csv_file} class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
+                    </div>
+                    <%= if @bulk_ids do %>
+                      <div class="mt-4">
+                        <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Device IDs:</strong></p>
+                        <ul>
+                          <%= for id <- @bulk_ids do %>
+                            <li class="text-base font-normal text-gray-900 dark:text-white"><%= id %></li>
+                          <% end %>
+                        </ul>
+                      </div>
+                    <% end %>
+                  </div>
+
+                  <div class="w-1/2 items-center">
+                    <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Example of .CSV file:</strong></p>
+                    <i class="text-base font-normal text-gray-900 dark:text-white">device_id,<br>
+                      nemo_gw_1,<br>
+                      nemo_gw_2,</i>
+                  </div>
+                </div>
+              </div>
+
+              <div class="w-full bg-white mt-6 rounded-lg shadow dark:bg-gray-800 p-4 md:p-6">
+                <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Step 2:</strong> Select firmware </p>
+                <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 mt-3 rounded-lg">
+                  <thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-400 rounded-lg">
+                    <tr>
+                        <th scope="col" class="px-6 py-3">
+
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File name
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File size
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File date
+                        </th>
+                    </tr>
+                  </thead>
+                  <tbody>
+                    <%= for file <- @uploaded_files do %>
+                      <tr class="bg-gray-50 border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600">
+                          <td class="w-4 p-4">
+                              <div class="flex items-center">
+                                  <input id={file.name} type="checkbox" phx-value-filename={file.name} phx-click="selected_firmware" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
+                                  <label for="checkbox-table-search-2" class="sr-only">checkbox</label>
+                              </div>
+                          </td>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= file.name %>
+                          </th>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= Float.round(file.size, 2) %> kB
+                          </th>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= file.upload_date %>
+                          </th>
+                      </tr>
+                    <% end %>
+                  </tbody>
+                </table>
+              </div>
+              <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-4" type="submit" phx-disable-with="Uploading...">Upgrade</button>
+            </form>
+          </div>
+
+        </div>
+      <% end %>
+
+      <%= if @current_view == "logs" do %>
+        <div id="logs" class="p-4 sm:ml-64">
+
+          <div class="p-4">
+            <div class="bg-gray-100 p-3 rounded-lg">
+              <%=
+                #data=Logs.list_messages()
+                data=[]
+                for line <- Enum.reverse(data) do
+              %>
+              <li class="text-gray-700 break-words leading-10" >
+              <%= if line.type=="error" do%>
+                <a class="text-red-700"><%= line.inserted_at %>: <%= line.message %></a>
+                <span class="inline-flex items-center bg-red-200 text-red-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-red-500 rounded-full"></span>
+                  Error
+                </span>
+              <% else %>
+                <a><%= line.inserted_at %>: <%= line.message %></a>
+              <% end %>
+              </li>
+              <% end %>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "workbook" do %>
+        <div id="workbook" class="p-4 sm:ml-64">
+
+          <div class="p-4 w-1/2">
+            <div>
+              <form
+                id="upload-form"
+                phx-submit="upload_workbook"
+                phx-change="validate"
+                enctype="multipart/form-data"
+                class="mb-6 flex flex-col"
+              >
+                <label for="message" class="block mb-2 text-sm font-medium text-gray-900">Enter message</label>
+                <textarea id="message" name="message" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Enter message..."></textarea>
+                <label for="user" class="block mb-2 mt-6 text-sm font-medium text-gray-900">Enter current user</label>
+                <input type="text" name="user" id="user" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Enter current user">
+                <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-6" type="submit" phx-disable-with="Uploading...">Enter</button>
+              </form>
+            </div>
+
+            <div class="bg-gray-100 p-3 rounded-lg mt-6">
+              <%=
+                for line <- Enum.reverse(@workbook_messages) do
+              %>
+              <li class="text-gray-700 break-words leading-10" >
+                <a><%= line.inserted_at %>: <%= line.message %></a>
+                <span class="inline-flex items-center bg-blue-200 text-blue-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-blue-500 rounded-full"></span>
+                  <%= line.user %>
+                </span>
+              </li>
+              <% end %>
+            </div>
+          </div>
+        </div>
+      <% end %>
+      """
+    end
+
+    def fetch_firmwares do
+      firmwares =
+        Path.wildcard("firmware_files/*.fw")
+        |> Enum.map(fn path ->
+          txt_path = Path.rootname(path) <> ".txt"
+          firmware_info = if File.exists?(txt_path), do: File.read!(txt_path), else: "No info available"
+          %{
+            name: Path.basename(path),
+            size: File.stat!(path).size / 1024 / 1000,
+            upload_date: File.stat!(path).mtime |> NaiveDateTime.from_erl!(),
+            info: firmware_info
+          }
+        end)
+
+      firmwares
+    end
+
+    def handle_info({"status", clientid, status_info}, socket) do
+      disconnected_at = status_info["disconnected_at"]
+
+      state = if disconnected_at == nil, do: "active", else: "offline"
+
+      updated_devices = Enum.map(socket.assigns.devices, fn device ->
+        if device.name == clientid && device.state != "updating" do
+          Map.put(device, :state, state)
+        else
+          device
+        end
+      end)
+
+      {:noreply, assign(socket, devices: updated_devices)}
+    end
+
+    def handle_info({"admin_out_pingpong", {clientid, _payload}}, socket) do
+      #MqttClient.request_firmware_info(clientid)
+
+      {:noreply, socket}
+    end
+
+    def handle_info({"admin_out_ssh", {clientid, ssh_info}}, socket) do
+      socket =
+        case Jason.decode(ssh_info) do
+          {:ok, %{"ssh_tunnel" => "opened"}} ->
+            #LogsLoger.write("SSH connection successfuly opened for device "<>clientid<>".", "info", clientid)
+            socket
+            |> put_flash(:info, "SSH connection established successfully.")
+
+          {:ok, %{"ssh_tunnel" => "closed"}} ->
+            #LogsLoger.write("SSH connection successfuly closed for device "<>clientid<>".", "info", clientid)
+            socket
+            |> put_flash(:info, "SSH connection was closed.")
+
+          {:ok, %{"reason" => reason, "ssh_tunnel" => error}} ->
+            #LogsLoger.write("SSH connection failed for device "<>clientid<>".", "error", clientid)
+            socket
+            |> put_flash(:error, "SSH connection error: #{reason}")
+
+          _ ->
+            put_flash(socket, :error, "Unexpected SSH info format: #{inspect(ssh_info)}")
+        end
+
+      {:noreply, socket}
+    end
+
+    def handle_info({"admin_out_other", {clientid, payload}}, socket) do
+      inactive_version = get_inactive_firmware_version(payload)
+
+      attributes = %{
+        name: clientid,
+        state: "active",
+        fw_version: payload[payload["nerves_fw_active"]<>".nerves_fw_version"] || "unknown",
+        fw_filename: "#{clientid}_firmware.fw",
+        fw_updated_date: DateTime.utc_now(),
+        active_partition: payload["nerves_fw_active"] || "unknown",
+        prev_fw_version: inactive_version,
+        ssh_state: false
+      }
+
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.name == clientid do
+            Map.merge(device, attributes)
+          else
+            device
+          end
+        end)
+
+      response_socket =
+        if Enum.any?(updated_devices, fn device -> device.name == clientid end) && socket.assigns.is_updating == true &&
+          Enum.find(updated_devices, fn device -> device.name == clientid end)["nerves_fw_active"] !=
+            Enum.find(socket.assigns.devices, fn device -> device.name == clientid end)["nerves_fw_active"] do
+          put_flash(socket, :info, "#{clientid} updated successfully!")
+        else
+          socket
+        end
+      {:noreply, assign(response_socket, devices: updated_devices, is_updating: false)}
+    end
+
+    def get_inactive_firmware_version(payload) do
+      case payload["nerves_fw_active"] do
+        "a" -> payload["b.nerves_fw_version"]
+        "b" -> payload["a.nerves_fw_version"]
+        _ -> nil
+      end
+    end
+
+    defp update_device_ssh_state(socket, clientid, ssh_state) do
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.id == clientid do
+            Map.put(device, :ssh_state, ssh_state)
+          else
+            device
+          end
+        end)
+
+      assign(socket, :devices, updated_devices)
+    end
+
+    def handle_event("manual_ping", _params, socket) do
+      #NemoCloudServices.Conn.MqttClient.send_ping_for_active_devices()
+
+      updated_devices = Enum.map(socket.assigns.devices, fn device ->
+        updated_device = %{device | state: "offline"}
+        #Devices.update_device(device, %{state: "offline"})
+        updated_device
+      end)
+
+      LogsLoger.write("Manual ping triggered by user.", "info", nil)
+      {:noreply, assign(socket, :devices, updated_devices)}
+    end
+
+    def handle_event("get_latest_data", %{"id" => id}, socket) do
+      IO.inspect("test")
+      case id do
+        "DT_nemo_1" ->
+          device_data = NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1.get_last_data()
+          {:noreply, assign(socket, last_data_dt_nemo_1: device_data)}
+        "DT_nemo_2" ->
+          device_data = NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2.get_last_data()
+          {:noreply, assign(socket, last_data_dt_nemo_2: device_data)}
+        _ -> 1
+      end
+
+    end
+
+    def handle_event("upload_workbook", %{"message" => message, "user" => user}, socket) do
+      #WorkbookLoger.write(message,user)
+      {:noreply, assign(socket, workbook_messages: [])}
+    end
+
+    def handle_event("clear_flash", %{"type" => type}, socket) do
+      {:noreply, clear_flash(socket, String.to_atom(type))}
+    end
+
+    def handle_event("edit_device", %{"id" => device_id}, socket) do
+      device = Enum.find(socket.assigns.devices, fn device -> device.name == device_id end)
+      {:noreply, assign(socket, show_modal: true, selected_device: device)}
+    end
+
+    def handle_event("close_modal", _params, socket) do
+      {:noreply, assign(socket, show_modal: false, selected_device: nil)}
+    end
+
+    def handle_event("close_update_modal", _params, socket) do
+      {:noreply, assign(socket, show_update_modal: false, selected_device: nil)}
+    end
+
+    def handle_event("close_firmware_modal", _params, socket) do
+      {:noreply, assign(socket, show_firmware_modal: false, selected_firmware: nil)}
+    end
+
+    def handle_event("validate", _params, socket) do
+      {:noreply, socket}
+    end
+
+    def handle_event("change_view", %{"button" => view}, socket) do
+      IO.inspect(socket.assigns)
+      {:noreply, assign(socket, :current_view, view)}
+    end
+
+    def handle_event("search_device", %{"search_term" => search_term}, socket) do
+      {:noreply, assign(socket, :search_term, search_term)}
+    end
+
+    def handle_event("upload_firmware", %{"firmware_info" => firmware_info}, socket) do
+      consume_uploaded_entries(socket, :firmware, fn %{path: path} = _upload, entry ->
+        case entry do
+          %Phoenix.LiveView.UploadEntry{client_name: original_name} ->
+            dest_folder = Path.expand("./firmware_files")
+            File.mkdir_p!(dest_folder)
+            dest = Path.join(dest_folder, original_name)
+
+            info_file_path = Path.rootname(dest) <> ".txt"
+            File.write(info_file_path, firmware_info)
+
+            case File.cp(path, dest) do
+              :ok ->
+                  LogsLoger.write("Firmware "<>original_name<>" uploaded.", "info", nil)
+                  {:ok, "path"}
+              {:error, _reason} ->
+                {:postpone, nil}
+            end
+          _ ->
+            {:postpone, nil}
+        end
+      end)
+
+      {:noreply, assign(socket, :uploaded_files, fetch_firmwares())}
+    end
+
+    def handle_event("edit_firmware", %{"name" => name}, socket) do
+      firmware = Enum.find(socket.assigns.uploaded_files, fn file -> file.name == name end)
+
+      {:noreply, assign(socket, :editing_firmware, firmware)}
+    end
+
+    def handle_event("save_firmware_info", %{"name" => name, "firmware_info" => firmware_info}, socket) do
+      dest_folder = Path.expand("./firmware_files")
+      file_path = Path.join(dest_folder, name)
+      info_file_path = Path.rootname(file_path) <> ".txt"
+
+      File.write!(info_file_path, firmware_info)
+
+      updated_firmwares = fetch_firmwares()
+
+      {:noreply, assign(socket, uploaded_files: updated_firmwares, editing_firmware: nil)}
+    end
+
+    def handle_event("delete_firmware", %{"name" => name}, socket) do
+      firmware_path = "firmware_files/" <> name
+      txt_path = Path.rootname(firmware_path) <> ".txt"
+
+
+      case File.rm(firmware_path) do
+        :ok ->
+          case File.rm(txt_path) do
+            :ok -> IO.puts("Info file deleted successfully.")
+            {:error, :enoent} -> IO.puts("Info file not found; nothing to delete.")
+            {:error, reason} -> IO.puts("Failed to delete info file: #{reason}")
+          end
+        {:error, reason} ->
+          IO.puts("Failed to delete firmware file: #{reason}")
+      end
+      LogsLoger.write("Firmware "<>name<>" deleted.", "info", nil)
+      {:noreply, assign(socket, :uploaded_files, fetch_firmwares())}
+    end
+
+    def handle_event("show_firmware", %{"name" => name}, socket) do
+      firmware = Enum.find(socket.assigns.uploaded_files, fn firmware -> firmware[:name] == name end)
+      {:noreply, assign(socket, show_firmware_modal: true, selected_firmware: firmware)}
+    end
+
+    def handle_event("bulk_update_device", %{"firmware" => firmware}, socket) do
+
+      case DevicesUpdate.trigger_update(socket.assigns.bulk_ids, firmware) do
+        :ok ->
+          {:noreply, socket |> put_flash(:updating, "Devices are updating...") |> assign(show_update_modal: false)}
+        {:error, reason} ->
+          {:noreply, socket |> put_flash(:error, "Update failed: #{reason}")}
+      end
+
+      {:noreply, socket}
+    end
+
+    def handle_event("show_update_modal", %{"id" => device_id}, socket) do
+      device=
+        socket.assigns.devices
+        |> Enum.find(fn device -> device.name == device_id end)
+        |> case do
+          nil -> raise "Device not found"
+          device -> device
+        end
+      {:noreply, assign(socket, show_update_modal: true, selected_device: device)}
+    end
+
+    def handle_event("update_device", %{"id" => device_id, "firmware" => firmware}, socket) do
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.name == device_id do
+            Map.put(device, :state, "updating")
+          else
+            device
+          end
+        end)
+
+      case DevicesUpdate.trigger_update([device_id], firmware) do
+        :ok ->
+          LogsLoger.write("Device "<>device_id<>" started updating.", "info", device_id)
+          {:noreply, socket |> put_flash(:updating, "Device is updating...") |> assign(show_update_modal: false, is_updating: true, devices: updated_devices)}
+        {:error, reason} ->
+          LogsLoger.write("Device "<>device_id<>" failed to start update. #{reason}", "error", device_id)
+          {:noreply, socket |> put_flash(:error, "Update failed: #{reason}")}
+      end
+    end
+
+    def handle_event("fake_update_device", %{"id" => device_id, "firmware" => firmware}, socket) do
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.name == device_id do
+            Map.put(device, :state, "updating")
+          else
+            device
+          end
+        end)
+
+        case device_id do
+          "DT_nemo_1" ->
+            NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin1.change_firmware(firmware)
+          "DT_nemo_2" ->
+            NemoCloudServices.DeviceDigitalTwin.DeviceDigitalTwin2.change_firmware(firmware)
+          _ -> 1
+        end
+
+        case :ok do
+          :ok ->
+            #LogsLoger.write("Device "<>device_id<>" started updating.", "info", device_id)
+            {:noreply, socket |> put_flash(:updating, "Device is updating...") |> assign(show_update_modal: false, is_updating: true, devices: updated_devices)}
+          {:error, reason} ->
+            #LogsLoger.write("Device "<>device_id<>" failed to start update. #{reason}", "error", device_id)
+            {:noreply, socket |> put_flash(:error, "Update failed: #{reason}")}
+        end
+    end
+
+    def handle_event("toggle_online_filter", _params, socket) do
+      new_filter_online = !socket.assigns.filter_online
+      {:noreply, assign(socket, :filter_online, new_filter_online)}
+    end
+
+    def handle_event("filter_type", %{"filter_type" => selected_types}, socket) do
+      filter_types = selected_types || []
+
+      {:noreply, assign(socket, :filter_type, filter_types)}
+    end
+
+    def handle_event("filter_type", _params, socket) do
+      {:noreply, socket}
+    end
+
+    def handle_event("selected_firmware", %{"filename" => filename}, socket) do
+      {:noreply, assign(socket, :selected_firmware, filename)}
+    end
+
+    def handle_event("ssh_connect", %{"id" => device_id, "value" => _value}, socket) do
+      #LogsLoger.write("User is starting SSH connection with "<>device_id, "info", device_id)
+      #MqttClient.publish_admin_in(device_id, Jason.encode!(%{"ssh_tunnel" => "open"}))
+      {:noreply, socket}
+    end
+
+    def handle_event("ssh_connect", %{"id" => device_id}, socket) do
+      #LogsLoger.write("User is closing SSH connection with "<>device_id, "info", device_id)
+      #MqttClient.publish_admin_in(device_id, Jason.encode!(%{"ssh_tunnel" => "close"}))
+      {:noreply, socket}
+    end
+
+    defp handle_progress(:csv_file, entry, socket) do
+      if entry.done? do
+        data = consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
+          case File.read(path) do
+            {:ok, content} ->
+              device_ids = content
+                |> String.split("\r\n", trim: true)
+                |> Enum.filter(&(&1 != "device_id"))
+              {:ok, device_ids}
+
+            {:error, reason} ->
+              IO.inspect("Failed to read CSV file: #{reason}", label: "File Read Error")
+              {:error, "Failed to read CSV file: #{reason}"}
+          end
+        end)
+
+        device_ids =
+          data
+          |> List.flatten()
+          |> Enum.map(&String.trim_trailing(&1, ","))
+          |> Enum.reject(&(&1 == "device_id"))
+
+        {:noreply, assign(socket, :bulk_ids, device_ids)}
+      else
+        {:noreply, socket}
+      end
+    end
+  end
diff --git a/lib/nemo_cloud_services_web/live/firmware_live/index.ex b/lib/nemo_cloud_services_web/live/firmware_live/index.ex
new file mode 100644
index 0000000000000000000000000000000000000000..55fe9ba0d5b1cdc1bd9cb1812bee74a97dcc9bb2
--- /dev/null
+++ b/lib/nemo_cloud_services_web/live/firmware_live/index.ex
@@ -0,0 +1,44 @@
+defmodule NemoCloudServicesWeb.FirmwareLive.Index do
+  use NemoCloudServicesWeb, :live_view
+
+  def mount(_params, _session, socket) do
+    {:ok, load_files(socket)}
+  end
+
+  def handle_params(_params, _uri, socket) do
+    {:noreply, socket}
+  end
+
+  def render(assigns) do
+    ~H"""
+    <h1 class="text-fuchsia-600 text-2xl">Available Firmware Files</h1>
+
+    <ul>
+      <%= for file <- @files do %>
+        <li>
+          <a href={~p"/firmwares/download/#{file.name}"}><%= file.name %></a>
+        </li>
+      <% end %>
+    </ul>
+    """
+  end
+
+  defp load_files(socket) do
+    firmware_dir =
+      Application.get_env(:nemo_cloud_services, :firmware_dir) ||
+        "/absolute/path/to/your/firmware/directory"
+
+    files =
+      case File.ls(firmware_dir) do
+        {:ok, file_list} -> file_list
+        {:error, _reason} -> []
+      end
+      |> Enum.filter(&String.ends_with?(&1, [".fw", ".mp4"]))
+      |> Enum.filter(fn file ->
+        File.regular?(Path.join(firmware_dir, file))
+      end)
+      |> Enum.map(&%{name: &1})
+
+    assign(socket, files: files)
+  end
+end
diff --git a/lib/nemo_cloud_services_web/live/ota_update_live.ex b/lib/nemo_cloud_services_web/live/ota_update_live.ex
new file mode 100644
index 0000000000000000000000000000000000000000..a100fb0344ed16955de393c4b6b15c541f36b58f
--- /dev/null
+++ b/lib/nemo_cloud_services_web/live/ota_update_live.ex
@@ -0,0 +1,688 @@
+defmodule NemoCloudServicesWeb.OTAUpdateLive do
+  require Logger
+  alias NemoCloudServices.Logs
+  alias NemoCloudServices.Workbook
+  use NemoCloudServicesWeb, :live_view
+  alias NemoCloudServices.Utils.DevicesUpdate
+  alias NemoCloudServicesWeb.CustomComponents
+  alias NemoCloudServices.Conn.MqttClient
+  alias NemoCloudServices.Repo
+  alias NemoCloudServices.Devices
+  alias NemoCloudServices.Utils.LogsLoger
+  alias NemoCloudServices.Utils.WorkbookLoger
+
+  alias NemoCloudServices.Devices.Device
+
+    @spec mount(any(), any(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
+    def mount(_params, _session, socket) do
+      if connected?(socket) do
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_other")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_pingpong")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "admin_out_ssh")
+        Phoenix.PubSub.subscribe(NemoCloudServices.PubSub, "status")
+      end
+
+      active_devices = MqttClient.get_active_devices()
+
+      socket =
+        socket
+        |> assign(:devices, active_devices)
+        |> assign(:uploaded_files, fetch_firmwares())
+        |> assign(:show_modal, false)
+        |> assign(:selected_device, nil)
+        |> assign(:selected_firmware, nil)
+        |> assign(:show_firmware_modal, false)
+        |> assign(:show_update_modal, false)
+        |> assign(:filter_online, false)
+        |> assign(:filter_type, ["GW", "ASM"])
+        |> assign(:current_view, "dashboard")
+        |> assign(:search_term, "")
+        |> assign(:bulk_ids, [])
+        |> assign(:is_loading, true)
+        |> assign(:is_updating, false)
+        |> assign(:editing_firmware, nil)
+        |> assign(:workbook_messages, Workbook.list_messages())
+        |> allow_upload(:firmware, accept: ~w(.bin .hex .jpg .fw), max_entries: 1, max_file_size: 50_000_000)
+        |> allow_upload(:csv_file, auto_upload: true, accept: ~w(.csv), max_entries: 1, progress: &handle_progress/3)
+
+      {:ok, socket}
+    end
+
+    def render(assigns) do
+      ~H"""
+      <CustomComponents.topbar />
+
+      <CustomComponents.sidebar number_of_firmwares={Enum.count(@uploaded_files)} />
+
+      <%= if @current_view == "dashboard" do %>
+        <div id="dashboard" class="p-4 sm:ml-64">
+          <%= if @show_modal do %>
+            <CustomComponents.info_modal selected_device={@selected_device} />
+          <% end %>
+
+          <%= if @show_update_modal do %>
+            <CustomComponents.update_modal
+              uploaded_files={@uploaded_files}
+              device_id={@selected_device.name}
+              selected_firmware={@selected_firmware}
+            />
+          <% end %>
+
+          <div class="p-4">
+            <div class="grid grid-cols-3 gap-4 mb-6">
+              <CustomComponents.card value={"#{Enum.count(@devices)}"} desc="Number of devices"/>
+              <CustomComponents.card value={"#{Enum.count(@devices, fn device -> Map.get(device, :state) == "active" end)}"} desc="Online devices"/>
+              <CustomComponents.card value={"#{Enum.count(@devices, fn device -> Map.get(device, :state) == "updating" end)}"} desc="Curently updating"/>
+            </div>
+
+            <div class="flex items-center justify-center mb-4 mt-10">
+              <div class="relative overflow-x-auto sm:rounded-lg w-full">
+
+
+                <div class="flex flex-column sm:flex-row space-y-4 sm:space-y-0 items-center justify-between pb-4">
+                  <button
+                    class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mr-10"
+                    phx-click="manual_ping"
+                  >
+                  Manual ping
+                  </button>
+
+                  <CustomComponents.serch search_term={@search_term} />
+
+                  <CustomComponents.online_toggle filter_online={@filter_online} />
+
+                  <CustomComponents.filter_form filter_type={@filter_type} />
+                </div>
+
+                <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
+
+                  <CustomComponents.device_table
+                    devices={@devices}
+                    filter_online={@filter_online}
+                    filter_type={@filter_type}
+                    search_term={@search_term}
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "firmwares" do %>
+        <div id="firmwares" class="p-4 sm:ml-64">
+          <%= if @show_firmware_modal do %>
+            <CustomComponents.firmware_modal selected_firmware={@selected_firmware} />
+          <% end %>
+          <div class="p-4">
+            <form
+              id="upload-form"
+              phx-submit="upload_firmware"
+              phx-change="validate"
+              phx-drop-target={@uploads.firmware.ref}
+              enctype="multipart/form-data"
+              class="mb-6 flex flex-col w-1/2"
+            >
+              <label for="file_upload" class="block mb-2 text-sm font-medium text-gray-900">Upload file (Size limit: 50 MB)</label>
+              <div
+                id="drag-drop-area"
+                class="border-2 border-dashed border-gray-300 rounded-lg text-center"
+                phx-drop-target={@uploads.firmware.ref}
+              >
+                <.live_file_input
+                  upload={@uploads.firmware}
+                  id="file_upload"
+                  class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
+                />
+              </div>
+              <label for="firmware_info" class="block mb-2 text-sm font-medium text-gray-900 mt-4">Firmware info</label>
+              <textarea id="firmware_info" name="firmware_info" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Write firmware info..."></textarea>
+              <button
+                class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-6 w-full"
+                type="submit"
+                phx-disable-with="Uploading..."
+              >
+                Upload firmware
+              </button>
+            </form>
+
+            <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
+              <CustomComponents.firmware_table uploaded_files={@uploaded_files} editing_firmware={@editing_firmware}/>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "bulk_update" do %>
+        <div id="bulk_update" class="p-4 sm:ml-64">
+          <div class="p-4">
+
+            <form id="upgrade-form" phx-submit="bulk_update_device" phx-change="validate" phx-value-firmware={@selected_firmware} enctype="multipart/form-data">
+              <div class="w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6">
+                <div class="flex gap-12">
+                  <div class="w-1/2">
+                    <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Step 1:</strong> Upload list of devices</p>
+                    <div
+                      class="border-2 border-dashed border-gray-300 rounded-lg text-center mt-3"
+                      id="drag-drop-area"
+                      phx-drop-target={@uploads.csv_file.ref}
+                    >
+                      <.live_file_input upload={@uploads.csv_file} class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
+                    </div>
+                    <%= if @bulk_ids do %>
+                      <div class="mt-4">
+                        <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Device IDs:</strong></p>
+                        <ul>
+                          <%= for id <- @bulk_ids do %>
+                            <li class="text-base font-normal text-gray-900 dark:text-white"><%= id %></li>
+                          <% end %>
+                        </ul>
+                      </div>
+                    <% end %>
+                  </div>
+
+                  <div class="w-1/2 items-center">
+                    <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Example of .CSV file:</strong></p>
+                    <i class="text-base font-normal text-gray-900 dark:text-white">device_id,<br>
+                      nemo_gw_1,<br>
+                      nemo_gw_2,</i>
+                  </div>
+                </div>
+              </div>
+
+              <div class="w-full bg-white mt-6 rounded-lg shadow dark:bg-gray-800 p-4 md:p-6">
+                <p class="text-base font-normal text-gray-900 dark:text-white"><strong>Step 2:</strong> Select firmware </p>
+                <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 mt-3 rounded-lg">
+                  <thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-400 rounded-lg">
+                    <tr>
+                        <th scope="col" class="px-6 py-3">
+
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File name
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File size
+                        </th>
+                        <th scope="col" class="px-6 py-3">
+                            File date
+                        </th>
+                    </tr>
+                  </thead>
+                  <tbody>
+                    <%= for file <- @uploaded_files do %>
+                      <tr class="bg-gray-50 border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600">
+                          <td class="w-4 p-4">
+                              <div class="flex items-center">
+                                  <input id={file.name} type="checkbox" phx-value-filename={file.name} phx-click="selected_firmware" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
+                                  <label for="checkbox-table-search-2" class="sr-only">checkbox</label>
+                              </div>
+                          </td>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= file.name %>
+                          </th>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= Float.round(file.size, 2) %> kB
+                          </th>
+                          <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+                            <%= file.upload_date %>
+                          </th>
+                      </tr>
+                    <% end %>
+                  </tbody>
+                </table>
+              </div>
+              <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-4" type="submit" phx-disable-with="Uploading...">Upgrade</button>
+            </form>
+          </div>
+
+        </div>
+      <% end %>
+
+      <%= if @current_view == "logs" do %>
+        <div id="logs" class="p-4 sm:ml-64">
+
+          <div class="p-4">
+            <div class="bg-gray-100 p-3 rounded-lg">
+              <%=
+                data=Logs.list_messages()
+                for line <- Enum.reverse(data) do
+              %>
+              <li class="text-gray-700 break-words leading-10" >
+              <%= if line.type=="error" do%>
+                <a class="text-red-700"><%= line.inserted_at %>: <%= line.message %></a>
+                <span class="inline-flex items-center bg-red-200 text-red-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-red-500 rounded-full"></span>
+                  Error
+                </span>
+              <% else %>
+                <a><%= line.inserted_at %>: <%= line.message %></a>
+              <% end %>
+              </li>
+              <% end %>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <%= if @current_view == "workbook" do %>
+        <div id="workbook" class="p-4 sm:ml-64">
+
+          <div class="p-4 w-1/2">
+            <div>
+              <form
+                id="upload-form"
+                phx-submit="upload_workbook"
+                phx-change="validate"
+                enctype="multipart/form-data"
+                class="mb-6 flex flex-col"
+              >
+                <label for="message" class="block mb-2 text-sm font-medium text-gray-900">Enter message</label>
+                <textarea id="message" name="message" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Enter message..."></textarea>
+                <label for="user" class="block mb-2 mt-6 text-sm font-medium text-gray-900">Enter current user</label>
+                <input type="text" name="user" id="user" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Enter current user">
+                <button class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg mt-6" type="submit" phx-disable-with="Uploading...">Enter</button>
+              </form>
+            </div>
+
+            <div class="bg-gray-100 p-3 rounded-lg mt-6">
+              <%=
+                for line <- Enum.reverse(@workbook_messages) do
+              %>
+              <li class="text-gray-700 break-words leading-10" >
+                <a><%= line.inserted_at %>: <%= line.message %></a>
+                <span class="inline-flex items-center bg-blue-200 text-blue-800 ml-3 text-xs font-medium px-2.5 py-0.5 rounded-full">
+                  <span class="w-2 h-2 me-1 bg-blue-500 rounded-full"></span>
+                  <%= line.user %>
+                </span>
+              </li>
+              <% end %>
+            </div>
+          </div>
+        </div>
+      <% end %>
+      """
+    end
+
+    def fetch_firmwares do
+      firmwares =
+        Path.wildcard("firmware_files/*.fw")
+        |> Enum.map(fn path ->
+          txt_path = Path.rootname(path) <> ".txt"
+          firmware_info = if File.exists?(txt_path), do: File.read!(txt_path), else: "No info available"
+          %{
+            name: Path.basename(path),
+            size: File.stat!(path).size / 1024 / 1000,
+            upload_date: File.stat!(path).mtime |> NaiveDateTime.from_erl!(),
+            info: firmware_info
+          }
+        end)
+
+      firmwares
+    end
+
+    def handle_info({"status", clientid, status_info}, socket) do
+      disconnected_at = status_info["disconnected_at"]
+
+      state = if disconnected_at == nil, do: "active", else: "offline"
+
+      case Repo.get_by(Device, name: clientid) do
+        nil ->
+          IO.puts("Device not found: #{clientid}")
+
+        device ->
+          if device.state != "updating" do
+            LogsLoger.write("Device "<>clientid<>" went online.", "info", clientid)
+            Devices.update_device(device, %{state: state})
+            if state == "offline" do
+              LogsLoger.write("Device "<>clientid<>" went offline.", "info", clientid)
+              Devices.update_device(device, %{last_alive: DateTime.utc_now()})
+            end
+          end
+      end
+
+      updated_devices = Enum.map(socket.assigns.devices, fn device ->
+        if device.name == clientid && device.state != "updating" do
+          Map.put(device, :state, state)
+        else
+          device
+        end
+      end)
+
+      {:noreply, assign(socket, devices: updated_devices)}
+    end
+
+    def handle_info({"admin_out_pingpong", {clientid, _payload}}, socket) do
+      MqttClient.request_firmware_info(clientid)
+
+      {:noreply, socket}
+    end
+
+    def handle_info({"admin_out_ssh", {clientid, ssh_info}}, socket) do
+      socket =
+        case Jason.decode(ssh_info) do
+          {:ok, %{"ssh_tunnel" => "opened"}} ->
+            LogsLoger.write("SSH connection successfuly opened for device "<>clientid<>".", "info", clientid)
+            socket
+            |> update_device_ssh_state(clientid, true)
+            |> put_flash(:info, "SSH connection established successfully.")
+
+          {:ok, %{"ssh_tunnel" => "closed"}} ->
+            LogsLoger.write("SSH connection successfuly closed for device "<>clientid<>".", "info", clientid)
+            socket
+            |> update_device_ssh_state(clientid, false)
+            |> put_flash(:info, "SSH connection was closed.")
+
+          {:ok, %{"reason" => reason, "ssh_tunnel" => error}} ->
+            LogsLoger.write("SSH connection failed for device "<>clientid<>".", "error", clientid)
+            socket
+            |> update_device_ssh_state(clientid, false)
+            |> put_flash(:error, "SSH connection error: #{reason}")
+
+          _ ->
+            put_flash(socket, :error, "Unexpected SSH info format: #{inspect(ssh_info)}")
+        end
+
+      case Repo.get_by(Device, name: clientid) do
+        nil ->
+          IO.puts("Device not found: #{clientid}")
+        device ->
+          Devices.update_device(device, %{last_ssh_session: DateTime.utc_now()})
+      end
+
+      {:noreply, socket}
+    end
+
+    def handle_info({"admin_out_other", {clientid, payload}}, socket) do
+      inactive_version = get_inactive_firmware_version(payload)
+
+      attributes = %{
+        name: clientid,
+        state: "active",
+        fw_version: payload[payload["nerves_fw_active"]<>".nerves_fw_version"] || "unknown",
+        fw_filename: "#{clientid}_firmware.fw",
+        fw_updated_date: DateTime.utc_now(),
+        active_partition: payload["nerves_fw_active"] || "unknown",
+        prev_fw_version: inactive_version,
+        ssh_state: false
+      }
+
+      case upsert_device(attributes) do
+        {:ok, _device} ->
+          :ok
+        {:error, changeset} ->
+          Logger.error("Failed to update or insert device: #{inspect(changeset)}")
+      end
+
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.name == clientid do
+            Map.merge(device, attributes)
+          else
+            device
+          end
+        end)
+
+      response_socket =
+        if Enum.any?(updated_devices, fn device -> device.name == clientid end) && socket.assigns.is_updating == true &&
+          Enum.find(updated_devices, fn device -> device.name == clientid end)["nerves_fw_active"] !=
+            Enum.find(socket.assigns.devices, fn device -> device.name == clientid end)["nerves_fw_active"] do
+          put_flash(socket, :info, "#{clientid} updated successfully!")
+        else
+          socket
+        end
+      {:noreply, assign(response_socket, devices: updated_devices, is_updating: false)}
+    end
+
+    defp upsert_device(data) do
+      Repo.insert(
+        %Device{}
+        |> Device.changeset(data),
+        on_conflict: {:replace_all_except, [:name]},
+        conflict_target: :name
+      )
+    end
+
+    def get_inactive_firmware_version(payload) do
+      case payload["nerves_fw_active"] do
+        "a" -> payload["b.nerves_fw_version"]
+        "b" -> payload["a.nerves_fw_version"]
+        _ -> nil # Handle unexpected values or missing data
+      end
+    end
+
+    defp update_device_ssh_state(socket, clientid, ssh_state) do
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.id == clientid do
+            Map.put(device, :ssh_state, ssh_state)
+          else
+            device
+          end
+        end)
+
+      assign(socket, :devices, updated_devices)
+    end
+
+    def handle_event("manual_ping", _params, socket) do
+      NemoCloudServices.Conn.MqttClient.send_ping_for_active_devices()
+
+      updated_devices = Enum.map(socket.assigns.devices, fn device ->
+        updated_device = %{device | state: "offline"}
+        Devices.update_device(device, %{state: "offline"})
+        updated_device
+      end)
+
+      LogsLoger.write("Manual ping triggered by user.", "info", nil)
+      {:noreply, assign(socket, :devices, updated_devices)}
+    end
+
+    def handle_event("upload_workbook", %{"message" => message, "user" => user}, socket) do
+      WorkbookLoger.write(message,user)
+      {:noreply, assign(socket, workbook_messages: Workbook.list_messages())}
+    end
+
+    def handle_event("clear_flash", %{"type" => type}, socket) do
+      {:noreply, clear_flash(socket, String.to_atom(type))}
+    end
+
+    def handle_event("edit_device", %{"id" => device_id}, socket) do
+      device = Enum.find(socket.assigns.devices, fn device -> device.name == device_id end)
+      {:noreply, assign(socket, show_modal: true, selected_device: device)}
+    end
+
+    def handle_event("close_modal", _params, socket) do
+      {:noreply, assign(socket, show_modal: false, selected_device: nil)}
+    end
+
+    def handle_event("close_update_modal", _params, socket) do
+      {:noreply, assign(socket, show_update_modal: false, selected_device: nil)}
+    end
+
+    def handle_event("close_firmware_modal", _params, socket) do
+      {:noreply, assign(socket, show_firmware_modal: false, selected_firmware: nil)}
+    end
+
+    def handle_event("validate", _params, socket) do
+      {:noreply, socket}
+    end
+
+    def handle_event("change_view", %{"button" => view}, socket) do
+      IO.inspect(socket.assigns)
+      {:noreply, assign(socket, :current_view, view)}
+    end
+
+    def handle_event("search_device", %{"search_term" => search_term}, socket) do
+      {:noreply, assign(socket, :search_term, search_term)}
+    end
+
+    def handle_event("upload_firmware", %{"firmware_info" => firmware_info}, socket) do
+      consume_uploaded_entries(socket, :firmware, fn %{path: path} = _upload, entry ->
+        case entry do
+          %Phoenix.LiveView.UploadEntry{client_name: original_name} ->
+            dest_folder = Path.expand("./firmware_files")
+            File.mkdir_p!(dest_folder)
+            dest = Path.join(dest_folder, original_name)
+
+            info_file_path = Path.rootname(dest) <> ".txt"
+            File.write(info_file_path, firmware_info)
+
+            case File.cp(path, dest) do
+              :ok ->
+                  LogsLoger.write("Firmware "<>original_name<>" uploaded.", "info", nil)
+                  {:ok, "path"}
+              {:error, _reason} ->
+                {:postpone, nil}
+            end
+          _ ->
+            {:postpone, nil}
+        end
+      end)
+
+      {:noreply, assign(socket, :uploaded_files, fetch_firmwares())}
+    end
+
+    def handle_event("edit_firmware", %{"name" => name}, socket) do
+      firmware = Enum.find(socket.assigns.uploaded_files, fn file -> file.name == name end)
+
+      {:noreply, assign(socket, :editing_firmware, firmware)}
+    end
+
+    def handle_event("save_firmware_info", %{"name" => name, "firmware_info" => firmware_info}, socket) do
+      dest_folder = Path.expand("./firmware_files")
+      file_path = Path.join(dest_folder, name)
+      info_file_path = Path.rootname(file_path) <> ".txt"
+
+      File.write!(info_file_path, firmware_info)
+
+      updated_firmwares = fetch_firmwares()
+
+      {:noreply, assign(socket, uploaded_files: updated_firmwares, editing_firmware: nil)}
+    end
+
+    def handle_event("delete_firmware", %{"name" => name}, socket) do
+      firmware_path = "firmware_files/" <> name
+      txt_path = Path.rootname(firmware_path) <> ".txt"
+
+
+      case File.rm(firmware_path) do
+        :ok ->
+          case File.rm(txt_path) do
+            :ok -> IO.puts("Info file deleted successfully.")
+            {:error, :enoent} -> IO.puts("Info file not found; nothing to delete.")
+            {:error, reason} -> IO.puts("Failed to delete info file: #{reason}")
+          end
+        {:error, reason} ->
+          IO.puts("Failed to delete firmware file: #{reason}")
+      end
+      LogsLoger.write("Firmware "<>name<>" deleted.", "info", nil)
+      {:noreply, assign(socket, :uploaded_files, fetch_firmwares())}
+    end
+
+    def handle_event("show_firmware", %{"name" => name}, socket) do
+      firmware = Enum.find(socket.assigns.uploaded_files, fn firmware -> firmware[:name] == name end)
+      {:noreply, assign(socket, show_firmware_modal: true, selected_firmware: firmware)}
+    end
+
+    def handle_event("bulk_update_device", %{"firmware" => firmware}, socket) do
+
+      case DevicesUpdate.trigger_update(socket.assigns.bulk_ids, firmware) do
+        :ok ->
+          {:noreply, socket |> put_flash(:updating, "Devices are updating...") |> assign(show_update_modal: false)}
+        {:error, reason} ->
+          {:noreply, socket |> put_flash(:error, "Update failed: #{reason}")}
+      end
+
+      {:noreply, socket}
+    end
+
+    def handle_event("show_update_modal", %{"id" => device_id}, socket) do
+      device=
+        socket.assigns.devices
+        |> Enum.find(fn device -> device.name == device_id end)
+        |> case do
+          nil -> raise "Device not found"
+          device -> device
+        end
+      {:noreply, assign(socket, show_update_modal: true, selected_device: device)}
+    end
+
+    def handle_event("update_device", %{"id" => device_id, "firmware" => firmware}, socket) do
+      updated_devices =
+        Enum.map(socket.assigns.devices, fn device ->
+          if device.name == device_id do
+            Map.put(device, :state, "updating")
+          else
+            device
+          end
+        end)
+
+      case DevicesUpdate.trigger_update([device_id], firmware) do
+        :ok ->
+          LogsLoger.write("Device "<>device_id<>" started updating.", "info", device_id)
+          {:noreply, socket |> put_flash(:updating, "Device is updating...") |> assign(show_update_modal: false, is_updating: true, devices: updated_devices)}
+        {:error, reason} ->
+          LogsLoger.write("Device "<>device_id<>" failed to start update. #{reason}", "error", device_id)
+          {:noreply, socket |> put_flash(:error, "Update failed: #{reason}")}
+      end
+    end
+
+    def handle_event("toggle_online_filter", _params, socket) do
+      new_filter_online = !socket.assigns.filter_online
+      {:noreply, assign(socket, :filter_online, new_filter_online)}
+    end
+
+    def handle_event("filter_type", %{"filter_type" => selected_types}, socket) do
+      filter_types = selected_types || []
+
+      {:noreply, assign(socket, :filter_type, filter_types)}
+    end
+
+    def handle_event("filter_type", _params, socket) do
+      {:noreply, socket}
+    end
+
+    def handle_event("selected_firmware", %{"filename" => filename}, socket) do
+      {:noreply, assign(socket, :selected_firmware, filename)}
+    end
+
+    def handle_event("ssh_connect", %{"id" => device_id, "value" => _value}, socket) do
+      LogsLoger.write("User is starting SSH connection with "<>device_id, "info", device_id)
+      MqttClient.publish_admin_in(device_id, Jason.encode!(%{"ssh_tunnel" => "open"}))
+      {:noreply, socket}
+    end
+
+    def handle_event("ssh_connect", %{"id" => device_id}, socket) do
+      LogsLoger.write("User is closing SSH connection with "<>device_id, "info", device_id)
+      MqttClient.publish_admin_in(device_id, Jason.encode!(%{"ssh_tunnel" => "close"}))
+      {:noreply, socket}
+    end
+
+    defp handle_progress(:csv_file, entry, socket) do
+      if entry.done? do
+        data = consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
+          case File.read(path) do
+            {:ok, content} ->
+              device_ids = content
+                |> String.split("\r\n", trim: true)
+                |> Enum.filter(&(&1 != "device_id"))
+              {:ok, device_ids}
+
+            {:error, reason} ->
+              IO.inspect("Failed to read CSV file: #{reason}", label: "File Read Error")
+              {:error, "Failed to read CSV file: #{reason}"}
+          end
+        end)
+
+        device_ids =
+          data
+          |> List.flatten()
+          |> Enum.map(&String.trim_trailing(&1, ","))
+          |> Enum.reject(&(&1 == "device_id"))
+
+        {:noreply, assign(socket, :bulk_ids, device_ids)}
+      else
+        {:noreply, socket}
+      end
+    end
+  end
diff --git a/lib/nemo_cloud_services_web/plugs/verify_token.ex b/lib/nemo_cloud_services_web/plugs/verify_token.ex
new file mode 100644
index 0000000000000000000000000000000000000000..f33418531bb86588dea49ec833c7216471fd0458
--- /dev/null
+++ b/lib/nemo_cloud_services_web/plugs/verify_token.ex
@@ -0,0 +1,33 @@
+defmodule NemoCloudServicesWeb.Plugs.VerifyToken do
+  import Plug.Conn
+  import Phoenix.Controller, only: [ redirect: 2]
+
+  def init(opts), do: opts
+
+  def call(conn, _opts) do
+    case verify_login(conn) do
+      {:ok, _claims} ->
+        conn
+      {:error, _reason} ->
+        redirect_to_keycloak(conn)
+    end
+  end
+
+  defp verify_login(conn) do
+    token = get_session(conn, :token)
+
+    if token == nil do
+      {:error, :unauthenticated}
+    else
+      claims=[]
+      {:ok, claims}
+    end
+  end
+
+  defp redirect_to_keycloak(conn) do
+    IO.inspect(Keycloak.authorize_url!(prompt: "login"))
+    conn
+    |> redirect(external: Keycloak.authorize_url!(prompt: "login"))
+    |> halt()
+  end
+end
diff --git a/lib/nemo_cloud_services_web/router.ex b/lib/nemo_cloud_services_web/router.ex
new file mode 100644
index 0000000000000000000000000000000000000000..0e87417f212d94e30b8b10411ebf2bff23578c92
--- /dev/null
+++ b/lib/nemo_cloud_services_web/router.ex
@@ -0,0 +1,74 @@
+defmodule NemoCloudServicesWeb.Router do
+  alias NemoCloudServices.DtController
+  use NemoCloudServicesWeb, :router
+
+  pipeline :browser do
+    plug :accepts, ["html"]
+    plug :fetch_session
+    plug :fetch_live_flash
+    plug :put_root_layout, html: {NemoCloudServicesWeb.Layouts, :root}
+    plug :protect_from_forgery
+    plug :put_secure_browser_headers
+  end
+
+  pipeline :api do
+    plug :accepts, ["json"]
+    plug OpenApiSpex.Plug.PutApiSpec, module: NemoCloudServicesWeb.ApiSpec
+  end
+
+  pipeline :authenticated do
+    plug NemoCloudServicesWeb.Plugs.VerifyToken
+  end
+
+  scope "/" do
+    pipe_through [:browser]
+
+    get "/", NemoCloudServicesWeb.PageController, :home
+
+    live "/firmwares", NemoCloudServicesWeb.OTAUpdateLive
+
+    live "dev/firmwares", NemoCloudServicesWeb.DtLive.OtaUpdateLive
+
+    get "/firmwares/download/:filename", NemoCloudServicesWeb.FirmwareController, :download,
+      as: :firmware_download
+
+    get "/swaggerui", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi"
+
+    resources "/devices", NemoCloudServicesWeb.DeviceController
+  end
+
+  scope "/api" do
+    pipe_through :api
+    get "/openapi", OpenApiSpex.Plug.RenderSpec, []
+  end
+
+  scope "/api/v1" do
+    pipe_through :api
+    get "/devices", NemoCloudServicesWeb.Api.DtController, :list_devices
+    get "/:device_id/info", NemoCloudServicesWeb.Api.DtController, :get_device_info
+    get "/:device_id/data", NemoCloudServicesWeb.Api.DtController, :get_device_data
+    post "/:device_id/firmware", NemoCloudServicesWeb.Api.DtController, :change_firmware
+  end
+
+  # Other scopes may use custom stacks.
+  # scope "/api", NemoCloudServicesWeb do
+  #   pipe_through :api
+  # end
+
+  # Enable LiveDashboard and Swoosh mailbox preview in development
+  if Application.compile_env(:nemo_cloud_services, :dev_routes) do
+    # If you want to use the LiveDashboard in production, you should put
+    # it behind authentication and allow only admins to access it.
+    # If your application does not have an admins-only section yet,
+    # you can use Plug.BasicAuth to set up some basic authentication
+    # as long as you are also using SSL (which you should anyway).
+    import Phoenix.LiveDashboard.Router
+
+    scope "/dev" do
+      pipe_through :browser
+
+      live_dashboard "/dashboard", metrics: NemoCloudServicesWeb.Telemetry
+      forward "/mailbox", Plug.Swoosh.MailboxPreview
+    end
+  end
+end
diff --git a/lib/nemo_cloud_services_web/telemetry.ex b/lib/nemo_cloud_services_web/telemetry.ex
new file mode 100644
index 0000000000000000000000000000000000000000..db455d43d08bec417f3fb44590923d982a077254
--- /dev/null
+++ b/lib/nemo_cloud_services_web/telemetry.ex
@@ -0,0 +1,92 @@
+defmodule NemoCloudServicesWeb.Telemetry do
+  use Supervisor
+  import Telemetry.Metrics
+
+  def start_link(arg) do
+    Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+  end
+
+  @impl true
+  def init(_arg) do
+    children = [
+      # Telemetry poller will execute the given period measurements
+      # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
+      {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+      # Add reporters as children of your supervision tree.
+      # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
+    ]
+
+    Supervisor.init(children, strategy: :one_for_one)
+  end
+
+  def metrics do
+    [
+      # Phoenix Metrics
+      summary("phoenix.endpoint.start.system_time",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.endpoint.stop.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.start.system_time",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.exception.duration",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.stop.duration",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.socket_connected.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.channel_joined.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.channel_handled_in.duration",
+        tags: [:event],
+        unit: {:native, :millisecond}
+      ),
+
+      # Database Metrics
+      summary("nemo_cloud_services.repo.query.total_time",
+        unit: {:native, :millisecond},
+        description: "The sum of the other measurements"
+      ),
+      summary("nemo_cloud_services.repo.query.decode_time",
+        unit: {:native, :millisecond},
+        description: "The time spent decoding the data received from the database"
+      ),
+      summary("nemo_cloud_services.repo.query.query_time",
+        unit: {:native, :millisecond},
+        description: "The time spent executing the query"
+      ),
+      summary("nemo_cloud_services.repo.query.queue_time",
+        unit: {:native, :millisecond},
+        description: "The time spent waiting for a database connection"
+      ),
+      summary("nemo_cloud_services.repo.query.idle_time",
+        unit: {:native, :millisecond},
+        description:
+          "The time the connection spent waiting before being checked out for the query"
+      ),
+
+      # VM Metrics
+      summary("vm.memory.total", unit: {:byte, :kilobyte}),
+      summary("vm.total_run_queue_lengths.total"),
+      summary("vm.total_run_queue_lengths.cpu"),
+      summary("vm.total_run_queue_lengths.io")
+    ]
+  end
+
+  defp periodic_measurements do
+    [
+      # A module, function and arguments to be invoked periodically.
+      # This function must call :telemetry.execute/3 and a metric must be added above.
+      # {NemoCloudServicesWeb, :count_users, []}
+    ]
+  end
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000000000000000000000000000000000000..cd075b6d4001e6fd3c0e2cd6bd537d41072e3d54
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,95 @@
+defmodule NemoCloudServices.MixProject do
+  use Mix.Project
+
+  def project do
+    [
+      app: :nemo_cloud_services,
+      version: "0.1.0",
+      elixir: "~> 1.14",
+      elixirc_paths: elixirc_paths(Mix.env()),
+      start_permanent: Mix.env() == :prod,
+      aliases: aliases(),
+      deps: deps()
+    ]
+  end
+
+  # Configuration for the OTP application.
+  #
+  # Type `mix help compile.app` for more information.
+  def application do
+    [
+      mod: {NemoCloudServices.Application, []},
+      extra_applications: [:logger, :runtime_tools, :emqtt, :logger_file_backend,]
+    ]
+  end
+
+  # Specifies which paths to compile per environment.
+  defp elixirc_paths(:test), do: ["lib", "test/support"]
+  defp elixirc_paths(_), do: ["lib"]
+
+  # Specifies your project dependencies.
+  #
+  # Type `mix help deps` for examples and options.
+  defp deps do
+    [
+      {:phoenix, "~> 1.7.14"},
+      {:phoenix_ecto, "~> 4.5"},
+      {:ecto_sql, "~> 3.10"},
+      {:postgrex, ">= 0.0.0"},
+      {:phoenix_html, "~> 4.1"},
+      {:phoenix_live_reload, "~> 1.2", only: :dev},
+      # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"},
+      {:phoenix_live_view, "~> 1.0.0-rc.1", override: true},
+      {:floki, ">= 0.30.0", only: :test},
+      {:phoenix_live_dashboard, "~> 0.8.3"},
+      {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
+      {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
+      {:heroicons,
+       github: "tailwindlabs/heroicons",
+       tag: "v2.1.1",
+       sparse: "optimized",
+       app: false,
+       compile: false,
+       depth: 1},
+      {:swoosh, "~> 1.5"},
+      {:finch, "~> 0.13"},
+      {:telemetry_metrics, "~> 1.0"},
+      {:telemetry_poller, "~> 1.0"},
+      {:gettext, "~> 0.20"},
+      {:jason, "~> 1.2"},
+      {:dns_cluster, "~> 0.1.1"},
+      {:bandit, "~> 1.5"},
+      {:req, "~> 0.5.6"},
+      {:emqtt, github: "emqx/emqtt", tag: "1.11.0", system_env: [{"BUILD_WITHOUT_QUIC", "1"}]},
+      {:net_address, "~> 0.3.1"},
+      {:nimble_csv, "~> 1.2"},
+      {:gun, git: "https://github.com/emqx/gun", tag: "1.3.7", override: true},
+      {:keycloak, git: "https://github.com/vanetix/elixir-keycloak.git"},
+      {:cowlib, "2.12.1", override: true},
+      {:logger_file_backend, "~> 0.0.14"},
+      {:open_api_spex, "~> 3.21"}
+    ]
+  end
+
+  # Aliases are shortcuts or tasks specific to the current project.
+  # For example, to install project dependencies and perform other setup tasks, run:
+  #
+  #     $ mix setup
+  #
+  # See the documentation for `Mix` for more info on aliases.
+  defp aliases do
+    [
+      setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
+      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
+      "ecto.reset": ["ecto.drop", "ecto.setup"],
+      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
+      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+      "assets.build": ["tailwind nemo_cloud_services", "esbuild nemo_cloud_services"],
+      "assets.deploy": [
+        "tailwind nemo_cloud_services --minify",
+        "esbuild nemo_cloud_services --minify",
+        "phx.digest"
+      ]
+    ]
+  end
+end
diff --git a/mix.lock b/mix.lock
new file mode 100644
index 0000000000000000000000000000000000000000..c5e636bac2d0287884df8f6fa4ebbef239043d10
--- /dev/null
+++ b/mix.lock
@@ -0,0 +1,56 @@
+%{
+  "bandit": {:hex, :bandit, "1.6.5", "24096d6232e0d050096acec96a0a382c44de026f9b591b883ed45497e1ef4916", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "b6b91f630699c8b41f3f0184bd4f60b281e19a336ad9dc1a0da90637b6688332"},
+  "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"},
+  "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
+  "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
+  "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
+  "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
+  "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
+  "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
+  "emqtt": {:git, "https://github.com/emqx/emqtt.git", "c815a18f9be46a7cace5620ef42aec468fc47552", [tag: "1.11.0"]},
+  "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
+  "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
+  "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
+  "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
+  "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
+  "getopt": {:hex, :getopt, "1.0.2", "33d9b44289fe7ad08627ddfe1d798e30b2da0033b51da1b3a2d64e72cd581d02", [:rebar3], [], "hexpm", "a0029aea4322fb82a61f6876a6d9c66dc9878b6cb61faa13df3187384fd4ea26"},
+  "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
+  "gun": {:git, "https://github.com/emqx/gun", "4faea40b9a8ca1eac5288355f8202e0cea379d50", [tag: "1.3.7"]},
+  "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
+  "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
+  "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+  "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
+  "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
+  "keycloak": {:git, "https://github.com/vanetix/elixir-keycloak.git", "c712e8821237a3f029b7b58d764edf43d1bceda8", []},
+  "logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"},
+  "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
+  "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
+  "net_address": {:hex, :net_address, "0.3.1", "70832e5a35a2c9f2d6e5bcf43e3164b9c4f7515ca7cc8740d2d38aab39ffa0e9", [:mix], [], "hexpm", "eb20da348f1ad88fdfaaa4e8923a3906ac54b54cb67ac1731f86bce07cb2b081"},
+  "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
+  "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+  "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+  "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"},
+  "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"},
+  "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"},
+  "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"},
+  "phoenix_html": {:hex, :phoenix_html, "4.2.0", "83a4d351b66f472ebcce242e4ae48af1b781866f00ef0eb34c15030d4e2069ac", [:mix], [], "hexpm", "9713b3f238d07043583a94296cc4bbdceacd3b3a6c74667f4df13971e7866ec8"},
+  "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"},
+  "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
+  "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.2", "e7b1dd68c86326e2c45cc81da41e332cc8aa7228a7161e2c811dcd7f1dd14db1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8a40265b0cd7d3a35f136dfa3cc048e3b198fc3718763411a78c323a44ebebee"},
+  "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
+  "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
+  "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
+  "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
+  "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
+  "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"},
+  "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"},
+  "swoosh": {:hex, :swoosh, "1.17.6", "27ff070f96246e35b7105ab1c52b2b689f523a3cb83ed9faadb2f33bd653ccba", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9798f3e72165f40c950f6762c06dab68afcdcf616138fc4a07965c09c250e1e2"},
+  "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},
+  "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+  "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
+  "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
+  "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"},
+  "thousand_island": {:hex, :thousand_island, "1.3.9", "095db3e2650819443e33237891271943fad3b7f9ba341073947581362582ab5a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25ab4c07badadf7f87adb4ab414e0ed374e5f19e72503aa85132caa25776e54f"},
+  "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+  "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
+}
diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po
new file mode 100644
index 0000000000000000000000000000000000000000..844c4f5cea7b7d15edcafcfa1c45a820b4724ea2
--- /dev/null
+++ b/priv/gettext/en/LC_MESSAGES/errors.po
@@ -0,0 +1,112 @@
+## `msgid`s in this file come from POT (.pot) files.
+##
+## Do not add, change, or remove `msgid`s manually here as
+## they're tied to the ones in the corresponding POT file
+## (with the same domain).
+##
+## Use `mix gettext.extract --merge` or `mix gettext.merge`
+## to merge POT files into PO files.
+msgid ""
+msgstr ""
+"Language: en\n"
+
+## From Ecto.Changeset.cast/4
+msgid "can't be blank"
+msgstr ""
+
+## From Ecto.Changeset.unique_constraint/3
+msgid "has already been taken"
+msgstr ""
+
+## From Ecto.Changeset.put_change/3
+msgid "is invalid"
+msgstr ""
+
+## From Ecto.Changeset.validate_acceptance/3
+msgid "must be accepted"
+msgstr ""
+
+## From Ecto.Changeset.validate_format/3
+msgid "has invalid format"
+msgstr ""
+
+## From Ecto.Changeset.validate_subset/3
+msgid "has an invalid entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_exclusion/3
+msgid "is reserved"
+msgstr ""
+
+## From Ecto.Changeset.validate_confirmation/3
+msgid "does not match confirmation"
+msgstr ""
+
+## From Ecto.Changeset.no_assoc_constraint/3
+msgid "is still associated with this entry"
+msgstr ""
+
+msgid "are still associated with this entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_length/3
+msgid "should have %{count} item(s)"
+msgid_plural "should have %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be %{count} character(s)"
+msgid_plural "should be %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be %{count} byte(s)"
+msgid_plural "should be %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at least %{count} item(s)"
+msgid_plural "should have at least %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at least %{count} character(s)"
+msgid_plural "should be at least %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at least %{count} byte(s)"
+msgid_plural "should be at least %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at most %{count} item(s)"
+msgid_plural "should have at most %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at most %{count} character(s)"
+msgid_plural "should be at most %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at most %{count} byte(s)"
+msgid_plural "should be at most %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+## From Ecto.Changeset.validate_number/3
+msgid "must be less than %{number}"
+msgstr ""
+
+msgid "must be greater than %{number}"
+msgstr ""
+
+msgid "must be less than or equal to %{number}"
+msgstr ""
+
+msgid "must be greater than or equal to %{number}"
+msgstr ""
+
+msgid "must be equal to %{number}"
+msgstr ""
diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot
new file mode 100644
index 0000000000000000000000000000000000000000..eef2de2ba4d91adb99805c4fe43e0c7fb8891637
--- /dev/null
+++ b/priv/gettext/errors.pot
@@ -0,0 +1,109 @@
+## This is a PO Template file.
+##
+## `msgid`s here are often extracted from source code.
+## Add new translations manually only if they're dynamic
+## translations that can't be statically extracted.
+##
+## Run `mix gettext.extract` to bring this file up to
+## date. Leave `msgstr`s empty as changing them here has no
+## effect: edit them in PO (`.po`) files instead.
+## From Ecto.Changeset.cast/4
+msgid "can't be blank"
+msgstr ""
+
+## From Ecto.Changeset.unique_constraint/3
+msgid "has already been taken"
+msgstr ""
+
+## From Ecto.Changeset.put_change/3
+msgid "is invalid"
+msgstr ""
+
+## From Ecto.Changeset.validate_acceptance/3
+msgid "must be accepted"
+msgstr ""
+
+## From Ecto.Changeset.validate_format/3
+msgid "has invalid format"
+msgstr ""
+
+## From Ecto.Changeset.validate_subset/3
+msgid "has an invalid entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_exclusion/3
+msgid "is reserved"
+msgstr ""
+
+## From Ecto.Changeset.validate_confirmation/3
+msgid "does not match confirmation"
+msgstr ""
+
+## From Ecto.Changeset.no_assoc_constraint/3
+msgid "is still associated with this entry"
+msgstr ""
+
+msgid "are still associated with this entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_length/3
+msgid "should have %{count} item(s)"
+msgid_plural "should have %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be %{count} character(s)"
+msgid_plural "should be %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be %{count} byte(s)"
+msgid_plural "should be %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at least %{count} item(s)"
+msgid_plural "should have at least %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at least %{count} character(s)"
+msgid_plural "should be at least %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at least %{count} byte(s)"
+msgid_plural "should be at least %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at most %{count} item(s)"
+msgid_plural "should have at most %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at most %{count} character(s)"
+msgid_plural "should be at most %{count} character(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should be at most %{count} byte(s)"
+msgid_plural "should be at most %{count} byte(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+## From Ecto.Changeset.validate_number/3
+msgid "must be less than %{number}"
+msgstr ""
+
+msgid "must be greater than %{number}"
+msgstr ""
+
+msgid "must be less than or equal to %{number}"
+msgstr ""
+
+msgid "must be greater than or equal to %{number}"
+msgstr ""
+
+msgid "must be equal to %{number}"
+msgstr ""
diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs
new file mode 100644
index 0000000000000000000000000000000000000000..49f9151ed22c4d59136609119e01e3a8f75ab6d6
--- /dev/null
+++ b/priv/repo/migrations/.formatter.exs
@@ -0,0 +1,4 @@
+[
+  import_deps: [:ecto_sql],
+  inputs: ["*.exs"]
+]
diff --git a/priv/repo/migrations/20241120110732_create_devices.exs b/priv/repo/migrations/20241120110732_create_devices.exs
new file mode 100644
index 0000000000000000000000000000000000000000..7ebeab8afdf4a6354613792b7f81eeb0464a87ee
--- /dev/null
+++ b/priv/repo/migrations/20241120110732_create_devices.exs
@@ -0,0 +1,19 @@
+defmodule NemoCloudServices.Repo.Migrations.CreateDevices do
+  use Ecto.Migration
+
+  def change do
+    create table(:devices) do
+      add :name, :string
+      add :state, :string
+      add :fw_version, :string
+      add :fw_filename, :string
+      add :fw_updated_date, :utc_datetime
+      add :active_partition, :string
+      add :last_ssh_session, :utc_datetime
+      add :ssh_state, :boolean, default: false, null: false
+      add :last_alive, :utc_datetime
+
+      timestamps(type: :utc_datetime)
+    end
+  end
+end
diff --git a/priv/repo/migrations/20241209081322_logs.exs b/priv/repo/migrations/20241209081322_logs.exs
new file mode 100644
index 0000000000000000000000000000000000000000..f1adb6293ffb356d37f55f4d1edccecfcd835f4e
--- /dev/null
+++ b/priv/repo/migrations/20241209081322_logs.exs
@@ -0,0 +1,13 @@
+defmodule NemoCloudServices.Repo.Migrations.Logs do
+  use Ecto.Migration
+
+  def change do
+    create table(:logs) do
+      add :message, :string
+      add :type, :string
+      add :device, :string
+
+      timestamps(type: :utc_datetime)
+    end
+  end
+end
diff --git a/priv/repo/migrations/20241209101641_workbook.exs b/priv/repo/migrations/20241209101641_workbook.exs
new file mode 100644
index 0000000000000000000000000000000000000000..97f27a8a6c5d2cc45f042dc1c393744bc1a9cd02
--- /dev/null
+++ b/priv/repo/migrations/20241209101641_workbook.exs
@@ -0,0 +1,12 @@
+defmodule NemoCloudServices.Repo.Migrations.Workbook do
+  use Ecto.Migration
+
+  def change do
+    create table(:workbook) do
+      add :message, :string
+      add :user, :string
+
+      timestamps(type: :utc_datetime)
+    end
+  end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
new file mode 100644
index 0000000000000000000000000000000000000000..4ce94cfd88ae19e06b1fb7c75722042e1075ad4f
--- /dev/null
+++ b/priv/repo/seeds.exs
@@ -0,0 +1,11 @@
+# Script for populating the database. You can run it as:
+#
+#     mix run priv/repo/seeds.exs
+#
+# Inside the script, you can read and write to any of your
+# repositories directly:
+#
+#     NemoCloudServices.Repo.insert!(%NemoCloudServices.SomeSchema{})
+#
+# We recommend using the bang functions (`insert!`, `update!`
+# and so on) as they will fail if something goes wrong.
diff --git a/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico b/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f
Binary files /dev/null and b/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico differ
diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f
Binary files /dev/null and b/priv/static/favicon.ico differ
diff --git a/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9f26babac25b9f22a523ea81335cf5a9559f373c
--- /dev/null
+++ b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
+  <path
+    d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
+    fill="#FD4F00"
+  />
+</svg>
diff --git a/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz
new file mode 100644
index 0000000000000000000000000000000000000000..1f3179cea0716bd4be9ccd18af26a59adc008012
Binary files /dev/null and b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz differ
diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9f26babac25b9f22a523ea81335cf5a9559f373c
--- /dev/null
+++ b/priv/static/images/logo.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
+  <path
+    d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
+    fill="#FD4F00"
+  />
+</svg>
diff --git a/priv/static/images/logo.svg.gz b/priv/static/images/logo.svg.gz
new file mode 100644
index 0000000000000000000000000000000000000000..1f3179cea0716bd4be9ccd18af26a59adc008012
Binary files /dev/null and b/priv/static/images/logo.svg.gz differ
diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt
new file mode 100644
index 0000000000000000000000000000000000000000..26e06b5f19e872df2c99ee3b1c072e65b8ee7fd7
--- /dev/null
+++ b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt
@@ -0,0 +1,5 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+#
+# To ban all spiders from the entire site uncomment the next two lines:
+# User-agent: *
+# Disallow: /
diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz
new file mode 100644
index 0000000000000000000000000000000000000000..043be337a41f4dd7232202988a782fd515bf5af3
Binary files /dev/null and b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz differ
diff --git a/priv/static/robots.txt b/priv/static/robots.txt
new file mode 100644
index 0000000000000000000000000000000000000000..26e06b5f19e872df2c99ee3b1c072e65b8ee7fd7
--- /dev/null
+++ b/priv/static/robots.txt
@@ -0,0 +1,5 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+#
+# To ban all spiders from the entire site uncomment the next two lines:
+# User-agent: *
+# Disallow: /
diff --git a/priv/static/robots.txt.gz b/priv/static/robots.txt.gz
new file mode 100644
index 0000000000000000000000000000000000000000..043be337a41f4dd7232202988a782fd515bf5af3
Binary files /dev/null and b/priv/static/robots.txt.gz differ
diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate
new file mode 100755
index 0000000000000000000000000000000000000000..013cbdbdc8f1beb0efbd5207b199cff36d247ac2
--- /dev/null
+++ b/rel/overlays/bin/migrate
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+cd -P -- "$(dirname -- "$0")"
+exec ./nemo_cloud_services eval NemoCloudServices.Release.migrate
diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat
new file mode 100755
index 0000000000000000000000000000000000000000..231fa8ae5ab6e28684fe6dbe5b1f50f1672e7e89
--- /dev/null
+++ b/rel/overlays/bin/migrate.bat
@@ -0,0 +1 @@
+call "%~dp0\nemo_cloud_services" eval NemoCloudServices.Release.migrate
diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server
new file mode 100755
index 0000000000000000000000000000000000000000..724c1860c24efec04d37350ad7eefd701f517368
--- /dev/null
+++ b/rel/overlays/bin/server
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+cd -P -- "$(dirname -- "$0")"
+PHX_SERVER=true exec ./nemo_cloud_services start
diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat
new file mode 100755
index 0000000000000000000000000000000000000000..82ed10a3dfd4a6fbf0b6fb3f38937f0c789ab54b
--- /dev/null
+++ b/rel/overlays/bin/server.bat
@@ -0,0 +1,2 @@
+set PHX_SERVER=true
+call "%~dp0\nemo_cloud_services" start
diff --git a/test/nemo_cloud_services/devices_test.exs b/test/nemo_cloud_services/devices_test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..90a2c8107aee9cf1d4b06e76ca5b3c573fe22fa9
--- /dev/null
+++ b/test/nemo_cloud_services/devices_test.exs
@@ -0,0 +1,75 @@
+defmodule NemoCloudServices.DevicesTest do
+  use NemoCloudServices.DataCase
+
+  alias NemoCloudServices.Devices
+
+  describe "devices" do
+    alias NemoCloudServices.Devices.Device
+
+    import NemoCloudServices.DevicesFixtures
+
+    @invalid_attrs %{name: nil, state: nil, fw_version: nil, fw_filename: nil, fw_updated_date: nil, active_partition: nil, last_ssh_session: nil, ssh_state: nil, last_alive: nil}
+
+    test "list_devices/0 returns all devices" do
+      device = device_fixture()
+      assert Devices.list_devices() == [device]
+    end
+
+    test "get_device!/1 returns the device with given id" do
+      device = device_fixture()
+      assert Devices.get_device!(device.id) == device
+    end
+
+    test "create_device/1 with valid data creates a device" do
+      valid_attrs = %{name: "some name", state: "some state", fw_version: "some fw_version", fw_filename: "some fw_filename", fw_updated_date: ~U[2024-11-19 11:07:00Z], active_partition: "some active_partition", last_ssh_session: ~U[2024-11-19 11:07:00Z], ssh_state: true, last_alive: ~U[2024-11-19 11:07:00Z]}
+
+      assert {:ok, %Device{} = device} = Devices.create_device(valid_attrs)
+      assert device.name == "some name"
+      assert device.state == "some state"
+      assert device.fw_version == "some fw_version"
+      assert device.fw_filename == "some fw_filename"
+      assert device.fw_updated_date == ~U[2024-11-19 11:07:00Z]
+      assert device.active_partition == "some active_partition"
+      assert device.last_ssh_session == ~U[2024-11-19 11:07:00Z]
+      assert device.ssh_state == true
+      assert device.last_alive == ~U[2024-11-19 11:07:00Z]
+    end
+
+    test "create_device/1 with invalid data returns error changeset" do
+      assert {:error, %Ecto.Changeset{}} = Devices.create_device(@invalid_attrs)
+    end
+
+    test "update_device/2 with valid data updates the device" do
+      device = device_fixture()
+      update_attrs = %{name: "some updated name", state: "some updated state", fw_version: "some updated fw_version", fw_filename: "some updated fw_filename", fw_updated_date: ~U[2024-11-20 11:07:00Z], active_partition: "some updated active_partition", last_ssh_session: ~U[2024-11-20 11:07:00Z], ssh_state: false, last_alive: ~U[2024-11-20 11:07:00Z]}
+
+      assert {:ok, %Device{} = device} = Devices.update_device(device, update_attrs)
+      assert device.name == "some updated name"
+      assert device.state == "some updated state"
+      assert device.fw_version == "some updated fw_version"
+      assert device.fw_filename == "some updated fw_filename"
+      assert device.fw_updated_date == ~U[2024-11-20 11:07:00Z]
+      assert device.active_partition == "some updated active_partition"
+      assert device.last_ssh_session == ~U[2024-11-20 11:07:00Z]
+      assert device.ssh_state == false
+      assert device.last_alive == ~U[2024-11-20 11:07:00Z]
+    end
+
+    test "update_device/2 with invalid data returns error changeset" do
+      device = device_fixture()
+      assert {:error, %Ecto.Changeset{}} = Devices.update_device(device, @invalid_attrs)
+      assert device == Devices.get_device!(device.id)
+    end
+
+    test "delete_device/1 deletes the device" do
+      device = device_fixture()
+      assert {:ok, %Device{}} = Devices.delete_device(device)
+      assert_raise Ecto.NoResultsError, fn -> Devices.get_device!(device.id) end
+    end
+
+    test "change_device/1 returns a device changeset" do
+      device = device_fixture()
+      assert %Ecto.Changeset{} = Devices.change_device(device)
+    end
+  end
+end
diff --git a/test/nemo_cloud_services_web/controllers/device_controller_test.exs b/test/nemo_cloud_services_web/controllers/device_controller_test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..984f02cfee81c33602fb3c3c47f8f6e23b2f3dff
--- /dev/null
+++ b/test/nemo_cloud_services_web/controllers/device_controller_test.exs
@@ -0,0 +1,84 @@
+defmodule NemoCloudServicesWeb.DeviceControllerTest do
+  use NemoCloudServicesWeb.ConnCase
+
+  import NemoCloudServices.DevicesFixtures
+
+  @create_attrs %{name: "some name", state: "some state", fw_version: "some fw_version", fw_filename: "some fw_filename", fw_updated_date: ~U[2024-11-19 11:07:00Z], active_partition: "some active_partition", last_ssh_session: ~U[2024-11-19 11:07:00Z], ssh_state: true, last_alive: ~U[2024-11-19 11:07:00Z]}
+  @update_attrs %{name: "some updated name", state: "some updated state", fw_version: "some updated fw_version", fw_filename: "some updated fw_filename", fw_updated_date: ~U[2024-11-20 11:07:00Z], active_partition: "some updated active_partition", last_ssh_session: ~U[2024-11-20 11:07:00Z], ssh_state: false, last_alive: ~U[2024-11-20 11:07:00Z]}
+  @invalid_attrs %{name: nil, state: nil, fw_version: nil, fw_filename: nil, fw_updated_date: nil, active_partition: nil, last_ssh_session: nil, ssh_state: nil, last_alive: nil}
+
+  describe "index" do
+    test "lists all devices", %{conn: conn} do
+      conn = get(conn, ~p"/devices")
+      assert html_response(conn, 200) =~ "Listing Devices"
+    end
+  end
+
+  describe "new device" do
+    test "renders form", %{conn: conn} do
+      conn = get(conn, ~p"/devices/new")
+      assert html_response(conn, 200) =~ "New Device"
+    end
+  end
+
+  describe "create device" do
+    test "redirects to show when data is valid", %{conn: conn} do
+      conn = post(conn, ~p"/devices", device: @create_attrs)
+
+      assert %{id: id} = redirected_params(conn)
+      assert redirected_to(conn) == ~p"/devices/#{id}"
+
+      conn = get(conn, ~p"/devices/#{id}")
+      assert html_response(conn, 200) =~ "Device #{id}"
+    end
+
+    test "renders errors when data is invalid", %{conn: conn} do
+      conn = post(conn, ~p"/devices", device: @invalid_attrs)
+      assert html_response(conn, 200) =~ "New Device"
+    end
+  end
+
+  describe "edit device" do
+    setup [:create_device]
+
+    test "renders form for editing chosen device", %{conn: conn, device: device} do
+      conn = get(conn, ~p"/devices/#{device}/edit")
+      assert html_response(conn, 200) =~ "Edit Device"
+    end
+  end
+
+  describe "update device" do
+    setup [:create_device]
+
+    test "redirects when data is valid", %{conn: conn, device: device} do
+      conn = put(conn, ~p"/devices/#{device}", device: @update_attrs)
+      assert redirected_to(conn) == ~p"/devices/#{device}"
+
+      conn = get(conn, ~p"/devices/#{device}")
+      assert html_response(conn, 200) =~ "some updated name"
+    end
+
+    test "renders errors when data is invalid", %{conn: conn, device: device} do
+      conn = put(conn, ~p"/devices/#{device}", device: @invalid_attrs)
+      assert html_response(conn, 200) =~ "Edit Device"
+    end
+  end
+
+  describe "delete device" do
+    setup [:create_device]
+
+    test "deletes chosen device", %{conn: conn, device: device} do
+      conn = delete(conn, ~p"/devices/#{device}")
+      assert redirected_to(conn) == ~p"/devices"
+
+      assert_error_sent 404, fn ->
+        get(conn, ~p"/devices/#{device}")
+      end
+    end
+  end
+
+  defp create_device(_) do
+    device = device_fixture()
+    %{device: device}
+  end
+end
diff --git a/test/nemo_cloud_services_web/controllers/error_html_test.exs b/test/nemo_cloud_services_web/controllers/error_html_test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..7d07d68558d6b0670fc11d7919fd3da2d7113846
--- /dev/null
+++ b/test/nemo_cloud_services_web/controllers/error_html_test.exs
@@ -0,0 +1,14 @@
+defmodule NemoCloudServicesWeb.ErrorHTMLTest do
+  use NemoCloudServicesWeb.ConnCase, async: true
+
+  # Bring render_to_string/4 for testing custom views
+  import Phoenix.Template
+
+  test "renders 404.html" do
+    assert render_to_string(NemoCloudServicesWeb.ErrorHTML, "404", "html", []) == "Not Found"
+  end
+
+  test "renders 500.html" do
+    assert render_to_string(NemoCloudServicesWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
+  end
+end
diff --git a/test/nemo_cloud_services_web/controllers/error_json_test.exs b/test/nemo_cloud_services_web/controllers/error_json_test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..b6e46d0a5553b6d30f5aaa41a1db7bd59714a21b
--- /dev/null
+++ b/test/nemo_cloud_services_web/controllers/error_json_test.exs
@@ -0,0 +1,12 @@
+defmodule NemoCloudServicesWeb.ErrorJSONTest do
+  use NemoCloudServicesWeb.ConnCase, async: true
+
+  test "renders 404" do
+    assert NemoCloudServicesWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+  end
+
+  test "renders 500" do
+    assert NemoCloudServicesWeb.ErrorJSON.render("500.json", %{}) ==
+             %{errors: %{detail: "Internal Server Error"}}
+  end
+end
diff --git a/test/nemo_cloud_services_web/controllers/page_controller_test.exs b/test/nemo_cloud_services_web/controllers/page_controller_test.exs
new file mode 100644
index 0000000000000000000000000000000000000000..0459a91a7fd0fba2227e57556ff982f1e78f4b96
--- /dev/null
+++ b/test/nemo_cloud_services_web/controllers/page_controller_test.exs
@@ -0,0 +1,8 @@
+defmodule NemoCloudServicesWeb.PageControllerTest do
+  use NemoCloudServicesWeb.ConnCase
+
+  test "GET /", %{conn: conn} do
+    conn = get(conn, ~p"/")
+    assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
+  end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
new file mode 100644
index 0000000000000000000000000000000000000000..d7623afb810f74fc41a79552b92bdc5a3804c6b1
--- /dev/null
+++ b/test/support/conn_case.ex
@@ -0,0 +1,38 @@
+defmodule NemoCloudServicesWeb.ConnCase do
+  @moduledoc """
+  This module defines the test case to be used by
+  tests that require setting up a connection.
+
+  Such tests rely on `Phoenix.ConnTest` and also
+  import other functionality to make it easier
+  to build common data structures and query the data layer.
+
+  Finally, if the test case interacts with the database,
+  we enable the SQL sandbox, so changes done to the database
+  are reverted at the end of every test. If you are using
+  PostgreSQL, you can even run database tests asynchronously
+  by setting `use NemoCloudServicesWeb.ConnCase, async: true`, although
+  this option is not recommended for other databases.
+  """
+
+  use ExUnit.CaseTemplate
+
+  using do
+    quote do
+      # The default endpoint for testing
+      @endpoint NemoCloudServicesWeb.Endpoint
+
+      use NemoCloudServicesWeb, :verified_routes
+
+      # Import conveniences for testing with connections
+      import Plug.Conn
+      import Phoenix.ConnTest
+      import NemoCloudServicesWeb.ConnCase
+    end
+  end
+
+  setup tags do
+    NemoCloudServices.DataCase.setup_sandbox(tags)
+    {:ok, conn: Phoenix.ConnTest.build_conn()}
+  end
+end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 0000000000000000000000000000000000000000..b37019c861d6cbf79aba00353dcf75db1c7ebe2f
--- /dev/null
+++ b/test/support/data_case.ex
@@ -0,0 +1,58 @@
+defmodule NemoCloudServices.DataCase do
+  @moduledoc """
+  This module defines the setup for tests requiring
+  access to the application's data layer.
+
+  You may define functions here to be used as helpers in
+  your tests.
+
+  Finally, if the test case interacts with the database,
+  we enable the SQL sandbox, so changes done to the database
+  are reverted at the end of every test. If you are using
+  PostgreSQL, you can even run database tests asynchronously
+  by setting `use NemoCloudServices.DataCase, async: true`, although
+  this option is not recommended for other databases.
+  """
+
+  use ExUnit.CaseTemplate
+
+  using do
+    quote do
+      alias NemoCloudServices.Repo
+
+      import Ecto
+      import Ecto.Changeset
+      import Ecto.Query
+      import NemoCloudServices.DataCase
+    end
+  end
+
+  setup tags do
+    NemoCloudServices.DataCase.setup_sandbox(tags)
+    :ok
+  end
+
+  @doc """
+  Sets up the sandbox based on the test tags.
+  """
+  def setup_sandbox(tags) do
+    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(NemoCloudServices.Repo, shared: not tags[:async])
+    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+  end
+
+  @doc """
+  A helper that transforms changeset errors into a map of messages.
+
+      assert {:error, changeset} = Accounts.create_user(%{password: "short"})
+      assert "password is too short" in errors_on(changeset).password
+      assert %{password: ["password is too short"]} = errors_on(changeset)
+
+  """
+  def errors_on(changeset) do
+    Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+      Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+      end)
+    end)
+  end
+end
diff --git a/test/support/fixtures/devices_fixtures.ex b/test/support/fixtures/devices_fixtures.ex
new file mode 100644
index 0000000000000000000000000000000000000000..01f18d37184db271cc48e7e513e74f1afa6d3a98
--- /dev/null
+++ b/test/support/fixtures/devices_fixtures.ex
@@ -0,0 +1,28 @@
+defmodule NemoCloudServices.DevicesFixtures do
+  @moduledoc """
+  This module defines test helpers for creating
+  entities via the `NemoCloudServices.Devices` context.
+  """
+
+  @doc """
+  Generate a device.
+  """
+  def device_fixture(attrs \\ %{}) do
+    {:ok, device} =
+      attrs
+      |> Enum.into(%{
+        active_partition: "some active_partition",
+        fw_filename: "some fw_filename",
+        fw_updated_date: ~U[2024-11-19 11:07:00Z],
+        fw_version: "some fw_version",
+        last_alive: ~U[2024-11-19 11:07:00Z],
+        last_ssh_session: ~U[2024-11-19 11:07:00Z],
+        name: "some name",
+        ssh_state: true,
+        state: "some state"
+      })
+      |> NemoCloudServices.Devices.create_device()
+
+    device
+  end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000000000000000000000000000000000000..259f6ed149a4a84803602babb844ff2e74a24c64
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1,2 @@
+ExUnit.start()
+Ecto.Adapters.SQL.Sandbox.mode(NemoCloudServices.Repo, :manual)