diff --git a/nix/esp-idf/default.nix b/nix/esp-idf/default.nix
index d46cba554877c9716c66c273bba15ca6d93051a6..2ccc3521a20b261a69b98352b630f882bdf92177 100644
--- a/nix/esp-idf/default.nix
+++ b/nix/esp-idf/default.nix
@@ -99,6 +99,18 @@ let
         pyyaml
       ];
     };
+
+    pyclang = buildPythonPackage rec {
+      pname = "pyclang";
+      version = "0.2.3";
+      src = fetchPypi {
+        inherit pname version;
+        sha256 = "sha256-gl3ZaK7/CpMICJlPdPqHfYXb/3MxlKiMpC4AyUtv7MY=";
+      };
+      propagatedBuildInputs = [
+        pyyaml
+      ];
+    };
   };
 in
 stdenv.mkDerivation rec {
@@ -110,7 +122,7 @@ stdenv.mkDerivation rec {
   # This is so that downstream derivations will have IDF_PATH set.
   setupHook = ./setup-hook.sh;
 
-  propagatedBuildInputs = with python3Packages; [
+  propagatedBuildInputs = (with python3Packages; [
     setuptools click future pyelftools urllib3
     jinja2 itsdangerous pyyaml
 
@@ -155,11 +167,10 @@ stdenv.mkDerivation rec {
         };
       });
     })
-
-    deps.esp-idf-monitor
-    deps.esp-idf-kconfig
-    deps.esp-idf-size
-  ];
+  ]) ++ (with deps; [
+    esp-idf-monitor esp-idf-kconfig esp-idf-size
+    pyclang
+  ]);
 
   patches = [
     ./rack-off-me-nix-mate.patch
diff --git a/nix/esp-llvm.nix b/nix/esp-llvm.nix
new file mode 100644
index 0000000000000000000000000000000000000000..e36a43bfc09e1cdb046c59d0d0a97f1ced96b1b9
--- /dev/null
+++ b/nix/esp-llvm.nix
@@ -0,0 +1,52 @@
+{ version ? "15.0.0-20221201"
+, hash ? "sha256-g55a36f0SYLootgoaA9uSqQ13NPR33ZeAvAVsEKGBW8="
+, stdenv
+, lib
+, fetchurl
+, makeWrapper
+, buildFHSUserEnv
+}:
+
+let
+  fhsEnv = buildFHSUserEnv {
+    name = "esp-llvm-env";
+    targetPkgs = pkgs: with pkgs; [ zlib libxml2 ];
+    runScript = "";
+  };
+in
+
+assert stdenv.system == "x86_64-linux";
+
+stdenv.mkDerivation rec {
+  pname = "esp-llvm";
+  inherit version;
+
+  src = fetchurl {
+    url = "https://github.com/espressif/llvm-project/releases/download/esp-${version}/llvm-esp-${version}-linux-amd64.tar.xz";
+    inherit hash;
+  };
+
+  buildInputs = [ makeWrapper ];
+
+  phases = [ "unpackPhase" "installPhase" ];
+
+  installPhase = ''
+    cp -r . $out
+    for FILE in $(ls $out/bin); do
+      FILE_PATH="$out/bin/$FILE"
+      FILE_PATH_UNWRAPPED="$out/.bin-unwrapped/$FILE"
+      mkdir -p $out/.bin-unwrapped
+      if [[ -x $FILE_PATH ]]; then
+        mv $FILE_PATH $FILE_PATH_UNWRAPPED
+        makeWrapper ${fhsEnv}/bin/esp-llvm-env $FILE_PATH --add-flags "$FILE_PATH_UNWRAPPED"
+      fi
+    done
+  '';
+
+  meta = with lib; {
+    description = "Espressif LLVM/Clang fork binary build";
+    homepage = "https://docs.espressif.com/projects/esp-idf/en/stable/get-started/linux-setup.html";
+    license = licenses.gpl3;
+  };
+}
+
diff --git a/nix/run-clang-tidy/default.nix b/nix/run-clang-tidy/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d60ede0cea933348bab085bd4f107909e4418cfd
--- /dev/null
+++ b/nix/run-clang-tidy/default.nix
@@ -0,0 +1,22 @@
+{ stdenv
+, lib
+, fetchurl
+, makeWrapper
+, buildFHSUserEnv
+}:
+
+stdenv.mkDerivation rec {
+  pname = "run-clang-tidy";
+  version = "20230720";
+
+  src = ./run-clang-tidy.py;
+
+  phases = [ "installPhase" ];
+
+  installPhase = ''
+    mkdir -p $out/bin
+    cp $src $out/bin/run-clang-tidy.py
+    chmod +x $out/bin/run-clang-tidy.py
+  '';
+}
+
diff --git a/nix/run-clang-tidy/run-clang-tidy.py b/nix/run-clang-tidy/run-clang-tidy.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0c2d63d2024cf990108c0c564fb3c96721b3aa4
--- /dev/null
+++ b/nix/run-clang-tidy/run-clang-tidy.py
@@ -0,0 +1,503 @@
+#!/usr/bin/env python3
+#
+# ===- run-clang-tidy.py - Parallel clang-tidy runner --------*- python -*--===#
+#
+# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+#
+# ===-----------------------------------------------------------------------===#
+# FIXME: Integrate with clang-tidy-diff.py
+
+
+"""
+Parallel clang-tidy runner
+==========================
+
+Runs clang-tidy over all files in a compilation database. Requires clang-tidy
+and clang-apply-replacements in $PATH.
+
+Example invocations.
+- Run clang-tidy on all files in the current working directory with a default
+  set of checks and show warnings in the cpp files and all project headers.
+    run-clang-tidy.py $PWD
+
+- Fix all header guards.
+    run-clang-tidy.py -fix -checks=-*,llvm-header-guard
+
+- Fix all header guards included from clang-tidy and header guards
+  for clang-tidy headers.
+    run-clang-tidy.py -fix -checks=-*,llvm-header-guard extra/clang-tidy \
+                      -header-filter=extra/clang-tidy
+
+Compilation database setup:
+http://clang.llvm.org/docs/HowToSetupToolingForLLVM.html
+"""
+
+from __future__ import print_function
+
+import argparse
+import glob
+import json
+import multiprocessing
+import os
+import queue
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import threading
+import traceback
+
+try:
+    import yaml
+except ImportError:
+    yaml = None
+
+
+def strtobool(val):
+    """Convert a string representation of truth to a bool following LLVM's CLI argument parsing."""
+
+    val = val.lower()
+    if val in ["", "true", "1"]:
+        return True
+    elif val in ["false", "0"]:
+        return False
+
+    # Return ArgumentTypeError so that argparse does not substitute its own error message
+    raise argparse.ArgumentTypeError(
+        "'{}' is invalid value for boolean argument! Try 0 or 1.".format(val)
+    )
+
+
+def find_compilation_database(path):
+    """Adjusts the directory until a compilation database is found."""
+    result = os.path.realpath("./")
+    while not os.path.isfile(os.path.join(result, path)):
+        parent = os.path.dirname(result)
+        if result == parent:
+            print("Error: could not find compilation database.")
+            sys.exit(1)
+        result = parent
+    return result
+
+
+def make_absolute(f, directory):
+    if os.path.isabs(f):
+        return f
+    return os.path.normpath(os.path.join(directory, f))
+
+
+def get_tidy_invocation(
+    f,
+    clang_tidy_binary,
+    checks,
+    tmpdir,
+    build_path,
+    header_filter,
+    allow_enabling_alpha_checkers,
+    extra_arg,
+    extra_arg_before,
+    quiet,
+    config_file_path,
+    config,
+    line_filter,
+    use_color,
+    plugins,
+    warnings_as_errors,
+):
+    """Gets a command line for clang-tidy."""
+    start = [clang_tidy_binary]
+    if allow_enabling_alpha_checkers:
+        start.append("-allow-enabling-analyzer-alpha-checkers")
+    if header_filter is not None:
+        start.append("-header-filter=" + header_filter)
+    if line_filter is not None:
+        start.append("-line-filter=" + line_filter)
+    if use_color is not None:
+        if use_color:
+            start.append("--use-color")
+        else:
+            start.append("--use-color=false")
+    if checks:
+        start.append("-checks=" + checks)
+    if tmpdir is not None:
+        start.append("-export-fixes")
+        # Get a temporary file. We immediately close the handle so clang-tidy can
+        # overwrite it.
+        (handle, name) = tempfile.mkstemp(suffix=".yaml", dir=tmpdir)
+        os.close(handle)
+        start.append(name)
+    for arg in extra_arg:
+        start.append("-extra-arg=%s" % arg)
+    for arg in extra_arg_before:
+        start.append("-extra-arg-before=%s" % arg)
+    start.append("-p=" + build_path)
+    if quiet:
+        start.append("-quiet")
+    if config_file_path:
+        start.append("--config-file=" + config_file_path)
+    elif config:
+        start.append("-config=" + config)
+    for plugin in plugins:
+        start.append("-load=" + plugin)
+    if warnings_as_errors:
+        start.append("--warnings-as-errors=" + warnings_as_errors)
+    start.append(f)
+    return start
+
+
+def merge_replacement_files(tmpdir, mergefile):
+    """Merge all replacement files in a directory into a single file"""
+    # The fixes suggested by clang-tidy >= 4.0.0 are given under
+    # the top level key 'Diagnostics' in the output yaml files
+    mergekey = "Diagnostics"
+    merged = []
+    for replacefile in glob.iglob(os.path.join(tmpdir, "*.yaml")):
+        content = yaml.safe_load(open(replacefile, "r"))
+        if not content:
+            continue  # Skip empty files.
+        merged.extend(content.get(mergekey, []))
+
+    if merged:
+        # MainSourceFile: The key is required by the definition inside
+        # include/clang/Tooling/ReplacementsYaml.h, but the value
+        # is actually never used inside clang-apply-replacements,
+        # so we set it to '' here.
+        output = {"MainSourceFile": "", mergekey: merged}
+        with open(mergefile, "w") as out:
+            yaml.safe_dump(output, out)
+    else:
+        # Empty the file:
+        open(mergefile, "w").close()
+
+
+def find_binary(arg, name, build_path):
+    """Get the path for a binary or exit"""
+    if arg:
+        if shutil.which(arg):
+            return arg
+        else:
+            raise SystemExit(
+                "error: passed binary '{}' was not found or is not executable".format(
+                    arg
+                )
+            )
+
+    built_path = os.path.join(build_path, "bin", name)
+    binary = shutil.which(name) or shutil.which(built_path)
+    if binary:
+        return binary
+    else:
+        raise SystemExit(
+            "error: failed to find {} in $PATH or at {}".format(name, built_path)
+        )
+
+
+def apply_fixes(args, clang_apply_replacements_binary, tmpdir):
+    """Calls clang-apply-fixes on a given directory."""
+    invocation = [clang_apply_replacements_binary]
+    invocation.append("-ignore-insert-conflict")
+    if args.format:
+        invocation.append("-format")
+    if args.style:
+        invocation.append("-style=" + args.style)
+    invocation.append(tmpdir)
+    subprocess.call(invocation)
+
+
+def run_tidy(args, clang_tidy_binary, tmpdir, build_path, queue, lock, failed_files):
+    """Takes filenames out of queue and runs clang-tidy on them."""
+    while True:
+        name = queue.get()
+        invocation = get_tidy_invocation(
+            name,
+            clang_tidy_binary,
+            args.checks,
+            tmpdir,
+            build_path,
+            args.header_filter,
+            args.allow_enabling_alpha_checkers,
+            args.extra_arg,
+            args.extra_arg_before,
+            args.quiet,
+            args.config_file,
+            args.config,
+            args.line_filter,
+            args.use_color,
+            args.plugins,
+            args.warnings_as_errors,
+        )
+
+        proc = subprocess.Popen(
+            invocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE
+        )
+        output, err = proc.communicate()
+        if proc.returncode != 0:
+            if proc.returncode < 0:
+                msg = "%s: terminated by signal %d\n" % (name, -proc.returncode)
+                err += msg.encode("utf-8")
+            failed_files.append(name)
+        with lock:
+            sys.stdout.write(" ".join(invocation) + "\n" + output.decode("utf-8"))
+            if len(err) > 0:
+                sys.stdout.flush()
+                sys.stderr.write(err.decode("utf-8"))
+        queue.task_done()
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Runs clang-tidy over all files "
+        "in a compilation database. Requires "
+        "clang-tidy and clang-apply-replacements in "
+        "$PATH or in your build directory."
+    )
+    parser.add_argument(
+        "-allow-enabling-alpha-checkers",
+        action="store_true",
+        help="allow alpha checkers from " "clang-analyzer.",
+    )
+    parser.add_argument(
+        "-clang-tidy-binary", metavar="PATH", help="path to clang-tidy binary"
+    )
+    parser.add_argument(
+        "-clang-apply-replacements-binary",
+        metavar="PATH",
+        help="path to clang-apply-replacements binary",
+    )
+    parser.add_argument(
+        "-checks",
+        default=None,
+        help="checks filter, when not specified, use clang-tidy " "default",
+    )
+    config_group = parser.add_mutually_exclusive_group()
+    config_group.add_argument(
+        "-config",
+        default=None,
+        help="Specifies a configuration in YAML/JSON format: "
+        "  -config=\"{Checks: '*', "
+        '                       CheckOptions: {x: y}}" '
+        "When the value is empty, clang-tidy will "
+        "attempt to find a file named .clang-tidy for "
+        "each source file in its parent directories.",
+    )
+    config_group.add_argument(
+        "-config-file",
+        default=None,
+        help="Specify the path of .clang-tidy or custom config "
+        "file: e.g. -config-file=/some/path/myTidyConfigFile. "
+        "This option internally works exactly the same way as "
+        "-config option after reading specified config file. "
+        "Use either -config-file or -config, not both.",
+    )
+    parser.add_argument(
+        "-header-filter",
+        default=None,
+        help="regular expression matching the names of the "
+        "headers to output diagnostics from. Diagnostics from "
+        "the main file of each translation unit are always "
+        "displayed.",
+    )
+    parser.add_argument(
+        "-line-filter",
+        default=None,
+        help="List of files with line ranges to filter the" "warnings.",
+    )
+    if yaml:
+        parser.add_argument(
+            "-export-fixes",
+            metavar="filename",
+            dest="export_fixes",
+            help="Create a yaml file to store suggested fixes in, "
+            "which can be applied with clang-apply-replacements.",
+        )
+    parser.add_argument(
+        "-j",
+        type=int,
+        default=0,
+        help="number of tidy instances to be run in parallel.",
+    )
+    parser.add_argument(
+        "files", nargs="*", default=[".*"], help="files to be processed (regex on path)"
+    )
+    parser.add_argument("-fix", action="store_true", help="apply fix-its")
+    parser.add_argument(
+        "-format", action="store_true", help="Reformat code " "after applying fixes"
+    )
+    parser.add_argument(
+        "-style",
+        default="file",
+        help="The style of reformat " "code after applying fixes",
+    )
+    parser.add_argument(
+        "-use-color",
+        type=strtobool,
+        nargs="?",
+        const=True,
+        help="Use colors in diagnostics, overriding clang-tidy's"
+        " default behavior. This option overrides the 'UseColor"
+        "' option in .clang-tidy file, if any.",
+    )
+    parser.add_argument(
+        "-p", dest="build_path", help="Path used to read a compile command database."
+    )
+    parser.add_argument(
+        "-extra-arg",
+        dest="extra_arg",
+        action="append",
+        default=[],
+        help="Additional argument to append to the compiler " "command line.",
+    )
+    parser.add_argument(
+        "-extra-arg-before",
+        dest="extra_arg_before",
+        action="append",
+        default=[],
+        help="Additional argument to prepend to the compiler " "command line.",
+    )
+    parser.add_argument(
+        "-quiet", action="store_true", help="Run clang-tidy in quiet mode"
+    )
+    parser.add_argument(
+        "-load",
+        dest="plugins",
+        action="append",
+        default=[],
+        help="Load the specified plugin in clang-tidy.",
+    )
+    parser.add_argument(
+        "-warnings-as-errors",
+        default=None,
+        help="Upgrades warnings to errors. Same format as " "'-checks'",
+    )
+    args = parser.parse_args()
+
+    db_path = "compile_commands.json"
+
+    if args.build_path is not None:
+        build_path = args.build_path
+    else:
+        # Find our database
+        build_path = find_compilation_database(db_path)
+
+    clang_tidy_binary = find_binary(args.clang_tidy_binary, "clang-tidy", build_path)
+
+    tmpdir = None
+    if args.fix:
+        clang_apply_replacements_binary = find_binary(
+            args.clang_apply_replacements_binary, "clang-apply-replacements", build_path
+        )
+        tmpdir = tempfile.mkdtemp()
+
+    try:
+        invocation = get_tidy_invocation(
+            "",
+            clang_tidy_binary,
+            args.checks,
+            None,
+            build_path,
+            args.header_filter,
+            args.allow_enabling_alpha_checkers,
+            args.extra_arg,
+            args.extra_arg_before,
+            args.quiet,
+            args.config_file,
+            args.config,
+            args.line_filter,
+            args.use_color,
+            args.plugins,
+            args.warnings_as_errors,
+        )
+        invocation.append("-list-checks")
+        invocation.append("-")
+        if args.quiet:
+            # Even with -quiet we still want to check if we can call clang-tidy.
+            with open(os.devnull, "w") as dev_null:
+                subprocess.check_call(invocation, stdout=dev_null)
+        else:
+            subprocess.check_call(invocation)
+    except:
+        print("Unable to run clang-tidy.", file=sys.stderr)
+        sys.exit(1)
+
+    # Load the database and extract all files.
+    database = json.load(open(os.path.join(build_path, db_path)))
+    files = set(
+        [make_absolute(entry["file"], entry["directory"]) for entry in database]
+    )
+
+    max_task = args.j
+    if max_task == 0:
+        max_task = multiprocessing.cpu_count()
+
+    # Build up a big regexy filter from all command line arguments.
+    file_name_re = re.compile("|".join(args.files))
+
+    return_code = 0
+    try:
+        # Spin up a bunch of tidy-launching threads.
+        task_queue = queue.Queue(max_task)
+        # List of files with a non-zero return code.
+        failed_files = []
+        lock = threading.Lock()
+        for _ in range(max_task):
+            t = threading.Thread(
+                target=run_tidy,
+                args=(
+                    args,
+                    clang_tidy_binary,
+                    tmpdir,
+                    build_path,
+                    task_queue,
+                    lock,
+                    failed_files,
+                ),
+            )
+            t.daemon = True
+            t.start()
+
+        # Fill the queue with files.
+        for name in files:
+            if file_name_re.search(name):
+                task_queue.put(name)
+
+        # Wait for all threads to be done.
+        task_queue.join()
+        if len(failed_files):
+            return_code = 1
+
+    except KeyboardInterrupt:
+        # This is a sad hack. Unfortunately subprocess goes
+        # bonkers with ctrl-c and we start forking merrily.
+        print("\nCtrl-C detected, goodbye.")
+        if tmpdir:
+            shutil.rmtree(tmpdir)
+        os.kill(0, 9)
+
+    if yaml and args.export_fixes:
+        print("Writing fixes to " + args.export_fixes + " ...")
+        try:
+            merge_replacement_files(tmpdir, args.export_fixes)
+        except:
+            print("Error exporting fixes.\n", file=sys.stderr)
+            traceback.print_exc()
+            return_code = 1
+
+    if args.fix:
+        print("Applying fixes ...")
+        try:
+            apply_fixes(args, clang_apply_replacements_binary, tmpdir)
+        except:
+            print("Error applying fixes.\n", file=sys.stderr)
+            traceback.print_exc()
+            return_code = 1
+
+    if tmpdir:
+        shutil.rmtree(tmpdir)
+    sys.exit(return_code)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/nix/shell.nix b/nix/shell.nix
index 6961850919b8a7dfd8d493f245400da3fbff7375..7313b4fe922578038ac54ec8785b095d4125dd04 100644
--- a/nix/shell.nix
+++ b/nix/shell.nix
@@ -6,6 +6,8 @@ let
         gcc-xtensa-esp32s3-elf-bin = super.callPackage ./esp32s3-toolchain-bin.nix {};
         openocd-esp32-bin = super.callPackage ./openocd-esp32-bin.nix {};
         esp-idf = super.callPackage ./esp-idf {};
+        esp-llvm = super.callPackage ./esp-llvm.nix {};
+        run-clang-tidy = super.callPackage ./run-clang-tidy {};
       })
     ];
   };
@@ -16,7 +18,9 @@ in with nixpkgs; pkgs.mkShell {
     gcc-xtensa-esp32s3-elf-bin
     openocd-esp32-bin
     esp-idf
+    esp-llvm
     esptool
+    run-clang-tidy
 
     git wget gnumake
     flex bison gperf pkgconfig