Browse Source

Community modules (#24848)

Nick Brassel 1 year ago
parent
commit
1efc82403b
37 changed files with 987 additions and 84 deletions
  1. 4 0
      .github/labeler.yml
  2. 1 0
      .github/workflows/format.yml
  3. 85 37
      builddefs/build_keyboard.mk
  4. 7 0
      data/constants/keycodes/keycodes_0.0.7.hjson
  5. 25 0
      data/constants/module_hooks/0.1.0.hjson
  6. 26 0
      data/constants/module_hooks/1.0.0.hjson
  7. 17 0
      data/schemas/community_module.jsonschema
  8. 12 5
      data/schemas/keyboard.jsonschema
  9. 6 0
      data/schemas/keymap.jsonschema
  10. 1 0
      docs/_sidebar.json
  11. 9 2
      docs/custom_quantum_functions.md
  12. 142 0
      docs/features/community_modules.md
  13. 7 0
      keyboards/handwired/onekey/keymaps/community_module/keymap.c
  14. 3 0
      keyboards/handwired/onekey/keymaps/community_module/keymap.json
  15. 1 0
      lib/python/qmk/cli/__init__.py
  16. 1 1
      lib/python/qmk/cli/format/c.py
  17. 11 2
      lib/python/qmk/cli/format/json.py
  18. 263 0
      lib/python/qmk/cli/generate/community_modules.py
  19. 41 5
      lib/python/qmk/cli/generate/rules_mk.py
  20. 5 0
      lib/python/qmk/cli/info.py
  21. 4 1
      lib/python/qmk/commands.py
  22. 100 0
      lib/python/qmk/community_modules.py
  23. 27 0
      lib/python/qmk/info.py
  24. 28 0
      lib/python/qmk/json_encoders.py
  25. 0 27
      lib/python/qmk/keymap.py
  26. 33 0
      modules/qmk/hello_world/hello_world.c
  27. 10 0
      modules/qmk/hello_world/introspection.c
  28. 10 0
      modules/qmk/hello_world/introspection.h
  29. 13 0
      modules/qmk/hello_world/qmk_module.json
  30. 2 0
      modules/qmk/hello_world/rules.mk
  31. 1 1
      quantum/action.h
  32. 42 2
      quantum/keyboard.c
  33. 3 0
      quantum/keycodes.h
  34. 11 0
      quantum/keymap_introspection.c
  35. 7 0
      quantum/os_detection.c
  36. 25 1
      quantum/quantum.c
  37. 4 0
      quantum/quantum.h

+ 4 - 0
.github/labeler.yml

@@ -54,3 +54,7 @@ dd:
       - data/constants/**
       - data/mappings/**
       - data/schemas/**
+community_module:
+  - changed-files:
+    - any-glob-to-any-file:
+      - modules/**

+ 1 - 0
.github/workflows/format.yml

@@ -10,6 +10,7 @@ on:
     - 'lib/arm_atsam/**'
     - 'lib/lib8tion/**'
     - 'lib/python/**'
+    - 'modules/**'
     - 'platforms/**'
     - 'quantum/**'
     - 'tests/**'

+ 85 - 37
builddefs/build_keyboard.mk

@@ -112,6 +112,39 @@ endif
 ifneq ("$(wildcard $(KEYBOARD_PATH_1)/rules.mk)","")
     include $(KEYBOARD_PATH_1)/rules.mk
 endif
+# Create dependencies on DD keyboard config - structure validated elsewhere
+DD_CONFIG_FILES :=
+ifneq ("$(wildcard $(KEYBOARD_PATH_1)/info.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_1)/info.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_2)/info.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_2)/info.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_3)/info.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_3)/info.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_4)/info.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_4)/info.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_5)/info.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_5)/info.json
+endif
+
+ifneq ("$(wildcard $(KEYBOARD_PATH_1)/keyboard.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_1)/keyboard.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_2)/keyboard.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_2)/keyboard.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_3)/keyboard.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_3)/keyboard.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_4)/keyboard.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_4)/keyboard.json
+endif
+ifneq ("$(wildcard $(KEYBOARD_PATH_5)/keyboard.json)","")
+    DD_CONFIG_FILES += $(KEYBOARD_PATH_5)/keyboard.json
+endif
 
 MAIN_KEYMAP_PATH_1 := $(KEYBOARD_PATH_1)/keymaps/$(KEYMAP)
 MAIN_KEYMAP_PATH_2 := $(KEYBOARD_PATH_2)/keymaps/$(KEYMAP)
@@ -207,17 +240,17 @@ ifneq ("$(wildcard $(KEYMAP_JSON))", "")
     include $(INFO_RULES_MK)
 
 # Add rules to generate the keymap files - indentation here is important
-$(INTERMEDIATE_OUTPUT)/src/keymap.c: $(KEYMAP_JSON)
+$(INTERMEDIATE_OUTPUT)/src/keymap.c: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
 	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
 	$(eval CMD=$(QMK_BIN) json2c --quiet --output $(KEYMAP_C) $(KEYMAP_JSON))
 	@$(BUILD_CMD)
 
-$(INTERMEDIATE_OUTPUT)/src/config.h: $(KEYMAP_JSON)
+$(INTERMEDIATE_OUTPUT)/src/config.h: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
 	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
 	$(eval CMD=$(QMK_BIN) generate-config-h --quiet --output $(KEYMAP_H) $(KEYMAP_JSON))
 	@$(BUILD_CMD)
 
-$(INTERMEDIATE_OUTPUT)/src/keymap.h: $(KEYMAP_JSON)
+$(INTERMEDIATE_OUTPUT)/src/keymap.h: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
 	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
 	$(eval CMD=$(QMK_BIN) generate-keymap-h --quiet --output $(INTERMEDIATE_OUTPUT)/src/keymap.h $(KEYMAP_JSON))
 	@$(BUILD_CMD)
@@ -226,6 +259,32 @@ generated-files: $(INTERMEDIATE_OUTPUT)/src/config.h $(INTERMEDIATE_OUTPUT)/src/
 
 endif
 
+# Community modules
+$(INTERMEDIATE_OUTPUT)/src/community_modules.h: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
+	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
+	$(eval CMD=$(QMK_BIN) generate-community-modules-h -kb $(KEYBOARD) --quiet --output $(INTERMEDIATE_OUTPUT)/src/community_modules.h $(KEYMAP_JSON))
+	@$(BUILD_CMD)
+
+$(INTERMEDIATE_OUTPUT)/src/community_modules.c: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
+	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
+	$(eval CMD=$(QMK_BIN) generate-community-modules-c -kb $(KEYBOARD) --quiet --output $(INTERMEDIATE_OUTPUT)/src/community_modules.c $(KEYMAP_JSON))
+	@$(BUILD_CMD)
+
+$(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.c: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
+	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
+	$(eval CMD=$(QMK_BIN) generate-community-modules-introspection-c -kb $(KEYBOARD) --quiet --output $(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.c $(KEYMAP_JSON))
+	@$(BUILD_CMD)
+
+$(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.h: $(KEYMAP_JSON) $(DD_CONFIG_FILES)
+	@$(SILENT) || printf "$(MSG_GENERATING) $@" | $(AWK_CMD)
+	$(eval CMD=$(QMK_BIN) generate-community-modules-introspection-h -kb $(KEYBOARD) --quiet --output $(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.h $(KEYMAP_JSON))
+	@$(BUILD_CMD)
+
+SRC += $(INTERMEDIATE_OUTPUT)/src/community_modules.c
+
+generated-files: $(INTERMEDIATE_OUTPUT)/src/community_modules.h $(INTERMEDIATE_OUTPUT)/src/community_modules.c $(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.c $(INTERMEDIATE_OUTPUT)/src/community_modules_introspection.h
+
+
 include $(BUILDDEFS_PATH)/converters.mk
 
 # Generate the board's version.h file.
@@ -315,6 +374,14 @@ endif
 
 # Find all of the config.h files and add them to our CONFIG_H define.
 CONFIG_H :=
+
+define config_h_community_module_appender
+	ifneq ("$(wildcard $(1)/config.h)","")
+		CONFIG_H += $(1)/config.h
+	endif
+endef
+$(foreach module,$(COMMUNITY_MODULE_PATHS),$(eval $(call config_h_community_module_appender,$(module))))
+
 ifneq ("$(wildcard $(KEYBOARD_PATH_5)/config.h)","")
     CONFIG_H += $(KEYBOARD_PATH_5)/config.h
 endif
@@ -332,6 +399,14 @@ ifneq ("$(wildcard $(KEYBOARD_PATH_1)/config.h)","")
 endif
 
 POST_CONFIG_H :=
+
+define post_config_h_community_module_appender
+	ifneq ("$(wildcard $(1)/post_config.h)","")
+		POST_CONFIG_H += $(1)/post_config.h
+	endif
+endef
+$(foreach module,$(COMMUNITY_MODULE_PATHS),$(eval $(call post_config_h_community_module_appender,$(module))))
+
 ifneq ("$(wildcard $(KEYBOARD_PATH_1)/post_config.h)","")
     POST_CONFIG_H += $(KEYBOARD_PATH_1)/post_config.h
 endif
@@ -348,40 +423,6 @@ ifneq ("$(wildcard $(KEYBOARD_PATH_5)/post_config.h)","")
     POST_CONFIG_H += $(KEYBOARD_PATH_5)/post_config.h
 endif
 
-# Create dependencies on DD keyboard config - structure validated elsewhere
-DD_CONFIG_FILES :=
-ifneq ("$(wildcard $(KEYBOARD_PATH_1)/info.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_1)/info.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_2)/info.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_2)/info.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_3)/info.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_3)/info.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_4)/info.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_4)/info.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_5)/info.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_5)/info.json
-endif
-
-ifneq ("$(wildcard $(KEYBOARD_PATH_1)/keyboard.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_1)/keyboard.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_2)/keyboard.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_2)/keyboard.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_3)/keyboard.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_3)/keyboard.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_4)/keyboard.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_4)/keyboard.json
-endif
-ifneq ("$(wildcard $(KEYBOARD_PATH_5)/keyboard.json)","")
-    DD_CONFIG_FILES += $(KEYBOARD_PATH_5)/keyboard.json
-endif
-
 CONFIG_H += $(INTERMEDIATE_OUTPUT)/src/info_config.h
 KEYBOARD_SRC += $(INTERMEDIATE_OUTPUT)/src/default_keyboard.c
 
@@ -462,6 +503,13 @@ ifneq ("$(wildcard $(KEYBOARD_PATH_5)/post_rules.mk)","")
     include $(KEYBOARD_PATH_5)/post_rules.mk
 endif
 
+define post_rules_mk_community_module_includer
+	ifneq ("$(wildcard $(1)/post_rules.mk)","")
+		include $(1)/post_rules.mk
+	endif
+endef
+$(foreach module,$(COMMUNITY_MODULE_PATHS),$(eval $(call post_rules_mk_community_module_includer,$(module))))
+
 ifneq ("$(wildcard $(KEYMAP_PATH)/config.h)","")
     CONFIG_H += $(KEYMAP_PATH)/config.h
 endif

+ 7 - 0
data/constants/keycodes/keycodes_0.0.7.hjson

@@ -0,0 +1,7 @@
+{
+    "ranges": {
+        "0x77C0/0x003F": {
+            "define": "QK_COMMUNITY_MODULE"
+        }
+    }
+}

+ 25 - 0
data/constants/module_hooks/0.1.0.hjson

@@ -0,0 +1,25 @@
+{
+    keyboard_pre_init: {
+        ret_type: void
+        args: void
+    }
+    keyboard_post_init: {
+        ret_type: void
+        args: void
+    }
+    pre_process_record: {
+        ret_type: bool
+        args: uint16_t keycode, keyrecord_t *record
+        call_params: keycode, record
+    }
+    process_record: {
+        ret_type: bool
+        args: uint16_t keycode, keyrecord_t *record
+        call_params: keycode, record
+    }
+    post_process_record: {
+        ret_type: void
+        args: uint16_t keycode, keyrecord_t *record
+        call_params: keycode, record
+    }
+}

+ 26 - 0
data/constants/module_hooks/1.0.0.hjson

@@ -0,0 +1,26 @@
+{
+    housekeeping_task: {
+        ret_type: void
+        args: void
+    }
+    suspend_power_down: {
+        ret_type: void
+        args: void
+    }
+    suspend_wakeup_init: {
+        ret_type: void
+        args: void
+    }
+    shutdown: {
+        ret_type: bool
+        args: bool jump_to_bootloader
+        call_params: jump_to_bootloader
+    }
+    process_detected_host_os: {
+        ret_type: bool
+        args: os_variant_t os
+        call_params: os
+        guard: defined(OS_DETECTION_ENABLE)
+        header: os_detection.h
+    }
+}

+ 17 - 0
data/schemas/community_module.jsonschema

@@ -0,0 +1,17 @@
+{
+    "$schema": "https://json-schema.org/draft/2020-12/schema#",
+    "$id": "qmk.community_module.v1",
+    "title": "Community Module Information",
+    "type": "object",
+    "required": ["module_name", "maintainer"]
+    "properties": {
+        "module_name": {"$ref": "qmk.definitions.v1#/text_identifier"},
+        "maintainer": {"$ref": "qmk.definitions.v1#/text_identifier"},
+        "url": {
+            "type": "string",
+            "format": "uri"
+        },
+        "keycodes": {"$ref": "qmk.definitions.v1#/keycode_decl_array"},
+        "features": {"$ref": "qmk.keyboard.v1#/definitions/features_config"},
+    }
+}

+ 12 - 5
data/schemas/keyboard.jsonschema

@@ -31,6 +31,11 @@
                 "pins": {"$ref": "qmk.definitions.v1#/mcu_pin_array"}
             }
         }
+        "features_config": {
+            "$ref": "qmk.definitions.v1#/boolean_array",
+            "propertyNames": {"$ref": "qmk.definitions.v1#/snake_case"},
+            "not": {"required": ["lto"]}
+        },
     },
     "type": "object",
     "not": {"required": ["vendorId", "productId"]}, // reject via keys...
@@ -328,11 +333,7 @@
                 "enabled": {"type": "boolean"}
             }
         },
-        "features": {
-            "$ref": "qmk.definitions.v1#/boolean_array",
-            "propertyNames": {"$ref": "qmk.definitions.v1#/snake_case"},
-            "not": {"required": ["lto"]}
-        },
+        "features": { "$ref": "#/definitions/features_config" },
         "indicators": {
             "type": "object",
             "properties": {
@@ -467,6 +468,12 @@
                 "rows": {"$ref": "qmk.definitions.v1#/mcu_pin_array"}
             }
         },
+        "modules": {
+            "type": "array",
+            "items": {
+                "type": "string"
+            }
+        },
         "mouse_key": {
             "type": "object",
             "properties": {

+ 6 - 0
data/schemas/keymap.jsonschema

@@ -71,6 +71,12 @@
         "config": {"$ref": "qmk.keyboard.v1"},
         "notes": {
             "type": "string"
+        },
+        "modules": {
+            "type": "array",
+            "items": {
+                "type": "string"
+            }
         }
     }
 }

+ 1 - 0
docs/_sidebar.json

@@ -60,6 +60,7 @@
                 "items": [
                     { "text": "Customizing Functionality", "link": "/custom_quantum_functions" },
                     { "text": "Driver Installation with Zadig", "link": "/driver_installation_zadig" },
+                    { "text": "Community Modules", "link": "/features/community_modules" },
                     { "text": "Keymap Overview", "link": "/keymap" },
                     {
                         "text": "Development Environments",

+ 9 - 2
docs/custom_quantum_functions.md

@@ -9,12 +9,19 @@ This page does not assume any special knowledge about QMK, but reading [Understa
 We have structured QMK as a hierarchy:
 
 * Core (`_quantum`)
+  * Community Module (`_<module>`)
+    * Community Module -> Keyboard/Revision (`_<module>_kb`)
+      * Community Module -> Keymap (`_<module>_user`)
   * Keyboard/Revision (`_kb`)
     * Keymap (`_user`)
 
 Each of the functions described below can be defined with a `_kb()` suffix or a `_user()` suffix. We intend for you to use the `_kb()` suffix at the Keyboard/Revision level, while the `_user()` suffix should be used at the Keymap level.
 
-When defining functions at the Keyboard/Revision level it is important that your `_kb()` implementation call `_user()` before executing anything else- otherwise the keymap level function will never be called.
+When defining functions at the Keyboard/Revision level it is important that your `_kb()` implementation call `_user()` at an appropriate location, otherwise the keymap level function will never be called.
+
+Functions at the `_<module>_xxx()` level are intended to allow keyboards or keymaps to override or enhance the processing associated with a [community module](/features/community_modules).
+
+When defining module overrides such as `process_record_<module>()`, the same pattern should be used; the module must invoke `process_record_<module>_kb()` as appropriate.
 
 # Custom Keycodes
 
@@ -99,7 +106,7 @@ These are the three main initialization functions, listed in the order that they
 * `keyboard_post_init_*` - Happens at the end of the firmware's startup process. This is where you'd want to put "customization" code, for the most part.
 
 ::: warning
-For most people, the `keyboard_post_init_user` function is what you want to call.  For instance, this is where you want to set up things for RGB Underglow.
+For most people, the `keyboard_post_init_user` function is what you want to implement. For instance, this is where you want to set up things for RGB Underglow.
 :::
 
 ## Keyboard Pre Initialization code

+ 142 - 0
docs/features/community_modules.md

@@ -0,0 +1,142 @@
+# Community Modules
+
+Community Modules are a feature within QMK which allows code to be implemented by third parties, making it available for other people to import into their own builds.
+
+These modules can provide implementations which override or enhance normal QMK processing; initialization, key processing, suspend, and shutdown are some of the provided hooks which modules may implement.
+
+## Adding a Community Module to your build
+
+Community Modules have first-class support for [External Userspace](/newbs_external_userspace), and QMK strongly recommends using External Userspace for hosting keymaps and Community Modules together.
+
+Modules must live in either of two locations:
+
+* `<QMK_USERSPACE>/modules/`
+* `<QMK_FIRMWARE>/modules/`
+
+A basic module is provided within QMK itself -- `qmk/hello_world` -- which prints out a notification over [HID console](/faq_debug) after 10 seconds, and adds a new keycode, `COMMUNITY_MODULE_HELLO` (aliased to `CM_HELO`) which types `Hello there.` to the active application when the corresponding key is pressed.
+
+To add this module to your build, in your keymap's directory create a `keymap.json` with the following content:
+
+```json
+{
+    "modules": [
+        "qmk/hello_world"
+    ]
+}
+```
+
+If you already have a `keymap.json`, you'll need to manually merge the `modules` section into your keymap.
+
+::: warning
+Community Modules are not supported by QMK Configurator. If you wish to use Community Modules, you must build your own firmware.
+:::
+
+## Adding a Community Module to your External Userspace
+
+Module authors are encouraged to provide a git repository on GitHub which may be imported into a user's external userspace. If a user wishes to import a module repository, they can do the following:
+
+```sh
+cd /path/to/your/external/userspace
+mkdir -p modules
+# Replace the following {user} and {repo} with the author's community module repository
+git submodule add https://github.com/{user}/{repo}.git modules/{user}
+git submdule update --init --recursive
+```
+
+This will ensure the copy of the module is made in your userspace.
+
+Add a new entry into your `keymap.json` with the desired modules, replacing `{user}` and `{module_name}` as appropriate:
+
+```json
+{
+    "modules": [
+        "qmk/hello_world",
+        "{user}/{module_name}"
+    ]
+}
+```
+
+::: info
+The module listed in `keymap.json` is the relative path within the `modules/` directory. So long as the module is present _somewhere_ under `modules/`, then the `keymap.json` can refer to that path.
+:::
+
+## Writing a QMK Community Module
+
+As stated earlier, Community Module authors are strongly encouraged to provide their modules through git, allowing users to leverage submodules to import functionality.
+
+### `qmk_module.json`
+
+A Community Module is denoted by a `qmk_module.json` file such as the following:
+
+```json
+{
+    "module_name": "Hello World",
+    "maintainer": "QMK Maintainers",
+    "features": {
+        "deferred_exec": true
+    },
+    "keycodes": [
+        {
+            "key": "COMMUNITY_MODULE_HELLO",
+            "aliases": ["CM_HELO"]
+        }
+    ]
+}
+```
+
+At minimum, the module must provide the `module_name` and `maintainer` fields.
+
+The use of `features` matches the definition normally provided within `keyboard.json` and `info.json`, allowing a module to signal to the build system that it has its own dependencies. In the example above, it enables the _deferred executor_ feature whenever the above module is used in a build.
+
+The `keycodes` array allows a module to provide new keycodes (as well as corresponding aliases) to a keymap.
+
+### `rules.mk` / `post_rules.mk`
+
+These two files follows standard QMK build system logic, allowing for `Makefile`-style customisation as if it were present in the keyboard or keymap.
+
+### `<module>.c`
+
+This file will be automatically added to the build if the filename matches the directory name. For example, the `qmk/hello_world` module contains a `hello_world.c` file, which is automatically added to the build.
+
+::: info
+Other files intended to be included must use the normal method of `SRC += my_file.c` inside `rules.mk`.
+:::
+
+::: tip
+This file should use `ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1,0,0);` to enforce a minimum version of the API that it requires, ensuring the Community Module is built with a compatible version of QMK. The list of APIs and corresponding version is given at the bottom of this document. Note the use of commas instead of periods.
+:::
+
+### `introspection.c` / `introspection.h`
+
+These two files hook into the keymap introspection logic -- the header is prepended before the user keymap, and the C source file is appended after the user keymap.
+
+The header may provide definitions which are useful to the user's `keymap.c`.
+
+The source file may provide functions which allow access to information specified in the user's `keymap.c`.
+
+::: warning
+Introspection is a relatively advanced topic within QMK, and existing patterns should be followed. If you need help please [open an issue](https://github.com/qmk/qmk_firmware/issues/new) or [chat with us on Discord](https://discord.gg/qmk).
+:::
+
+### Compatible APIs
+
+Community Modules may provide specializations for the following APIs:
+
+| Base API                   | API Format                          | Example (`hello_world` module)         | API Version |
+|----------------------------|-------------------------------------|----------------------------------------|-------------|
+| `keyboard_pre_init`        | `keyboard_pre_init_<module>`        | `keyboard_pre_init_hello_world`        | `0.1.0`     |
+| `keyboard_post_init`       | `keyboard_post_init_<module>`       | `keyboard_post_init_hello_world`       | `0.1.0`     |
+| `pre_process_record`       | `pre_process_record_<module>`       | `pre_process_record_hello_world`       | `0.1.0`     |
+| `process_record`           | `process_record_<module>`           | `process_record_hello_world`           | `0.1.0`     |
+| `post_process_record`      | `post_process_record_<module>`      | `post_process_record_hello_world`      | `0.1.0`     |
+| `housekeeping_task`        | `housekeeping_task_<module>`        | `housekeeping_task_hello_world`        | `1.0.0`     |
+| `suspend_power_down`       | `suspend_power_down_<module>`       | `suspend_power_down_hello_world`       | `1.0.0`     |
+| `suspend_wakeup_init`      | `suspend_wakeup_init_<module>`      | `suspend_wakeup_init_hello_world`      | `1.0.0`     |
+| `shutdown`                 | `shutdown_<module>`                 | `shutdown_hello_world`                 | `1.0.0`     |
+| `process_detected_host_os` | `process_detected_host_os_<module>` | `process_detected_host_os_hello_world` | `1.0.0`     |
+
+::: info
+An unspecified API is disregarded if a Community Module does not provide a specialization for it.
+:::
+
+Each API has an equivalent `_<module>_kb()` and `_<module>_user()` hook, as per the normal QMK [`_quantum`, `_kb`, and `_user` functions](/custom_quantum_functions#a-word-on-core-vs-keyboards-vs-keymap).

+ 7 - 0
keyboards/handwired/onekey/keymaps/community_module/keymap.c

@@ -0,0 +1,7 @@
+// Copyright 2025 Nick Brassel (@tzarc)
+// SPDX-License-Identifier: GPL-2.0-or-later
+#include QMK_KEYBOARD_H
+
+const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
+    LAYOUT_ortho_1x1(CM_HELO)
+};

+ 3 - 0
keyboards/handwired/onekey/keymaps/community_module/keymap.json

@@ -0,0 +1,3 @@
+{
+    "modules": ["qmk/hello_world"]
+}

+ 1 - 0
lib/python/qmk/cli/__init__.py

@@ -49,6 +49,7 @@ subcommands = [
     'qmk.cli.generate.api',
     'qmk.cli.generate.autocorrect_data',
     'qmk.cli.generate.compilation_database',
+    'qmk.cli.generate.community_modules',
     'qmk.cli.generate.config_h',
     'qmk.cli.generate.develop_pr_list',
     'qmk.cli.generate.dfu_header',

+ 1 - 1
lib/python/qmk/cli/format/c.py

@@ -10,7 +10,7 @@ from qmk.path import normpath
 from qmk.c_parse import c_source_files
 
 c_file_suffixes = ('c', 'h', 'cpp', 'hpp')
-core_dirs = ('drivers', 'quantum', 'tests', 'tmk_core', 'platforms')
+core_dirs = ('drivers', 'quantum', 'tests', 'tmk_core', 'platforms', 'modules')
 ignored = ('tmk_core/protocol/usb_hid', 'platforms/chibios/boards')
 
 

+ 11 - 2
lib/python/qmk/cli/format/json.py

@@ -9,7 +9,7 @@ from milc import cli
 
 from qmk.info import info_json
 from qmk.json_schema import json_load, validate
-from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder, CommunityModuleJSONEncoder
 from qmk.path import normpath
 
 
@@ -30,6 +30,13 @@ def _detect_json_format(file, json_data):
         except ValidationError:
             pass
 
+    if json_encoder is None:
+        try:
+            validate(json_data, 'qmk.community_module.v1')
+            json_encoder = CommunityModuleJSONEncoder
+        except ValidationError:
+            pass
+
     if json_encoder is None:
         try:
             validate(json_data, 'qmk.keyboard.v1')
@@ -54,6 +61,8 @@ def _get_json_encoder(file, json_data):
         json_encoder = KeymapJSONEncoder
     elif cli.args.format == 'userspace':
         json_encoder = UserspaceJSONEncoder
+    elif cli.args.format == 'community_module':
+        json_encoder = CommunityModuleJSONEncoder
     else:
         # This should be impossible
         cli.log.error('Unknown format: %s', cli.args.format)
@@ -61,7 +70,7 @@ def _get_json_encoder(file, json_data):
 
 
 @cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
-@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap', 'userspace'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
+@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap', 'userspace', 'community_module'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
 @cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
 @cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
 @cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)

+ 263 - 0
lib/python/qmk/cli/generate/community_modules.py

@@ -0,0 +1,263 @@
+import contextlib
+from argcomplete.completers import FilesCompleter
+from pathlib import Path
+
+from milc import cli
+
+import qmk.path
+from qmk.info import get_modules
+from qmk.keyboard import keyboard_completer, keyboard_folder
+from qmk.commands import dump_lines
+from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE
+from qmk.community_modules import module_api_list, load_module_jsons, find_module_path
+
+
+@contextlib.contextmanager
+def _render_api_guard(lines, api):
+    if api.guard:
+        lines.append(f'#if {api.guard}')
+    yield
+    if api.guard:
+        lines.append(f'#endif  // {api.guard}')
+
+
+def _render_api_header(api):
+    lines = []
+    if api.header:
+        lines.append('')
+        with _render_api_guard(lines, api):
+            lines.append(f'#include <{api.header}>')
+    return lines
+
+
+def _render_keycodes(module_jsons):
+    lines = []
+    lines.append('')
+    lines.append('enum {')
+    first = True
+    for module_json in module_jsons:
+        module_name = Path(module_json['module']).name
+        keycodes = module_json.get('keycodes', [])
+        if len(keycodes) > 0:
+            lines.append(f'    // From module: {module_name}')
+            for keycode in keycodes:
+                key = keycode.get('key', None)
+                if first:
+                    lines.append(f'    {key} = QK_COMMUNITY_MODULE,')
+                    first = False
+                else:
+                    lines.append(f'    {key},')
+                for alias in keycode.get('aliases', []):
+                    lines.append(f'    {alias} = {key},')
+            lines.append('')
+    lines.append('    LAST_COMMUNITY_MODULE_KEY')
+    lines.append('};')
+    lines.append('_Static_assert((int)LAST_COMMUNITY_MODULE_KEY <= (int)(QK_COMMUNITY_MODULE_MAX+1), "Too many community module keycodes");')
+    return lines
+
+
+def _render_api_declarations(api, module, user_kb=True):
+    lines = []
+    lines.append('')
+    with _render_api_guard(lines, api):
+        if user_kb:
+            lines.append(f'{api.ret_type} {api.name}_{module}_user({api.args});')
+            lines.append(f'{api.ret_type} {api.name}_{module}_kb({api.args});')
+        lines.append(f'{api.ret_type} {api.name}_{module}({api.args});')
+    return lines
+
+
+def _render_api_implementations(api, module):
+    module_name = Path(module).name
+    lines = []
+    lines.append('')
+    with _render_api_guard(lines, api):
+        # _user
+        lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}_user({api.args}) {{')
+        if api.ret_type == 'bool':
+            lines.append('    return true;')
+        else:
+            pass
+        lines.append('}')
+        lines.append('')
+
+        # _kb
+        lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}_kb({api.args}) {{')
+        if api.ret_type == 'bool':
+            lines.append(f'    if(!{api.name}_{module_name}_user({api.call_params})) {{ return false; }}')
+            lines.append('    return true;')
+        else:
+            lines.append(f'    {api.name}_{module_name}_user({api.call_params});')
+        lines.append('}')
+        lines.append('')
+
+        # module (non-suffixed)
+        lines.append(f'__attribute__((weak)) {api.ret_type} {api.name}_{module_name}({api.args}) {{')
+        if api.ret_type == 'bool':
+            lines.append(f'    if(!{api.name}_{module_name}_kb({api.call_params})) {{ return false; }}')
+            lines.append('    return true;')
+        else:
+            lines.append(f'    {api.name}_{module_name}_kb({api.call_params});')
+        lines.append('}')
+    return lines
+
+
+def _render_core_implementation(api, modules):
+    lines = []
+    lines.append('')
+    with _render_api_guard(lines, api):
+        lines.append(f'{api.ret_type} {api.name}_modules({api.args}) {{')
+        if api.ret_type == 'bool':
+            lines.append('    return true')
+        for module in modules:
+            module_name = Path(module).name
+            if api.ret_type == 'bool':
+                lines.append(f'        && {api.name}_{module_name}({api.call_params})')
+            else:
+                lines.append(f'    {api.name}_{module_name}({api.call_params});')
+        if api.ret_type == 'bool':
+            lines.append('    ;')
+        lines.append('}')
+    return lines
+
+
+@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
+@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
+@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.h for.')
+@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
+@cli.subcommand('Creates a community_modules.h from a keymap.json file.')
+def generate_community_modules_h(cli):
+    """Creates a community_modules.h from a keymap.json file
+    """
+    if cli.args.output and cli.args.output.name == '-':
+        cli.args.output = None
+
+    api_list, api_version, ver_major, ver_minor, ver_patch = module_api_list()
+
+    lines = [
+        GPL2_HEADER_C_LIKE,
+        GENERATED_HEADER_C_LIKE,
+        '#pragma once',
+        '#include <stdint.h>',
+        '#include <stdbool.h>',
+        '#include <keycodes.h>',
+        '',
+        '#define COMMUNITY_MODULES_API_VERSION_BUILDER(ver_major,ver_minor,ver_patch) (((((uint32_t)(ver_major))&0xFF) << 24) | ((((uint32_t)(ver_minor))&0xFF) << 16) | (((uint32_t)(ver_patch))&0xFF))',
+        f'#define COMMUNITY_MODULES_API_VERSION COMMUNITY_MODULES_API_VERSION_BUILDER({ver_major},{ver_minor},{ver_patch})',
+        f'#define ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(ver_major,ver_minor,ver_patch) _Static_assert(COMMUNITY_MODULES_API_VERSION_BUILDER(ver_major,ver_minor,ver_patch) <= COMMUNITY_MODULES_API_VERSION, "Community module requires a newer version of QMK modules API -- needs: " #ver_major "." #ver_minor "." #ver_patch ", current: {api_version}.")',
+        '',
+        'typedef struct keyrecord_t keyrecord_t; // forward declaration so we don\'t need to include quantum.h',
+        '',
+    ]
+
+    modules = get_modules(cli.args.keyboard, cli.args.filename)
+    module_jsons = load_module_jsons(modules)
+    if len(modules) > 0:
+        lines.extend(_render_keycodes(module_jsons))
+
+        for api in api_list:
+            lines.extend(_render_api_header(api))
+
+        for module in modules:
+            lines.append('')
+            lines.append(f'// From module: {module}')
+            for api in api_list:
+                lines.extend(_render_api_declarations(api, Path(module).name))
+        lines.append('')
+
+        lines.append('// Core wrapper')
+        for api in api_list:
+            lines.extend(_render_api_declarations(api, 'modules', user_kb=False))
+
+    dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
+
+
+@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
+@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
+@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.c for.')
+@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
+@cli.subcommand('Creates a community_modules.c from a keymap.json file.')
+def generate_community_modules_c(cli):
+    """Creates a community_modules.c from a keymap.json file
+    """
+    if cli.args.output and cli.args.output.name == '-':
+        cli.args.output = None
+
+    api_list, _, _, _, _ = module_api_list()
+
+    lines = [
+        GPL2_HEADER_C_LIKE,
+        GENERATED_HEADER_C_LIKE,
+        '',
+        '#include "community_modules.h"',
+    ]
+
+    modules = get_modules(cli.args.keyboard, cli.args.filename)
+    if len(modules) > 0:
+
+        for module in modules:
+            for api in api_list:
+                lines.extend(_render_api_implementations(api, Path(module).name))
+
+        for api in api_list:
+            lines.extend(_render_core_implementation(api, modules))
+
+    dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
+
+
+@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
+@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
+@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.c for.')
+@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
+@cli.subcommand('Creates a community_modules_introspection.h from a keymap.json file.')
+def generate_community_modules_introspection_h(cli):
+    """Creates a community_modules_introspection.h from a keymap.json file
+    """
+    if cli.args.output and cli.args.output.name == '-':
+        cli.args.output = None
+
+    lines = [
+        GPL2_HEADER_C_LIKE,
+        GENERATED_HEADER_C_LIKE,
+        '',
+    ]
+
+    modules = get_modules(cli.args.keyboard, cli.args.filename)
+    if len(modules) > 0:
+        for module in modules:
+            module_path = find_module_path(module)
+            lines.append(f'#if __has_include("{module_path}/introspection.h")')
+            lines.append(f'#include "{module_path}/introspection.h"')
+            lines.append(f'#endif  // __has_include("{module_path}/introspection.h")')
+            lines.append('')
+
+    dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)
+
+
+@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
+@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
+@cli.argument('-kb', '--keyboard', arg_only=True, type=keyboard_folder, completer=keyboard_completer, help='Keyboard to generate community_modules.c for.')
+@cli.argument('filename', nargs='?', type=qmk.path.FileType('r'), arg_only=True, completer=FilesCompleter('.json'), help='Configurator JSON file')
+@cli.subcommand('Creates a community_modules_introspection.c from a keymap.json file.')
+def generate_community_modules_introspection_c(cli):
+    """Creates a community_modules_introspection.c from a keymap.json file
+    """
+    if cli.args.output and cli.args.output.name == '-':
+        cli.args.output = None
+
+    lines = [
+        GPL2_HEADER_C_LIKE,
+        GENERATED_HEADER_C_LIKE,
+        '',
+    ]
+
+    modules = get_modules(cli.args.keyboard, cli.args.filename)
+    if len(modules) > 0:
+        for module in modules:
+            module_path = find_module_path(module)
+            lines.append(f'#if __has_include("{module_path}/introspection.c")')
+            lines.append(f'#include "{module_path}/introspection.c"')
+            lines.append(f'#endif  // __has_include("{module_path}/introspection.c")')
+            lines.append('')
+
+    dump_lines(cli.args.output, lines, cli.args.quiet, remove_repeated_newlines=True)

+ 41 - 5
lib/python/qmk/cli/generate/rules_mk.py

@@ -6,12 +6,13 @@ from dotty_dict import dotty
 from argcomplete.completers import FilesCompleter
 from milc import cli
 
-from qmk.info import info_json
+from qmk.info import info_json, get_modules
 from qmk.json_schema import json_load
 from qmk.keyboard import keyboard_completer, keyboard_folder
 from qmk.commands import dump_lines, parse_configurator_json
 from qmk.path import normpath, FileType
 from qmk.constants import GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE
+from qmk.community_modules import find_module_path, load_module_jsons
 
 
 def generate_rule(rules_key, rules_value):
@@ -46,6 +47,42 @@ def process_mapping_rule(kb_info_json, rules_key, info_dict):
     return generate_rule(rules_key, rules_value)
 
 
+def generate_features_rules(features_dict):
+    lines = []
+    for feature, enabled in features_dict.items():
+        feature = feature.upper()
+        enabled = 'yes' if enabled else 'no'
+        lines.append(generate_rule(f'{feature}_ENABLE', enabled))
+    return lines
+
+
+def generate_modules_rules(keyboard, filename):
+    lines = []
+    modules = get_modules(keyboard, filename)
+    if len(modules) > 0:
+        lines.append('')
+        lines.append('OPT_DEFS += -DCOMMUNITY_MODULES_ENABLE=TRUE')
+        for module in modules:
+            module_path = find_module_path(module)
+            if not module_path:
+                raise FileNotFoundError(f"Module '{module}' not found.")
+            lines.append('')
+            lines.append(f'COMMUNITY_MODULES += {module_path.name}')  # use module_path here instead of module as it may be a subdirectory
+            lines.append(f'OPT_DEFS += -DCOMMUNITY_MODULE_{module_path.name.upper()}_ENABLE=TRUE')
+            lines.append(f'COMMUNITY_MODULE_PATHS += {module_path}')
+            lines.append(f'VPATH += {module_path}')
+            lines.append(f'SRC += $(wildcard {module_path}/{module_path.name}.c)')
+            lines.append(f'-include {module_path}/rules.mk')
+
+        module_jsons = load_module_jsons(modules)
+        for module_json in module_jsons:
+            if 'features' in module_json:
+                lines.append('')
+                lines.append(f'# Module: {module_json["module_name"]}')
+                lines.extend(generate_features_rules(module_json['features']))
+    return lines
+
+
 @cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), completer=FilesCompleter('.json'), help='A configurator export JSON to be compiled and flashed or a pre-compiled binary firmware file (bin/hex) to be flashed.')
 @cli.argument('-o', '--output', arg_only=True, type=normpath, help='File to write to')
 @cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
@@ -80,10 +117,7 @@ def generate_rules_mk(cli):
 
     # Iterate through features to enable/disable them
     if 'features' in kb_info_json:
-        for feature, enabled in kb_info_json['features'].items():
-            feature = feature.upper()
-            enabled = 'yes' if enabled else 'no'
-            rules_mk_lines.append(generate_rule(f'{feature}_ENABLE', enabled))
+        rules_mk_lines.extend(generate_features_rules(kb_info_json['features']))
 
     # Set SPLIT_TRANSPORT, if needed
     if kb_info_json.get('split', {}).get('transport', {}).get('protocol') == 'custom':
@@ -99,6 +133,8 @@ def generate_rules_mk(cli):
     if converter:
         rules_mk_lines.append(generate_rule('CONVERT_TO', converter))
 
+    rules_mk_lines.extend(generate_modules_rules(cli.args.keyboard, cli.args.filename))
+
     # Show the results
     dump_lines(cli.args.output, rules_mk_lines)
 

+ 5 - 0
lib/python/qmk/cli/info.py

@@ -52,6 +52,11 @@ def show_keymap(kb_info_json, title_caps=True):
 
     if keymap_path and keymap_path.suffix == '.json':
         keymap_data = json.load(keymap_path.open(encoding='utf-8'))
+
+        # cater for layout-less keymap.json
+        if 'layout' not in keymap_data:
+            return
+
         layout_name = keymap_data['layout']
         layout_name = kb_info_json.get('layout_aliases', {}).get(layout_name, layout_name)  # Resolve alias names
 

+ 4 - 1
lib/python/qmk/commands.py

@@ -98,11 +98,14 @@ def in_virtualenv():
     return active_prefix != sys.prefix
 
 
-def dump_lines(output_file, lines, quiet=True):
+def dump_lines(output_file, lines, quiet=True, remove_repeated_newlines=False):
     """Handle dumping to stdout or file
     Creates parent folders if required
     """
     generated = '\n'.join(lines) + '\n'
+    if remove_repeated_newlines:
+        while '\n\n\n' in generated:
+            generated = generated.replace('\n\n\n', '\n\n')
     if output_file and output_file.name != '-':
         output_file.parent.mkdir(parents=True, exist_ok=True)
         if output_file.exists():

+ 100 - 0
lib/python/qmk/community_modules.py

@@ -0,0 +1,100 @@
+import os
+
+from pathlib import Path
+from functools import lru_cache
+
+from milc.attrdict import AttrDict
+
+from qmk.json_schema import json_load, validate, merge_ordered_dicts
+from qmk.util import truthy
+from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
+from qmk.path import under_qmk_firmware, under_qmk_userspace
+
+COMMUNITY_MODULE_JSON_FILENAME = 'qmk_module.json'
+
+
+class ModuleAPI(AttrDict):
+    def __init__(self, **kwargs):
+        super().__init__()
+        for key, value in kwargs.items():
+            self[key] = value
+
+
+@lru_cache(maxsize=1)
+def module_api_list():
+    module_definition_files = sorted(set(QMK_FIRMWARE.glob('data/constants/module_hooks/*.hjson')))
+    module_definition_jsons = [json_load(f) for f in module_definition_files]
+    module_definitions = merge_ordered_dicts(module_definition_jsons)
+    latest_module_version = module_definition_files[-1].stem
+    latest_module_version_parts = latest_module_version.split('.')
+
+    api_list = []
+    for name, mod in module_definitions.items():
+        api_list.append(ModuleAPI(
+            ret_type=mod['ret_type'],
+            name=name,
+            args=mod['args'],
+            call_params=mod.get('call_params', ''),
+            guard=mod.get('guard', None),
+            header=mod.get('header', None),
+        ))
+
+    return api_list, latest_module_version, latest_module_version_parts[0], latest_module_version_parts[1], latest_module_version_parts[2]
+
+
+def find_available_module_paths():
+    """Find all available modules.
+    """
+    search_dirs = []
+    if HAS_QMK_USERSPACE:
+        search_dirs.append(QMK_USERSPACE / 'modules')
+    search_dirs.append(QMK_FIRMWARE / 'modules')
+
+    modules = []
+    for search_dir in search_dirs:
+        for module_json_path in search_dir.rglob(COMMUNITY_MODULE_JSON_FILENAME):
+            modules.append(module_json_path.parent)
+    return modules
+
+
+def find_module_path(module):
+    """Find a module by name.
+    """
+    for module_path in find_available_module_paths():
+        # Ensure the module directory is under QMK Firmware or QMK Userspace
+        relative_path = under_qmk_firmware(module_path)
+        if not relative_path:
+            relative_path = under_qmk_userspace(module_path)
+        if not relative_path:
+            continue
+
+        lhs = str(relative_path.as_posix())[len('modules/'):]
+        rhs = str(Path(module).as_posix())
+
+        if relative_path and lhs == rhs:
+            return module_path
+    return None
+
+
+def load_module_json(module):
+    """Load a module JSON file.
+    """
+    module_path = find_module_path(module)
+    if not module_path:
+        raise FileNotFoundError(f'Module not found: {module}')
+
+    module_json = json_load(module_path / COMMUNITY_MODULE_JSON_FILENAME)
+
+    if not truthy(os.environ.get('SKIP_SCHEMA_VALIDATION'), False):
+        validate(module_json, 'qmk.community_module.v1')
+
+    module_json['module'] = module
+    module_json['module_path'] = module_path
+
+    return module_json
+
+
+def load_module_jsons(modules):
+    """Load the module JSON files, matching the specified order.
+    """
+    return list(map(load_module_json, modules))

+ 27 - 0
lib/python/qmk/info.py

@@ -1059,3 +1059,30 @@ def keymap_json(keyboard, keymap, force_layout=None):
     _extract_config_h(kb_info_json, parse_config_h_file(keymap_config))
 
     return kb_info_json
+
+
+def get_modules(keyboard, keymap_filename):
+    """Get the modules for a keyboard/keymap.
+    """
+    modules = []
+
+    if keymap_filename:
+        keymap_json = parse_configurator_json(keymap_filename)
+
+        if keymap_json:
+            kb = keymap_json.get('keyboard', None)
+            if not kb:
+                kb = keyboard
+
+            if kb:
+                kb_info_json = info_json(kb)
+                if kb_info_json:
+                    modules.extend(kb_info_json.get('modules', []))
+
+            modules.extend(keymap_json.get('modules', []))
+
+    elif keyboard:
+        kb_info_json = info_json(keyboard)
+        modules.extend(kb_info_json.get('modules', []))
+
+    return list(dict.fromkeys(modules))  # remove dupes

+ 28 - 0
lib/python/qmk/json_encoders.py

@@ -235,3 +235,31 @@ class UserspaceJSONEncoder(QMKJSONEncoder):
                 return '01build_targets'
 
         return key
+
+
+class CommunityModuleJSONEncoder(QMKJSONEncoder):
+    """Custom encoder to make qmk_module.json's a little nicer to work with.
+    """
+    def sort_dict(self, item):
+        """Sorts the hashes in a nice way.
+        """
+        key = item[0]
+
+        if self.indentation_level == 1:
+            if key == 'module_name':
+                return '00module_name'
+            if key == 'maintainer':
+                return '01maintainer'
+            if key == 'url':
+                return '02url'
+            if key == 'features':
+                return '03features'
+            if key == 'keycodes':
+                return '04keycodes'
+        elif self.indentation_level == 3:  # keycodes
+            if key == 'key':
+                return '00key'
+            if key == 'aliases':
+                return '01aliases'
+
+        return key

+ 0 - 27
lib/python/qmk/keymap.py

@@ -334,33 +334,6 @@ def write_json(keyboard, keymap, layout, layers, macros=None):
     return write_file(keymap_file, keymap_content)
 
 
-def write(keymap_json):
-    """Generate the `keymap.c` and write it to disk.
-
-    Returns the filename written to.
-
-    `keymap_json` should be a dict with the following keys:
-        keyboard
-            The name of the keyboard
-
-        keymap
-            The name of the keymap
-
-        layout
-            The LAYOUT macro this keymap uses.
-
-        layers
-            An array of arrays describing the keymap. Each item in the inner array should be a string that is a valid QMK keycode.
-
-        macros
-            A list of macros for this keymap.
-    """
-    keymap_content = generate_c(keymap_json)
-    keymap_file = qmk.path.keymaps(keymap_json['keyboard'])[0] / keymap_json['keymap'] / 'keymap.c'
-
-    return write_file(keymap_file, keymap_content)
-
-
 def locate_keymap(keyboard, keymap, force_layout=None):
     """Returns the path to a keymap for a specific keyboard.
     """

+ 33 - 0
modules/qmk/hello_world/hello_world.c

@@ -0,0 +1,33 @@
+// Copyright 2025 Nick Brassel (@tzarc)
+// SPDX-License-Identifier: GPL-2.0-or-later
+#include QMK_KEYBOARD_H
+
+#include "introspection.h"
+
+ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0);
+
+uint32_t delayed_hello_world(uint32_t trigger_time, void *cb_arg) {
+    printf("Hello, world! I'm a QMK based keyboard! The keymap array size is %d bytes.\n", (int)hello_world_introspection().total_size);
+    return 0;
+}
+
+void keyboard_post_init_hello_world(void) {
+    keyboard_post_init_hello_world_kb();
+    defer_exec(10000, delayed_hello_world, NULL);
+}
+
+bool process_record_hello_world(uint16_t keycode, keyrecord_t *record) {
+    if (!process_record_hello_world_kb(keycode, record)) {
+        return false;
+    }
+
+    switch (keycode) {
+        case COMMUNITY_MODULE_HELLO:
+            if (record->event.pressed) {
+                SEND_STRING("Hello there.");
+                break;
+            }
+    }
+
+    return true;
+}

+ 10 - 0
modules/qmk/hello_world/introspection.c

@@ -0,0 +1,10 @@
+// Copyright 2025 Nick Brassel (@tzarc)
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+hello_world_introspection_t hello_world_introspection(void) {
+    hello_world_introspection_t introspection = {
+        .total_size  = sizeof(keymaps),
+        .layer_count = sizeof(keymaps) / sizeof(keymaps[0]),
+    };
+    return introspection;
+}

+ 10 - 0
modules/qmk/hello_world/introspection.h

@@ -0,0 +1,10 @@
+// Copyright 2025 Nick Brassel (@tzarc)
+// SPDX-License-Identifier: GPL-2.0-or-later
+#include QMK_KEYBOARD_H
+
+typedef struct hello_world_introspection_t {
+    int16_t total_size;
+    int16_t layer_count;
+} hello_world_introspection_t;
+
+hello_world_introspection_t hello_world_introspection(void);

+ 13 - 0
modules/qmk/hello_world/qmk_module.json

@@ -0,0 +1,13 @@
+{
+    "module_name": "Hello World",
+    "maintainer": "QMK Maintainers",
+    "features": {
+        "deferred_exec": true
+    },
+    "keycodes": [
+        {
+            "key": "COMMUNITY_MODULE_HELLO",
+            "aliases": ["CM_HELO"]
+        }
+    ]
+}

+ 2 - 0
modules/qmk/hello_world/rules.mk

@@ -0,0 +1,2 @@
+# Just a simple rules.mk which tests that they work from a community module.
+$(shell $(QMK_BIN) hello -n "from QMK's hello world community module")

+ 1 - 1
quantum/action.h

@@ -45,7 +45,7 @@ typedef struct {
 } tap_t;
 
 /* Key event container for recording */
-typedef struct {
+typedef struct keyrecord_t {
     keyevent_t event;
 #ifndef NO_ACTION_TAPPING
     tap_t tap;

+ 42 - 2
quantum/keyboard.c

@@ -289,6 +289,21 @@ __attribute__((weak)) void keyboard_pre_init_kb(void) {
     keyboard_pre_init_user();
 }
 
+/** \brief keyboard_pre_init_modules
+ *
+ * FIXME: needs doc
+ */
+__attribute__((weak)) void keyboard_pre_init_modules(void) {}
+
+/** \brief keyboard_pre_init_quantum
+ *
+ * FIXME: needs doc
+ */
+void keyboard_pre_init_quantum(void) {
+    keyboard_pre_init_modules();
+    keyboard_pre_init_kb();
+}
+
 /** \brief keyboard_post_init_user
  *
  * FIXME: needs doc
@@ -305,6 +320,23 @@ __attribute__((weak)) void keyboard_post_init_kb(void) {
     keyboard_post_init_user();
 }
 
+/** \brief keyboard_post_init_modules
+ *
+ * FIXME: needs doc
+ */
+
+__attribute__((weak)) void keyboard_post_init_modules(void) {}
+
+/** \brief keyboard_post_init_quantum
+ *
+ * FIXME: needs doc
+ */
+
+void keyboard_post_init_quantum(void) {
+    keyboard_post_init_modules();
+    keyboard_post_init_kb();
+}
+
 /** \brief matrix_can_read
  *
  * Allows overriding when matrix scanning operations should be executed.
@@ -323,7 +355,7 @@ void keyboard_setup(void) {
     eeprom_driver_init();
 #endif
     matrix_setup();
-    keyboard_pre_init_kb();
+    keyboard_pre_init_quantum();
 }
 
 #ifndef SPLIT_KEYBOARD
@@ -355,6 +387,13 @@ __attribute__((weak)) bool should_process_keypress(void) {
     return is_keyboard_master();
 }
 
+/** \brief housekeeping_task_modules
+ *
+ * Codegen will override this if community modules are enabled.
+ * This is specific to keyboard-level functionality.
+ */
+__attribute__((weak)) void housekeeping_task_modules(void) {}
+
 /** \brief housekeeping_task_kb
  *
  * Override this function if you have a need to execute code for every keyboard main loop iteration.
@@ -374,6 +413,7 @@ __attribute__((weak)) void housekeeping_task_user(void) {}
  * Invokes hooks for executing code after QMK is done after each loop iteration.
  */
 void housekeeping_task(void) {
+    housekeeping_task_modules();
     housekeeping_task_kb();
     housekeeping_task_user();
 }
@@ -493,7 +533,7 @@ void keyboard_init(void) {
     debug_enable = true;
 #endif
 
-    keyboard_post_init_kb(); /* Always keep this last */
+    keyboard_post_init_quantum(); /* Always keep this last */
 }
 
 /** \brief key_event_task

+ 3 - 0
quantum/keycodes.h

@@ -76,6 +76,8 @@ enum qk_keycode_ranges {
     QK_MACRO_MAX                   = 0x777F,
     QK_CONNECTION                  = 0x7780,
     QK_CONNECTION_MAX              = 0x77BF,
+    QK_COMMUNITY_MODULE            = 0x77C0,
+    QK_COMMUNITY_MODULE_MAX        = 0x77FF,
     QK_LIGHTING                    = 0x7800,
     QK_LIGHTING_MAX                = 0x78FF,
     QK_QUANTUM                     = 0x7C00,
@@ -1476,6 +1478,7 @@ enum qk_keycode_defines {
 #define IS_QK_STENO(code) ((code) >= QK_STENO && (code) <= QK_STENO_MAX)
 #define IS_QK_MACRO(code) ((code) >= QK_MACRO && (code) <= QK_MACRO_MAX)
 #define IS_QK_CONNECTION(code) ((code) >= QK_CONNECTION && (code) <= QK_CONNECTION_MAX)
+#define IS_QK_COMMUNITY_MODULE(code) ((code) >= QK_COMMUNITY_MODULE && (code) <= QK_COMMUNITY_MODULE_MAX)
 #define IS_QK_LIGHTING(code) ((code) >= QK_LIGHTING && (code) <= QK_LIGHTING_MAX)
 #define IS_QK_QUANTUM(code) ((code) >= QK_QUANTUM && (code) <= QK_QUANTUM_MAX)
 #define IS_QK_KB(code) ((code) >= QK_KB && (code) <= QK_KB_MAX)

+ 11 - 0
quantum/keymap_introspection.c

@@ -1,6 +1,10 @@
 // Copyright 2022 Nick Brassel (@tzarc)
 // SPDX-License-Identifier: GPL-2.0-or-later
 
+#if defined(COMMUNITY_MODULES_ENABLE)
+#    include "community_modules_introspection.h"
+#endif // defined(COMMUNITY_MODULES_ENABLE)
+
 // Pull the actual keymap code so that we can inspect stuff from it
 #include KEYMAP_C
 
@@ -171,3 +175,10 @@ __attribute__((weak)) const key_override_t* key_override_get(uint16_t key_overri
 }
 
 #endif // defined(KEY_OVERRIDE_ENABLE)
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+// Community modules (must be last in this file!)
+
+#if defined(COMMUNITY_MODULES_ENABLE)
+#    include "community_modules_introspection.c"
+#endif // defined(COMMUNITY_MODULES_ENABLE)

+ 7 - 0
quantum/os_detection.c

@@ -72,6 +72,8 @@ static volatile struct usb_device_state maxprev_usb_device_state = {.configure_s
 static volatile bool         debouncing = false;
 static volatile fast_timer_t last_time  = 0;
 
+bool process_detected_host_os_modules(os_variant_t os);
+
 void os_detection_task(void) {
 #ifdef OS_DETECTION_KEYBOARD_RESET
     // resetting the keyboard on the USB device state change callback results in instability, so delegate that to this task
@@ -96,12 +98,17 @@ void os_detection_task(void) {
             if (detected_os != reported_os || first_report) {
                 first_report = false;
                 reported_os  = detected_os;
+                process_detected_host_os_modules(detected_os);
                 process_detected_host_os_kb(detected_os);
             }
         }
     }
 }
 
+__attribute__((weak)) bool process_detected_host_os_modules(os_variant_t os) {
+    return true;
+}
+
 __attribute__((weak)) bool process_detected_host_os_kb(os_variant_t detected_os) {
     return process_detected_host_os_user(detected_os);
 }

+ 25 - 1
quantum/quantum.c

@@ -162,6 +162,10 @@ __attribute__((weak)) void tap_code16(uint16_t code) {
     tap_code16_delay(code, code == KC_CAPS_LOCK ? TAP_HOLD_CAPS_DELAY : TAP_CODE_DELAY);
 }
 
+__attribute__((weak)) bool pre_process_record_modules(uint16_t keycode, keyrecord_t *record) {
+    return true;
+}
+
 __attribute__((weak)) bool pre_process_record_kb(uint16_t keycode, keyrecord_t *record) {
     return pre_process_record_user(keycode, record);
 }
@@ -174,6 +178,10 @@ __attribute__((weak)) bool process_action_kb(keyrecord_t *record) {
     return true;
 }
 
+__attribute__((weak)) bool process_record_modules(uint16_t keycode, keyrecord_t *record) {
+    return true;
+}
+
 __attribute__((weak)) bool process_record_kb(uint16_t keycode, keyrecord_t *record) {
     return process_record_user(keycode, record);
 }
@@ -182,12 +190,22 @@ __attribute__((weak)) bool process_record_user(uint16_t keycode, keyrecord_t *re
     return true;
 }
 
+__attribute__((weak)) void post_process_record_modules(uint16_t keycode, keyrecord_t *record) {}
+
 __attribute__((weak)) void post_process_record_kb(uint16_t keycode, keyrecord_t *record) {
     post_process_record_user(keycode, record);
 }
 
 __attribute__((weak)) void post_process_record_user(uint16_t keycode, keyrecord_t *record) {}
 
+__attribute__((weak)) bool shutdown_modules(bool jump_to_bootloader) {
+    return true;
+}
+
+__attribute__((weak)) void suspend_power_down_modules(void) {}
+
+__attribute__((weak)) void suspend_wakeup_init_modules(void) {}
+
 void shutdown_quantum(bool jump_to_bootloader) {
     clear_keyboard();
 #if defined(MIDI_ENABLE) && defined(MIDI_BASIC)
@@ -199,11 +217,13 @@ void shutdown_quantum(bool jump_to_bootloader) {
 #    endif
     uint16_t timer_start = timer_read();
     PLAY_SONG(goodbye_song);
+    shutdown_modules(jump_to_bootloader);
     shutdown_kb(jump_to_bootloader);
     while (timer_elapsed(timer_start) < 250)
         wait_ms(1);
     stop_all_notes();
 #else
+    shutdown_modules(jump_to_bootloader);
     shutdown_kb(jump_to_bootloader);
     wait_ms(250);
 #endif
@@ -258,7 +278,7 @@ uint16_t get_event_keycode(keyevent_t event, bool update_layer_cache) {
 
 /* Get keycode, and then process pre tapping functionality */
 bool pre_process_record_quantum(keyrecord_t *record) {
-    return pre_process_record_kb(get_record_keycode(record, true), record) &&
+    return pre_process_record_modules(get_record_keycode(record, true), record) && pre_process_record_kb(get_record_keycode(record, true), record) &&
 #ifdef COMBO_ENABLE
            process_combo(get_record_keycode(record, true), record) &&
 #endif
@@ -268,6 +288,7 @@ bool pre_process_record_quantum(keyrecord_t *record) {
 /* Get keycode, and then call keyboard function */
 void post_process_record_quantum(keyrecord_t *record) {
     uint16_t keycode = get_record_keycode(record, false);
+    post_process_record_modules(keycode, record);
     post_process_record_kb(keycode, record);
 }
 
@@ -332,6 +353,7 @@ bool process_record_quantum(keyrecord_t *record) {
 #if defined(POINTING_DEVICE_ENABLE) && defined(POINTING_DEVICE_AUTO_MOUSE_ENABLE)
             process_auto_mouse(keycode, record) &&
 #endif
+            process_record_modules(keycode, record) && // modules must run before kb
             process_record_kb(keycode, record) &&
 #if defined(VIA_ENABLE)
             process_record_via(keycode, record) &&
@@ -526,6 +548,7 @@ __attribute__((weak)) bool shutdown_kb(bool jump_to_bootloader) {
 }
 
 void suspend_power_down_quantum(void) {
+    suspend_power_down_modules();
     suspend_power_down_kb();
 #ifndef NO_SUSPEND_POWER_DOWN
 // Turn off backlight
@@ -593,6 +616,7 @@ __attribute__((weak)) void suspend_wakeup_init_quantum(void) {
 #if defined(RGB_MATRIX_ENABLE)
     rgb_matrix_set_suspend_state(false);
 #endif
+    suspend_wakeup_init_modules();
     suspend_wakeup_init_kb();
 }
 

+ 4 - 0
quantum/quantum.h

@@ -244,6 +244,10 @@ extern layer_state_t layer_state;
 #    include "layer_lock.h"
 #endif
 
+#ifdef COMMUNITY_MODULES_ENABLE
+#    include "community_modules.h"
+#endif
+
 void set_single_default_layer(uint8_t default_layer);
 void set_single_persistent_default_layer(uint8_t default_layer);