diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..36245cc
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,142 @@
+name: Lua Tests
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+ branches: [ main, master ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ name: Check
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Lua
+ uses: leafo/gh-actions-lua@v11
+ with:
+ luaVersion: '5.4'
+
+ - name: Setup LuaRocks
+ uses: leafo/gh-actions-luarocks@v5
+ with:
+ luarocksVersion: "3.12.2"
+
+ - name: Install luacheck
+ run: luarocks install luacheck
+
+ - name: Install stylua
+ uses: JohnnyMorganz/stylua-action@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ version: v2.1.0
+ args: false # Will be run as part of `make check`
+
+ - name: Check
+ run: make check
+
+ test:
+ needs: check
+ runs-on: ubuntu-latest
+ continue-on-error: false
+ strategy:
+ fail-fast: true
+ matrix:
+ lua-version: ['5.1', '5.2', '5.3', '5.4', 'luajit-2.0', 'luajit-2.1']
+
+ name: Lua ${{ matrix.lua-version }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Lua
+ uses: leafo/gh-actions-lua@v11
+ with:
+ luaVersion: ${{ matrix.lua-version }}
+
+ - name: Setup tests
+ run: |
+ # Set the correct binary name based on the Lua version
+ if [[ "${{ matrix.lua-version }}" == luajit* ]]; then
+ LUA_BINARY=luajit
+ else
+ LUA_BINARY=lua
+ fi
+
+ echo "LUA_BINARY=$LUA_BINARY" >> "$GITHUB_ENV"
+ ${LUA_BINARY} -v
+
+ - name: Run tests
+ run: |
+ make test-all
+
+ build:
+ needs: test
+ runs-on: ubuntu-latest
+ name: Build Combined Module
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Lua
+ uses: leafo/gh-actions-lua@v11
+ with:
+ luaVersion: '5.4'
+
+ - name: Setup LuaRocks
+ uses: leafo/gh-actions-luarocks@v5
+ with:
+ luarocksVersion: "3.12.2"
+
+ - name: Install amalg
+ run: luarocks install amalg
+
+ - name: Build combined module
+ run: make build
+
+ - name: Verify build
+ run: |
+ # Check full build
+ if [ -f "build/bthome.lua" ]; then
+ echo "bthome.lua exists ($(wc -c < "build/bthome.lua") bytes)"
+ if grep -q "bthome" build/bthome.lua; then
+ echo "bthome.lua contains expected content"
+ else
+ echo "bthome.lua seems corrupted"
+ exit 1
+ fi
+ else
+ echo "build/bthome.lua missing"
+ exit 1
+ fi
+
+ # Check core build
+ if [ -f "build/bthome-core.lua" ]; then
+ echo "bthome-core.lua exists ($(wc -c < "build/bthome-core.lua") bytes)"
+ if grep -q "bthome" build/bthome-core.lua; then
+ echo "bthome-core.lua contains expected content"
+ else
+ echo "bthome-core.lua seems corrupted"
+ exit 1
+ fi
+ else
+ echo "build/bthome-core.lua missing"
+ exit 1
+ fi
+
+ - name: Upload bthome.lua artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bthome.lua
+ path: build/bthome.lua
+ retention-days: 30
+
+ - name: Upload bthome-core.lua artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bthome-core.lua
+ path: build/bthome-core.lua
+ retention-days: 30
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..42b8524
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,48 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Lua
+ uses: leafo/gh-actions-lua@v11
+ with:
+ luaVersion: '5.4'
+
+ - name: Setup LuaRocks
+ uses: leafo/gh-actions-luarocks@v5
+ with:
+ luarocksVersion: "3.12.2"
+
+ - name: Install amalg
+ run: luarocks install amalg
+
+ - name: Build combined modules
+ run: |
+ # The make build command will automatically inject the version from the git tag
+ make build
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ draft: false
+ prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
+ name: Release ${{ github.ref_name }}
+ tag_name: ${{ github.ref }}
+ generate_release_notes: true
+ files: |
+ build/bthome.lua
+ build/bthome-core.lua
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..284737f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# Compiled Lua sources
+luac.out
+
+# luarocks build files
+*.src.rock
+*.zip
+*.tar.gz
+
+# Object files
+*.o
+*.os
+*.ko
+*.obj
+*.elf
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Libraries
+*.lib
+*.a
+*.la
+*.lo
+*.def
+*.exp
+
+# Shared objects (inc. Windows DLLs)
+*.dll
+*.so
+*.so.*
+*.dylib
+
+# Executables
+*.exe
+*.out
+*.app
+*.i*86
+*.x86_64
+*.hex
+
+# Build artifacts
+build/
diff --git a/.luacheckrc b/.luacheckrc
new file mode 100644
index 0000000..34544ed
--- /dev/null
+++ b/.luacheckrc
@@ -0,0 +1,12 @@
+std = "min"
+globals = {
+ unpack = {}
+}
+ignore = {
+ "212/_.*",
+ "211/_.*",
+ "213/_.*",
+}
+compat = true
+unused_args = false
+max_line_length = false
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a5ad507
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,189 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is a pure Lua implementation of the BTHome BLE advertisement parser with zero external dependencies. It supports both BTHome V1 and V2 formats, including encrypted advertisements using AES-128-CCM.
+
+**Key Characteristics:**
+- Pure Lua implementation (5.1+ and LuaJIT compatible)
+- Zero dependencies for maximum portability
+- Complete BTHome V1/V2 parsing
+- AES-128-CCM decryption for encrypted payloads
+- Extensive test coverage with official bthome-ble test vectors
+
+## Development Guide
+
+## Project Structure
+
+```
+lua-bthome-ble/
+├── src/bthome/
+│ ├── init.lua # Module aggregator with version()
+│ ├── const.lua # Object IDs, data types, factors, units
+│ ├── event.lua # Button/dimmer event definitions
+│ ├── parser.lua # BLE advertisement parsing
+│ └── crypto/
+│ ├── init.lua # Crypto aggregator
+│ └── aes_ccm.lua # AES-128 + AES-CCM AEAD for BTHome encryption
+├── vendor/
+│ └── bitn.lua # Vendored bitwise operations library
+├── .github/workflows/
+│ ├── build.yml # CI: lint, test matrix, build
+│ └── release.yml # Release automation
+├── run_tests.sh # Main test runner
+├── run_tests_matrix.sh # Multi-version test runner
+└── Makefile # Build automation
+```
+
+## Key Commands
+
+```bash
+# Run tests
+make test
+
+# Run specific module tests
+make test-bthome
+make test-parser
+make test-crypto
+
+# Run across Lua versions
+make test-matrix
+
+# Format code
+make format
+
+# Lint code
+make lint
+
+# Run all quality checks
+make check
+
+# Build single-file distributions
+make build
+# Output: build/bthome.lua (full) and build/bthome-core.lua (without vendor)
+
+# Install development dependencies
+make install-deps
+```
+
+## Architecture
+
+### Module Design
+
+The BTHome library provides parsing for BTHome V1 and V2 BLE advertisements:
+
+- **const.lua**: 78+ sensor object IDs from bthome.io/format spec
+- **event.lua**: Button events (press, double_press, long_press, etc.) and dimmer events
+- **parser.lua**: Main parsing logic for device info, object IDs, and encrypted payloads
+- **crypto/aes_ccm.lua**: AES-128 block cipher and AES-CCM AEAD for encrypted BTHome advertisements
+
+### BTHome Protocol
+
+- **Service UUIDs**:
+ - `0x181C` - V1 unencrypted
+ - `0x181E` - V1 encrypted
+ - `0xFCD2` - V2 (encryption determined by device_info byte)
+- **V2 Device Info Byte**: Bit 0=encrypted, Bit 2=trigger, Bits 5-7=version
+- **Data Format**: Object ID followed by little-endian value bytes
+- **Encryption**: AES-128-CCM, 16-byte key, 4-byte MIC
+
+### Public API
+
+```lua
+local bthome = require("bthome")
+
+-- Parse V2 unencrypted advertisement
+local result = bthome.parse(bthome.UUID_V2, service_data)
+
+-- Parse V2 encrypted advertisement
+local result = bthome.parse(bthome.UUID_V2, service_data, bind_key, mac_address)
+
+-- Parse V1 unencrypted advertisement
+local result = bthome.parse(bthome.UUID_V1_UNENCRYPTED, service_data)
+
+-- Parse V1 encrypted advertisement
+local result = bthome.parse(bthome.UUID_V1_ENCRYPTED, service_data, bind_key, mac_address)
+
+-- Result structure:
+-- {
+-- device_info = { encrypted = bool, trigger_based = bool, version = 1|2 },
+-- packet_id = number|nil,
+-- readings = {
+-- { name = "temperature", value = 25.06, unit = "°C", id = 0x02, instance = 1 },
+-- { name = "humidity", value = 50.55, unit = "%", id = 0x03, instance = 1 },
+-- }
+-- }
+```
+
+### Error Handling
+
+All functions return `result` or `nil, error_message` (no thrown exceptions):
+
+```lua
+local result, err = bthome.parse(data)
+if not result then
+ print("Parse error: " .. err)
+end
+```
+
+## Testing
+
+Tests use the selftest() pattern with inline test vectors:
+
+```lua
+function module.selftest()
+ local passed = 0
+ local total = 0
+
+ -- Test cases...
+ total = total + 1
+ if condition then
+ passed = passed + 1
+ end
+
+ return passed == total
+end
+```
+
+Run with: `./run_tests.sh` or `make test`
+
+Available test modules: bthome, const, event, crypto, parser
+
+## Building
+
+The build process uses `amalg` to create single-file distributions:
+
+```bash
+make build
+# Output:
+# build/bthome.lua - Full library (includes bitn)
+# build/bthome-core.lua - Core only (requires external bitn)
+```
+
+Version is automatically injected from git tags during release.
+
+## CI/CD
+
+- **build.yml**: Runs on push/PR to main
+ - Format check with stylua
+ - Lint with luacheck
+ - Test matrix (Lua 5.1-5.4, LuaJIT 2.0/2.1)
+ - Build both single-file distributions
+
+- **release.yml**: Runs on version tags (v*)
+ - Builds and publishes release with bthome.lua and bthome-core.lua artifacts
+
+## Code Style
+
+- 2-space indentation
+- 120 column width
+- Double quotes preferred
+- LuaDoc annotations for all public functions
+
+## Dependencies
+
+- **vendor/bitn.lua**: Vendored bitwise operations library (pure Lua)
+ - Provides bit32 operations needed for AES and parsing
+ - Included in bthome.lua build, excluded from bthome-core.lua
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..29ebfa5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b3e541e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,171 @@
+# Luarocks path for amalg and other tools
+LUAROCKS_PATH := $(shell luarocks path --lr-path 2>/dev/null)
+
+# Lua path for local modules (src, vendor)
+LUA_PATH_LOCAL := ./?.lua;./?/init.lua;./src/?.lua;./src/?/init.lua;./vendor/?.lua;$(LUAROCKS_PATH)
+
+# Default target
+.PHONY: all
+all: format lint test build
+
+# Run tests
+.PHONY: test
+test:
+ ./run_tests.sh
+
+# Run test matrix
+.PHONY: test-matrix
+test-matrix:
+ ./run_tests_matrix.sh
+
+# Run specific test suite for test matrix
+.PHONY: test-matrix-%
+test-matrix-%:
+ ./run_tests_matrix.sh $*
+
+# Run specific test suite
+.PHONY: test-%
+test-%:
+ ./run_tests.sh $*
+
+build/amalg.cache: src/bthome/init.lua
+ @echo "Generating amalgamation cache..."
+ @mkdir -p build
+ @if command -v amalg.lua >/dev/null 2>&1; then \
+ LUA_PATH="$(LUA_PATH_LOCAL)" lua -lamalg src/bthome/init.lua && mv amalg.cache build || exit 1; \
+ echo "Generated amalg.cache"; \
+ else \
+ echo "Error: amalg not found."; \
+ echo "Please install amalg: luarocks install amalg"; \
+ echo "Or run: make install-deps"; \
+ exit 1; \
+ fi
+
+# Build single-file distributions
+.PHONY: build
+build: build/amalg.cache
+ @echo "Building single-file distribution..."
+ @if command -v amalg.lua >/dev/null 2>&1; then \
+ LUA_PATH="$(LUA_PATH_LOCAL)" amalg.lua -o build/bthome.lua -C ./build/amalg.cache || exit 1; \
+ echo "Built build/bthome.lua"; \
+ LUA_PATH="$(LUA_PATH_LOCAL)" amalg.lua -o build/bthome-core.lua -C ./build/amalg.cache -i "bitn" || exit 1;\
+ echo "Built build/bthome-core.lua (no vendor dependencies)"; \
+ VERSION=$$(git describe --exact-match --tags 2>/dev/null || echo "dev"); \
+ if [ "$$VERSION" != "dev" ]; then \
+ echo "Injecting version $$VERSION..."; \
+ sed -i.bak 's/VERSION = "dev"/VERSION = "'$$VERSION'"/' build/bthome.lua && rm build/bthome.lua.bak; \
+ sed -i.bak 's/VERSION = "dev"/VERSION = "'$$VERSION'"/' build/bthome-core.lua && rm build/bthome-core.lua.bak; \
+ fi; \
+ echo "Testing version function..."; \
+ LUA_VERSION=$$(lua -e 'local b = require("build.bthome"); print(b.version())' 2>/dev/null || echo "test failed"); \
+ if [ "$$LUA_VERSION" = "$$VERSION" ]; then \
+ echo "Version correctly set to: $$VERSION"; \
+ else \
+ echo "Version test failed. Expected: $$VERSION, Got: $$LUA_VERSION"; \
+ fi; \
+ else \
+ echo "Error: amalg not found."; \
+ echo "Please install amalg: luarocks install amalg"; \
+ echo "Or run: make install-deps"; \
+ exit 1; \
+ fi
+
+# Install all development dependencies
+.PHONY: install-deps
+install-deps:
+ @echo "Installing development dependencies..."
+ @echo ""
+ @echo "=== Installing system tools ==="
+ @if command -v brew >/dev/null 2>&1; then \
+ echo "Using Homebrew to install tools..."; \
+ brew install lua-language-server stylua || true; \
+ else \
+ echo "Please install the following manually:"; \
+ echo " - lua-language-server: https://github.com/LuaLS/lua-language-server/releases"; \
+ echo " - stylua: https://github.com/JohnnyMorganz/StyLua/releases"; \
+ echo " - luarocks: https://github.com/luarocks/luarocks/wiki/Download"; \
+ fi
+ @echo ""
+ @echo "=== Installing Lua tools ==="
+ @if command -v luarocks >/dev/null 2>&1; then \
+ echo "Using LuaRocks to install tools..."; \
+ luarocks install luacheck || exit 1; \
+ luarocks install amalg || exit 1; \
+ else \
+ echo "luarocks not found. Please install it first."; \
+ echo " macOS: brew install luarocks"; \
+ echo " Linux: apt-get install luarocks"; \
+ exit 1; \
+ fi
+
+# Format Lua code with stylua
+.PHONY: format
+format:
+ @if command -v stylua >/dev/null 2>&1; then \
+ echo "Running stylua..."; \
+ stylua --indent-type Spaces --column-width 120 --line-endings Unix \
+ --indent-width 2 --quote-style AutoPreferDouble \
+ src/ 2>/dev/null; \
+ else \
+ echo "stylua not found. Install with: make install-deps"; \
+ exit 1; \
+ fi
+
+# Check Lua formatting
+.PHONY: format-check
+format-check:
+ @if command -v stylua >/dev/null 2>&1; then \
+ echo "Running stylua check..."; \
+ stylua --check --indent-type Spaces --column-width 120 --line-endings Unix \
+ --indent-width 2 --quote-style AutoPreferDouble \
+ src/; \
+ else \
+ echo "stylua not found. Install with: make install-deps"; \
+ exit 1; \
+ fi
+
+# Lint the code with luacheck
+.PHONY: lint
+lint:
+ @if command -v luacheck >/dev/null 2>&1; then \
+ echo "Running luacheck..."; \
+ luacheck src/; \
+ else \
+ echo "luacheck not found. Install with: make install-deps"; \
+ exit 1; \
+ fi
+
+.PHONY: check
+check: format-check lint
+ @echo "Code quality checks complete."
+
+# Clean generated files
+.PHONY: clean
+clean:
+ rm -rf build/
+
+# Help
+.PHONY: help
+help:
+ @echo "Lua BTHome BLE Library - Makefile targets"
+ @echo ""
+ @echo "Testing:"
+ @echo " make test - Run all tests"
+ @echo " make test- - Run specific test (e.g., make test-bthome)"
+ @echo " make test-matrix - Run tests across all Lua versions"
+ @echo " make test-matrix- - Run specific test across all Lua versions"
+ @echo ""
+ @echo "Building:"
+ @echo " make build - Build single-file distributions"
+ @echo ""
+ @echo "Code Quality:"
+ @echo " make check - Run format-check and lint"
+ @echo " make format - Format code with stylua"
+ @echo " make format-check - Check code formatting"
+ @echo " make lint - Lint code with luacheck"
+ @echo ""
+ @echo "Setup:"
+ @echo " make install-deps - Install development dependencies"
+ @echo " make clean - Remove generated files"
+ @echo ""
+ @echo " make help - Show this help"
diff --git a/README.md b/README.md
index aa5a921..fa6b4e3 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,218 @@
# lua-bthome-ble
-Pure Lua BTHome BLE advertisement parser with AES-128-CCM decryption for encrypted payloads. Supports V1 and V2 formats. No C dependencies, supports Lua 5.1+ and LuaJIT.
+
+A pure Lua [BTHome](https://bthome.io) BLE advertisement parser with **zero external dependencies**. Supports both V1
+and V2 formats, including encrypted advertisements with AES-128-CCM decryption. This library provides a complete,
+cross-platform implementation that runs on Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
+
+## Features
+
+- **Zero Dependencies**: Pure Lua implementation, no C extensions required
+- **Portable**: Runs on Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT
+- **Complete**: Supports 78+ sensor types from the BTHome specification
+- **Encryption**: AES-128-CCM decryption for encrypted advertisements
+- **Well-tested**: 70+ self-tests with vectors from the official bthome-ble implementation
+
+## Installation
+
+Download the single-file distribution from the
+[releases page](https://github.com/finitelabs/lua-bthome-ble/releases):
+
+- **`bthome.lua`** - Full library (includes bitn for bitwise operations)
+- **`bthome-core.lua`** - Core library only (requires external bitn)
+
+Or clone this repository:
+
+```bash
+git clone https://github.com/finitelabs/lua-bthome-ble.git
+cd lua-bthome-ble
+```
+
+Add the `src` and `vendor` directories to your Lua path.
+
+## Usage
+
+### Basic Example
+
+```lua
+local bthome = require("bthome")
+
+-- Check version
+print(bthome.version())
+
+-- Parse an unencrypted V2 advertisement (UUID 0xFCD2)
+-- Example: Temperature 25.06°C + Humidity 50.55%
+local service_data = "\x40\x02\xca\x09\x03\xbf\x13"
+local result, err = bthome.parse(bthome.UUID_V2, service_data)
+
+if result then
+ print("BTHome Version:", result.device_info.version)
+ print("Encrypted:", result.device_info.encrypted)
+
+ for _, reading in ipairs(result.readings) do
+ print(string.format("%s: %s %s",
+ reading.name,
+ reading.value,
+ reading.unit or ""))
+ end
+else
+ print("Parse error:", err)
+end
+```
+
+Output:
+
+```
+BTHome Version: 2
+Encrypted: false
+temperature: 25.06 °C
+humidity: 50.55 %
+```
+
+### Encrypted Advertisements
+
+```lua
+local bthome = require("bthome")
+
+-- 16-byte encryption key (bind_key)
+local bind_key = "\x23\x1d\x39\xc1\xd7\xcc\x1a\xb1\xae\xe2\x24\xcd\x09\x6d\xb9\x32"
+
+-- 6-byte MAC address
+local mac_address = "\x54\x48\xe6\x8f\x80\xa5"
+
+-- V2 encrypted service data (UUID 0xFCD2)
+local service_data = "\x41..." -- encrypted payload
+local result, err = bthome.parse(bthome.UUID_V2, service_data, bind_key, mac_address)
+
+-- V1 encrypted service data (UUID 0x181E)
+local v1_service_data = "\xfb..." -- encrypted payload
+local result, err = bthome.parse(bthome.UUID_V1_ENCRYPTED, v1_service_data, bind_key, mac_address)
+
+if result then
+ for _, reading in ipairs(result.readings) do
+ print(reading.name, reading.value)
+ end
+end
+```
+
+### Result Structure
+
+```lua
+{
+ device_info = {
+ encrypted = false, -- true if advertisement was encrypted
+ trigger_based = false, -- true for button/event devices
+ version = 2 -- BTHome version (1 or 2)
+ },
+ packet_id = 5, -- optional packet counter
+ readings = {
+ {
+ name = "temperature",
+ value = 25.06,
+ unit = "°C",
+ id = 0x02,
+ instance = 1 -- instance number for duplicate sensors
+ },
+ {
+ name = "humidity",
+ value = 50.55,
+ unit = "%",
+ id = 0x03,
+ instance = 1
+ }
+ }
+}
+```
+
+### Supported Sensor Types
+
+| Category | Sensors |
+|---------------|------------------------------------------------------------------------------|
+| Environmental | temperature, humidity, pressure, illuminance, dewpoint, uv_index |
+| Air Quality | co2, tvoc, pm2_5, pm10 |
+| Power | battery, voltage, current, power, energy |
+| Motion | motion, acceleration, gyroscope, rotation, speed |
+| Binary | opening, door, window, lock, smoke, tamper, vibration, moisture_detected |
+| Volume | volume_liters, volume_ml, volume_flow_rate, gas_volume |
+| Distance | distance_mm, distance_m |
+| Mass | mass_kg, mass_lb |
+| Events | button (press, double_press, long_press), dimmer (rotate_left, rotate_right) |
+
+## Testing
+
+```bash
+# Run all tests
+make test
+
+# Run specific module tests
+make test-parser
+make test-crypto
+
+# Run test matrix across Lua versions
+make test-matrix
+
+# Check formatting and linting
+make check
+```
+
+## Building
+
+```bash
+# Build single-file distributions
+make build
+
+# Output:
+# build/bthome.lua - Full library (includes bitn)
+# build/bthome-core.lua - Core only (requires external bitn)
+```
+
+## BTHome Protocol
+
+BTHome is an open standard for broadcasting sensor data over Bluetooth Low
+Energy. Key characteristics:
+
+- **Service UUIDs**:
+ - `0x181C` - V1 unencrypted
+ - `0x181E` - V1 encrypted
+ - `0xFCD2` - V2 (encryption determined by device_info byte)
+- **V2 Device Info Byte**: Bit 0 = encrypted, Bit 2 = trigger-based, Bits 5-7 = version
+- **Data Format**: Object ID followed by little-endian value bytes
+- **Encryption**: AES-128-CCM with 4-byte MIC
+
+For full specification, see [bthome.io](https://bthome.io).
+
+## Current Limitations
+
+- Pure Lua performance is slower than native implementations
+- No constant-time guarantees for cryptographic operations
+
+## Security Warning
+
+This is a pure Lua implementation intended for portability and ease of use.
+While we implement the algorithms correctly and pass all test vectors, the
+implementation:
+
+- Cannot guarantee constant-time operations
+- Has not been independently audited
+- Is significantly slower than native implementations
+
+For production use with encrypted advertisements, consider using native
+cryptographic libraries for the AES-CCM decryption.
+
+## License
+
+GNU Affero General Public License v3.0 - see LICENSE file for details.
+
+## Contributing
+
+Contributions are welcome! Please ensure all tests pass (`make test`) and
+code passes linting (`make check`).
+
+## Acknowledgments
+
+- [BTHome specification](https://bthome.io) by the BTHome community
+- [bthome-ble](https://github.com/Bluetooth-Devices/bthome-ble) Python reference implementation
+- Test vectors derived from the official bthome-ble test suite
+
+---
+
+
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..87af7f4
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,171 @@
+#!/bin/bash
+
+# lua-bthome-ble Test Runner
+#
+# Usage: ./run_tests.sh [module_names...]
+#
+# Examples:
+# ./run_tests.sh # Run all modules
+# ./run_tests.sh bthome # Run only bthome
+# ./run_tests.sh const parser # Run only const and parser
+#
+# Available modules: bthome, const, event, crypto, parser
+
+set -e # Exit on any error
+
+echo "============================================="
+echo "BTHome BLE Library - Test Suite Runner"
+echo "============================================="
+echo
+
+# Colors for output
+green='\033[0;32m'
+red='\033[0;31m'
+blue='\033[0;34m'
+nc='\033[0m' # No Color
+
+# Track overall results
+passed_modules=()
+failed_modules=()
+
+# Lua binary to use for running tests
+lua_binary="${LUA_BINARY:-lua}"
+
+# Check if the lua binary is available
+if ! command -v "$lua_binary" &> /dev/null; then
+ echo -e "${red}Error: $lua_binary command not found.${nc}"
+ exit 1
+fi
+echo "$($lua_binary -v)"
+echo
+
+# Get script directory
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+# Add repository root to Lua's package path
+# This allows require() to find modules in the src/vendor directories
+lua_path="$script_dir/?.lua;$script_dir/?/init.lua;$script_dir/src/?.lua;$script_dir/src/?/init.lua;$script_dir/vendor/?.lua;$LUA_PATH"
+
+# Parse command line arguments to determine which modules to run
+default_modules=("bthome")
+all_modules=("bthome" "const" "event" "crypto" "parser")
+modules_to_run=("$@")
+
+# Validate modules if specified
+if [ ${#modules_to_run[@]} -gt 0 ] && [ "${modules_to_run[0]}" != "all" ]; then
+ for module in "${modules_to_run[@]}"; do
+ valid=0
+ for valid_module in "${all_modules[@]}"; do
+ if [ "$module" = "$valid_module" ]; then
+ valid=1
+ break
+ fi
+ done
+ if [ $valid -eq 0 ]; then
+ echo -e "${red}Error: Unknown module '$module'${nc}"
+ echo "Available modules: ${all_modules[*]}"
+ exit 1
+ fi
+ done
+fi
+
+if [ ${#modules_to_run[@]} -eq 0 ]; then
+ modules_to_run=("${default_modules[@]}")
+ echo "Running default modules: ${modules_to_run[*]}"
+elif [ "${modules_to_run[0]}" = "all" ]; then
+ modules_to_run=("${all_modules[@]}")
+ echo "Running all modules: ${modules_to_run[*]}"
+else
+ echo "Running specified modules: ${modules_to_run[*]}"
+fi
+echo
+
+# Function to check if a module should be run
+should_run_module() {
+ local module_key="$1"
+ for module in "${modules_to_run[@]}"; do
+ if [ "$module" = "$module_key" ]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+# Function to run a test and capture result
+run_test() {
+ local module_name="$1"
+ local module_key="$2"
+ local lua_command="$3"
+
+ if ! should_run_module "$module_key"; then
+ return
+ fi
+
+ echo "---------------------------------------------"
+ echo -e "${blue}Testing $module_name...${nc}"
+ echo "---------------------------------------------"
+
+ if LUA_PATH="$lua_path" "$lua_binary" -e "$lua_command" 2>&1; then
+ echo -e "${green}$module_name: ALL TESTS PASSED${nc}"
+ passed_modules+=("$module_name")
+ else
+ echo -e "${red}$module_name: TESTS FAILED${nc}"
+ failed_modules+=("$module_name")
+ fi
+
+ echo
+}
+
+run_selftest() {
+ local module_name="$1"
+ local module_key="$2"
+ local lua_module="$3"
+ run_test "$module_name" "$module_key" "
+ local result = require('$lua_module').selftest()
+ if not result then
+ os.exit(1)
+ end
+ "
+}
+
+# Run module tests
+run_selftest "BTHome (full)" "bthome" "bthome"
+run_selftest "Constants" "const" "bthome.const"
+run_selftest "Events" "event" "bthome.event"
+run_selftest "Crypto" "crypto" "bthome.crypto"
+run_selftest "Parser" "parser" "bthome.parser"
+
+passed_count=${#passed_modules[@]}
+failed_count=${#failed_modules[@]}
+total_count=$((passed_count + failed_count))
+
+# If only one module is run, no need to summarize
+if [ $total_count -eq 1 ]; then
+ if [ $failed_count -gt 0 ]; then
+ exit 1
+ fi
+ exit 0
+fi
+
+# Summary
+echo "============================================="
+echo "TEST SUMMARY"
+echo "============================================="
+
+if [ $passed_count -eq $total_count ]; then
+ echo -e "${green}ALL MODULES PASSED: $passed_count/$total_count${nc}"
+ echo
+ echo "Passed modules:"
+ for module in "${passed_modules[@]}"; do
+ echo " $module: PASS"
+ done
+ exit 0
+else
+ echo -e "${red}SOME MODULES FAILED: $passed_count/$total_count passed${nc}"
+ echo
+ echo "Failed modules:"
+ for module in "${failed_modules[@]}"; do
+ echo " $module: FAIL"
+ done
+ exit 1
+fi
diff --git a/run_tests_matrix.sh b/run_tests_matrix.sh
new file mode 100755
index 0000000..4d81e76
--- /dev/null
+++ b/run_tests_matrix.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+# List of luaenv versions to test
+LUA_VERSIONS=("5.1.5" "5.2.4" "5.3.6" "5.4.8" "luajit-2.1-dev")
+
+# Colors for output
+green='\033[0;32m'
+yellow='\033[1;33m'
+red='\033[0;31m'
+nc='\033[0m' # No Color
+
+luaenv_binary="${LUAENV_BINARY:-luaenv}" # Use luaenv by default, can be overridden
+
+# Get script directory
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+if ! command -v "$luaenv_binary" &> /dev/null; then
+ echo -e "${red}❌ Error: $luaenv_binary command not found.${nc}"
+ exit 1
+fi
+
+if [ ! -d "$($luaenv_binary prefix)/../../plugins/luaenv-luarocks" ]; then
+ echo -e "${red}❌ Error: luaenv-luarocks plugin not found. Please install it first.${nc}"
+ exit 1
+fi
+
+# Track overall results
+failed_versions=()
+passed_versions=()
+
+for lua_version in "${LUA_VERSIONS[@]}"; do
+ echo -e "${yellow}~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~${nc}"
+ echo -e "${yellow}Running tests with $lua_version${nc}"
+ echo -e "${yellow}~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~${nc}"
+ echo
+
+ "$luaenv_binary" install -s $lua_version
+ lua_prefix="$($luaenv_binary prefix $lua_version)"
+ lua_binary="$lua_prefix/bin/lua"
+
+ # Run the tests and pass all arguments
+ if ! LUA_BINARY="$lua_binary" "$script_dir/run_tests.sh" "$@"; then
+ failed_versions+=("$lua_version")
+ else
+ passed_versions+=("$lua_version")
+ fi
+done
+
+# Final summary
+echo "============================================="
+echo "📊 Matrix Test Summary"
+echo "============================================="
+
+if [ ${#failed_versions[@]} -eq 0 ]; then
+ echo -e "${green}✅ All LUA VERSIONS PASSED:${nc}"
+ printf '%s\n' "${passed_versions[@]}"
+ exit 0
+else
+ echo -e "${red}💥 SOME LUA VERSIONS FAILED:${nc}"
+ printf '%s\n' "${failed_versions[@]}"
+ exit 1
+fi
diff --git a/src/bthome/const.lua b/src/bthome/const.lua
new file mode 100644
index 0000000..33080ea
--- /dev/null
+++ b/src/bthome/const.lua
@@ -0,0 +1,779 @@
+--- @module "bthome.const"
+--- BTHome object ID definitions and data format constants.
+--- Contains all 78+ sensor object IDs from the BTHome specification.
+--- @see https://bthome.io/format
+---
+--- @class bthome.const
+local const = {}
+
+--- @class BTHomeObjectDefinition
+--- @field name string Sensor name (e.g., "temperature", "humidity")
+--- @field display_name string Human-readable display name (e.g., "Temperature", "Humidity")
+--- @field format BTHomeFormat Data format type (e.g., "uint8", "sint16", "string")
+--- @field factor number Scaling factor to apply to raw value
+--- @field unit string|nil Unit of measurement (e.g., "°C", "%", nil)
+--- @field length integer Byte length of the value (0 for variable-length)
+--- @field is_event boolean|nil True if this is an event type (button, dimmer)
+
+--- Data format types for encoding values.
+--- @enum BTHomeFormat
+const.FORMAT = {
+ UINT8 = "uint8",
+ SINT8 = "sint8",
+ UINT16 = "uint16",
+ SINT16 = "sint16",
+ UINT24 = "uint24",
+ SINT24 = "sint24",
+ UINT32 = "uint32",
+ SINT32 = "sint32",
+ UINT48 = "uint48",
+ STRING = "string",
+ MAC = "mac",
+}
+
+--- Object ID definitions.
+--- @type table
+const.OBJECT_IDS = {
+ -- Sensors
+ [0x01] = {
+ name = "battery",
+ display_name = "Battery",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = "%",
+ length = 1,
+ },
+ [0x02] = {
+ name = "temperature",
+ display_name = "Temperature",
+ format = const.FORMAT.SINT16,
+ factor = 0.01,
+ unit = "°C",
+ length = 2,
+ },
+ [0x03] = {
+ name = "humidity",
+ display_name = "Humidity",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "%",
+ length = 2,
+ },
+ [0x04] = {
+ name = "pressure",
+ display_name = "Pressure",
+ format = const.FORMAT.UINT24,
+ factor = 0.01,
+ unit = "hPa",
+ length = 3,
+ },
+ [0x05] = {
+ name = "illuminance",
+ display_name = "Illuminance",
+ format = const.FORMAT.UINT24,
+ factor = 0.01,
+ unit = "lx",
+ length = 3,
+ },
+ [0x06] = {
+ name = "mass_kg",
+ display_name = "Mass",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "kg",
+ length = 2,
+ },
+ [0x07] = {
+ name = "mass_lb",
+ display_name = "Mass",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "lb",
+ length = 2,
+ },
+ [0x08] = {
+ name = "dewpoint",
+ display_name = "Dew Point",
+ format = const.FORMAT.SINT16,
+ factor = 0.01,
+ unit = "°C",
+ length = 2,
+ },
+ [0x09] = { name = "count", display_name = "Count", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x0A] = {
+ name = "energy",
+ display_name = "Energy",
+ format = const.FORMAT.UINT24,
+ factor = 0.001,
+ unit = "kWh",
+ length = 3,
+ },
+ [0x0B] = {
+ name = "power",
+ display_name = "Power",
+ format = const.FORMAT.UINT24,
+ factor = 0.01,
+ unit = "W",
+ length = 3,
+ },
+ [0x0C] = {
+ name = "voltage",
+ display_name = "Voltage",
+ format = const.FORMAT.UINT16,
+ factor = 0.001,
+ unit = "V",
+ length = 2,
+ },
+ [0x0D] = {
+ name = "pm2_5",
+ display_name = "PM2.5",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "µg/m³",
+ length = 2,
+ },
+ [0x0E] = {
+ name = "pm10",
+ display_name = "PM10",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "µg/m³",
+ length = 2,
+ },
+ [0x12] = { name = "co2", display_name = "CO₂", format = const.FORMAT.UINT16, factor = 1, unit = "ppm", length = 2 },
+ [0x13] = {
+ name = "tvoc",
+ display_name = "TVOC",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "µg/m³",
+ length = 2,
+ },
+ [0x14] = {
+ name = "moisture",
+ display_name = "Moisture",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "%",
+ length = 2,
+ },
+ [0x2E] = {
+ name = "humidity",
+ display_name = "Humidity",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = "%",
+ length = 1,
+ },
+ [0x2F] = {
+ name = "moisture",
+ display_name = "Moisture",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = "%",
+ length = 1,
+ },
+ [0x3D] = { name = "count", display_name = "Count", format = const.FORMAT.UINT16, factor = 1, unit = nil, length = 2 },
+ [0x3E] = { name = "count", display_name = "Count", format = const.FORMAT.UINT32, factor = 1, unit = nil, length = 4 },
+ [0x3F] = {
+ name = "rotation",
+ display_name = "Rotation",
+ format = const.FORMAT.SINT16,
+ factor = 0.1,
+ unit = "°",
+ length = 2,
+ },
+ [0x40] = {
+ name = "distance_mm",
+ display_name = "Distance",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "mm",
+ length = 2,
+ },
+ [0x41] = {
+ name = "distance_m",
+ display_name = "Distance",
+ format = const.FORMAT.UINT16,
+ factor = 0.1,
+ unit = "m",
+ length = 2,
+ },
+ [0x42] = {
+ name = "duration",
+ display_name = "Duration",
+ format = const.FORMAT.UINT24,
+ factor = 0.001,
+ unit = "s",
+ length = 3,
+ },
+ [0x43] = {
+ name = "current",
+ display_name = "Current",
+ format = const.FORMAT.UINT16,
+ factor = 0.001,
+ unit = "A",
+ length = 2,
+ },
+ [0x44] = {
+ name = "speed",
+ display_name = "Speed",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "m/s",
+ length = 2,
+ },
+ [0x45] = {
+ name = "temperature",
+ display_name = "Temperature",
+ format = const.FORMAT.SINT16,
+ factor = 0.1,
+ unit = "°C",
+ length = 2,
+ },
+ [0x46] = {
+ name = "uv_index",
+ display_name = "UV Index",
+ format = const.FORMAT.UINT8,
+ factor = 0.1,
+ unit = nil,
+ length = 1,
+ },
+ [0x47] = {
+ name = "volume",
+ display_name = "Volume",
+ format = const.FORMAT.UINT16,
+ factor = 0.1,
+ unit = "L",
+ length = 2,
+ },
+ [0x48] = {
+ name = "volume_ml",
+ display_name = "Volume",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "mL",
+ length = 2,
+ },
+ [0x49] = {
+ name = "volume_flow_rate",
+ display_name = "Volume Flow Rate",
+ format = const.FORMAT.UINT16,
+ factor = 0.001,
+ unit = "m³/h",
+ length = 2,
+ },
+ [0x4A] = {
+ name = "voltage",
+ display_name = "Voltage",
+ format = const.FORMAT.UINT16,
+ factor = 0.1,
+ unit = "V",
+ length = 2,
+ },
+ [0x4B] = {
+ name = "gas",
+ display_name = "Gas",
+ format = const.FORMAT.UINT24,
+ factor = 0.001,
+ unit = "m³",
+ length = 3,
+ },
+ [0x4C] = {
+ name = "gas",
+ display_name = "Gas",
+ format = const.FORMAT.UINT32,
+ factor = 0.001,
+ unit = "m³",
+ length = 4,
+ },
+ [0x4D] = {
+ name = "energy",
+ display_name = "Energy",
+ format = const.FORMAT.UINT32,
+ factor = 0.001,
+ unit = "kWh",
+ length = 4,
+ },
+ [0x4E] = {
+ name = "volume",
+ display_name = "Volume",
+ format = const.FORMAT.UINT32,
+ factor = 0.001,
+ unit = "L",
+ length = 4,
+ },
+ [0x4F] = {
+ name = "water",
+ display_name = "Water",
+ format = const.FORMAT.UINT32,
+ factor = 0.001,
+ unit = "L",
+ length = 4,
+ },
+ [0x50] = {
+ name = "timestamp",
+ display_name = "Timestamp",
+ format = const.FORMAT.UINT32,
+ factor = 1,
+ unit = nil,
+ length = 4,
+ },
+ [0x51] = {
+ name = "acceleration",
+ display_name = "Acceleration",
+ format = const.FORMAT.UINT16,
+ factor = 0.001,
+ unit = "m/s²",
+ length = 2,
+ },
+ [0x52] = {
+ name = "gyroscope",
+ display_name = "Gyroscope",
+ format = const.FORMAT.UINT16,
+ factor = 0.001,
+ unit = "°/s",
+ length = 2,
+ },
+ [0x53] = { name = "text", display_name = "Text", format = const.FORMAT.STRING, factor = 1, unit = nil, length = 0 }, -- Variable length
+ [0x54] = { name = "raw", display_name = "Raw", format = const.FORMAT.STRING, factor = 1, unit = nil, length = 0 }, -- Variable length
+ [0x55] = {
+ name = "volume_storage",
+ display_name = "Volume Storage",
+ format = const.FORMAT.UINT32,
+ factor = 0.001,
+ unit = "L",
+ length = 4,
+ },
+ [0x56] = {
+ name = "conductivity",
+ display_name = "Conductivity",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "µS/cm",
+ length = 2,
+ },
+ [0x57] = {
+ name = "temperature",
+ display_name = "Temperature",
+ format = const.FORMAT.SINT8,
+ factor = 1,
+ unit = "°C",
+ length = 1,
+ },
+ [0x58] = {
+ name = "temperature",
+ display_name = "Temperature",
+ format = const.FORMAT.SINT8,
+ factor = 0.35,
+ unit = "°C",
+ length = 1,
+ },
+ [0x59] = { name = "count", display_name = "Count", format = const.FORMAT.SINT8, factor = 1, unit = nil, length = 1 },
+ [0x5A] = { name = "count", display_name = "Count", format = const.FORMAT.SINT16, factor = 1, unit = nil, length = 2 },
+ [0x5B] = { name = "count", display_name = "Count", format = const.FORMAT.SINT32, factor = 1, unit = nil, length = 4 },
+ [0x5C] = {
+ name = "power",
+ display_name = "Power",
+ format = const.FORMAT.SINT32,
+ factor = 0.01,
+ unit = "W",
+ length = 4,
+ },
+ [0x5D] = {
+ name = "current",
+ display_name = "Current",
+ format = const.FORMAT.SINT16,
+ factor = 0.001,
+ unit = "A",
+ length = 2,
+ },
+ [0x5E] = {
+ name = "direction",
+ display_name = "Direction",
+ format = const.FORMAT.UINT16,
+ factor = 0.01,
+ unit = "°",
+ length = 2,
+ },
+ [0x5F] = {
+ name = "precipitation",
+ display_name = "Precipitation",
+ format = const.FORMAT.UINT16,
+ factor = 0.1,
+ unit = "mm",
+ length = 2,
+ },
+ [0x60] = {
+ name = "channel",
+ display_name = "Channel",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x61] = {
+ name = "rotational_speed",
+ display_name = "Rotational Speed",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = "rpm",
+ length = 2,
+ },
+ [0x62] = {
+ name = "speed_signed",
+ display_name = "Speed",
+ format = const.FORMAT.SINT32,
+ factor = 0.000001,
+ unit = "m/s",
+ length = 4,
+ },
+ [0x63] = {
+ name = "acceleration_signed",
+ display_name = "Acceleration",
+ format = const.FORMAT.SINT32,
+ factor = 0.000001,
+ unit = "m/s²",
+ length = 4,
+ },
+
+ -- Binary sensors
+ [0x0F] = {
+ name = "generic_boolean",
+ display_name = "Generic Boolean",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x10] = {
+ name = "power_on",
+ display_name = "Power",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x11] = {
+ name = "opening",
+ display_name = "Opening",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x15] = {
+ name = "battery_low",
+ display_name = "Battery Low",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x16] = {
+ name = "battery_charging",
+ display_name = "Battery Charging",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x17] = {
+ name = "carbon_monoxide_detected",
+ display_name = "Carbon Monoxide",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x18] = { name = "cold", display_name = "Cold", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x19] = {
+ name = "connectivity",
+ display_name = "Connectivity",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x1A] = { name = "door", display_name = "Door", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x1B] = {
+ name = "garage_door",
+ display_name = "Garage Door",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x1C] = {
+ name = "gas_detected",
+ display_name = "Gas",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x1D] = { name = "heat", display_name = "Heat", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x1E] = {
+ name = "light_detected",
+ display_name = "Light",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x1F] = {
+ name = "lock_unlocked",
+ display_name = "Lock",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x20] = {
+ name = "moisture_detected",
+ display_name = "Moisture",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x21] = { name = "motion", display_name = "Motion", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x22] = { name = "moving", display_name = "Moving", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x23] = {
+ name = "occupancy",
+ display_name = "Occupancy",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x24] = { name = "plug", display_name = "Plug", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x25] = {
+ name = "presence",
+ display_name = "Presence",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x26] = {
+ name = "problem",
+ display_name = "Problem",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x27] = {
+ name = "running",
+ display_name = "Running",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x28] = { name = "safety", display_name = "Safety", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x29] = {
+ name = "smoke_detected",
+ display_name = "Smoke",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x2A] = {
+ name = "sound_detected",
+ display_name = "Sound",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x2B] = { name = "tamper", display_name = "Tamper", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+ [0x2C] = {
+ name = "vibration_detected",
+ display_name = "Vibration",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+ [0x2D] = { name = "window", display_name = "Window", format = const.FORMAT.UINT8, factor = 1, unit = nil, length = 1 },
+
+ -- Events
+ [0x3A] = {
+ name = "button",
+ display_name = "Button",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ is_event = true,
+ },
+ [0x3C] = {
+ name = "dimmer",
+ display_name = "Dimmer",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 2,
+ is_event = true,
+ },
+
+ -- Device Info
+ [0xF0] = {
+ name = "device_type_id",
+ display_name = "Device Type ID",
+ format = const.FORMAT.UINT16,
+ factor = 1,
+ unit = nil,
+ length = 2,
+ },
+ [0xF1] = {
+ name = "firmware_version",
+ display_name = "Firmware Version",
+ format = const.FORMAT.UINT32,
+ factor = 1,
+ unit = nil,
+ length = 4,
+ },
+ [0xF2] = {
+ name = "firmware_version",
+ display_name = "Firmware Version",
+ format = const.FORMAT.UINT24,
+ factor = 1,
+ unit = nil,
+ length = 3,
+ },
+
+ -- Misc
+ [0x00] = {
+ name = "packet_id",
+ display_name = "Packet ID",
+ format = const.FORMAT.UINT8,
+ factor = 1,
+ unit = nil,
+ length = 1,
+ },
+}
+
+--- @class BTHomeV1FormatDefinition
+--- @field format BTHomeFormat Format name (e.g., "uint8", "sint16")
+--- @field length integer Byte length
+
+--- BTHome V1 data format types (for legacy support).
+--- In V1, bits 5-7 of the object byte encode the data format.
+--- @type table
+const.V1_FORMATS = {
+ [0x00] = { format = const.FORMAT.UINT8, length = 1 },
+ [0x01] = { format = const.FORMAT.SINT8, length = 1 },
+ [0x02] = { format = const.FORMAT.UINT16, length = 2 },
+ [0x03] = { format = const.FORMAT.SINT16, length = 2 },
+ [0x04] = { format = const.FORMAT.UINT24, length = 3 },
+ [0x05] = { format = const.FORMAT.SINT24, length = 3 },
+ [0x06] = { format = const.FORMAT.UINT32, length = 4 },
+ [0x07] = { format = const.FORMAT.SINT32, length = 4 },
+}
+
+--- Device info byte bit positions.
+--- @enum BTHomeDeviceInfoBit
+const.DEVICE_INFO = {
+ ENCRYPTED_BIT = 0, -- Bit 0: encryption flag
+ TRIGGER_BIT = 2, -- Bit 2: trigger-based device flag
+ VERSION_SHIFT = 5, -- Bits 5-7: BTHome version
+ VERSION_MASK = 0x07, -- Mask for version bits (3 bits)
+}
+
+--- BTHome versions.
+--- @enum BTHomeVersionEnum
+const.VERSION = {
+ V1 = 1,
+ V2 = 2,
+}
+
+--- Get object definition by ID.
+--- @param object_id integer The object ID (0x00-0xFF)
+--- @return BTHomeObjectDefinition|nil definition Object definition or nil if unknown
+function const.get_object(object_id)
+ return const.OBJECT_IDS[object_id]
+end
+
+--- Get the length of a variable-length field.
+--- For text and raw fields, the first byte is the length.
+--- @param format BTHomeFormat The format type
+--- @param data string The data starting at the length byte
+--- @return integer length The total length including the length byte
+function const.get_variable_length(format, data)
+ if format == const.FORMAT.STRING and #data >= 1 then
+ return 1 + string.byte(data, 1)
+ end
+ return 0
+end
+
+--- Run self-tests.
+--- @return boolean success True if all tests passed
+function const.selftest()
+ print("Testing const module...")
+ local passed = 0
+ local total = 0
+
+ -- ===========================================================================
+ -- Object ID Lookup Tests
+ -- ===========================================================================
+
+ local test_ids = {
+ { id = 0x00, name = "packet_id" },
+ { id = 0x01, name = "battery" },
+ { id = 0x02, name = "temperature" },
+ { id = 0x03, name = "humidity" },
+ { id = 0x3A, name = "button" },
+ { id = 0x53, name = "text" },
+ }
+
+ for _, test in ipairs(test_ids) do
+ total = total + 1
+ local obj = const.get_object(test.id)
+ if obj and obj.name == test.name then
+ print(string.format(" PASS: Object ID 0x%02X = %s", test.id, test.name))
+ passed = passed + 1
+ else
+ print(string.format(" FAIL: Object ID 0x%02X", test.id))
+ print(string.format(" Expected: %s", test.name))
+ print(string.format(" Got: %s", obj and obj.name or "nil"))
+ end
+ end
+
+ -- ===========================================================================
+ -- Object Attribute Tests
+ -- ===========================================================================
+
+ total = total + 1
+ local temp = const.get_object(0x02)
+ if temp and temp.factor == 0.01 and temp.length == 2 then
+ print(" PASS: Temperature has correct factor and length")
+ passed = passed + 1
+ else
+ print(" FAIL: Temperature attributes")
+ print(string.format(" Expected: factor=0.01, length=2"))
+ print(string.format(" Got: factor=%s, length=%s", temp and temp.factor, temp and temp.length))
+ end
+
+ -- ===========================================================================
+ -- Unknown Object ID Tests
+ -- ===========================================================================
+
+ total = total + 1
+ local unknown = const.get_object(0xFF)
+ if unknown == nil then
+ print(" PASS: Unknown object ID returns nil")
+ passed = passed + 1
+ else
+ print(" FAIL: Unknown object ID should return nil")
+ print(string.format(" Got: %s", tostring(unknown)))
+ end
+
+ print(string.format("\nconst module: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+return const
diff --git a/src/bthome/crypto/aes_ccm.lua b/src/bthome/crypto/aes_ccm.lua
new file mode 100644
index 0000000..7096db2
--- /dev/null
+++ b/src/bthome/crypto/aes_ccm.lua
@@ -0,0 +1,936 @@
+--- @module "bthome.crypto.aes_ccm"
+--- AES-CCM Authenticated Encryption for BTHome BLE advertisements.
+--- CCM combines CTR mode encryption with CBC-MAC authentication.
+--- @see RFC 3610 for CCM specification
+--- @see https://bthome.io/encryption for BTHome encryption details
+---
+--- @class bthome.crypto.aes_ccm
+local aes_ccm = {}
+
+local bit32 = require("bitn").bit32
+
+-- Local references for performance
+local bit32_raw_band = bit32.raw_band
+local bit32_raw_bxor = bit32.raw_bxor
+local bit32_raw_lshift = bit32.raw_lshift
+local math_floor = math.floor
+local math_min = math.min
+local string_byte = string.byte
+local string_char = string.char
+local string_format = string.format
+local string_rep = string.rep
+local string_sub = string.sub
+local table_concat = table.concat
+
+-- ============================================================================
+-- AES CORE IMPLEMENTATION
+-- ============================================================================
+
+-- AES S-box (substitution box)
+--- @type integer[]
+local SBOX = {
+ 0x63,
+ 0x7c,
+ 0x77,
+ 0x7b,
+ 0xf2,
+ 0x6b,
+ 0x6f,
+ 0xc5,
+ 0x30,
+ 0x01,
+ 0x67,
+ 0x2b,
+ 0xfe,
+ 0xd7,
+ 0xab,
+ 0x76,
+ 0xca,
+ 0x82,
+ 0xc9,
+ 0x7d,
+ 0xfa,
+ 0x59,
+ 0x47,
+ 0xf0,
+ 0xad,
+ 0xd4,
+ 0xa2,
+ 0xaf,
+ 0x9c,
+ 0xa4,
+ 0x72,
+ 0xc0,
+ 0xb7,
+ 0xfd,
+ 0x93,
+ 0x26,
+ 0x36,
+ 0x3f,
+ 0xf7,
+ 0xcc,
+ 0x34,
+ 0xa5,
+ 0xe5,
+ 0xf1,
+ 0x71,
+ 0xd8,
+ 0x31,
+ 0x15,
+ 0x04,
+ 0xc7,
+ 0x23,
+ 0xc3,
+ 0x18,
+ 0x96,
+ 0x05,
+ 0x9a,
+ 0x07,
+ 0x12,
+ 0x80,
+ 0xe2,
+ 0xeb,
+ 0x27,
+ 0xb2,
+ 0x75,
+ 0x09,
+ 0x83,
+ 0x2c,
+ 0x1a,
+ 0x1b,
+ 0x6e,
+ 0x5a,
+ 0xa0,
+ 0x52,
+ 0x3b,
+ 0xd6,
+ 0xb3,
+ 0x29,
+ 0xe3,
+ 0x2f,
+ 0x84,
+ 0x53,
+ 0xd1,
+ 0x00,
+ 0xed,
+ 0x20,
+ 0xfc,
+ 0xb1,
+ 0x5b,
+ 0x6a,
+ 0xcb,
+ 0xbe,
+ 0x39,
+ 0x4a,
+ 0x4c,
+ 0x58,
+ 0xcf,
+ 0xd0,
+ 0xef,
+ 0xaa,
+ 0xfb,
+ 0x43,
+ 0x4d,
+ 0x33,
+ 0x85,
+ 0x45,
+ 0xf9,
+ 0x02,
+ 0x7f,
+ 0x50,
+ 0x3c,
+ 0x9f,
+ 0xa8,
+ 0x51,
+ 0xa3,
+ 0x40,
+ 0x8f,
+ 0x92,
+ 0x9d,
+ 0x38,
+ 0xf5,
+ 0xbc,
+ 0xb6,
+ 0xda,
+ 0x21,
+ 0x10,
+ 0xff,
+ 0xf3,
+ 0xd2,
+ 0xcd,
+ 0x0c,
+ 0x13,
+ 0xec,
+ 0x5f,
+ 0x97,
+ 0x44,
+ 0x17,
+ 0xc4,
+ 0xa7,
+ 0x7e,
+ 0x3d,
+ 0x64,
+ 0x5d,
+ 0x19,
+ 0x73,
+ 0x60,
+ 0x81,
+ 0x4f,
+ 0xdc,
+ 0x22,
+ 0x2a,
+ 0x90,
+ 0x88,
+ 0x46,
+ 0xee,
+ 0xb8,
+ 0x14,
+ 0xde,
+ 0x5e,
+ 0x0b,
+ 0xdb,
+ 0xe0,
+ 0x32,
+ 0x3a,
+ 0x0a,
+ 0x49,
+ 0x06,
+ 0x24,
+ 0x5c,
+ 0xc2,
+ 0xd3,
+ 0xac,
+ 0x62,
+ 0x91,
+ 0x95,
+ 0xe4,
+ 0x79,
+ 0xe7,
+ 0xc8,
+ 0x37,
+ 0x6d,
+ 0x8d,
+ 0xd5,
+ 0x4e,
+ 0xa9,
+ 0x6c,
+ 0x56,
+ 0xf4,
+ 0xea,
+ 0x65,
+ 0x7a,
+ 0xae,
+ 0x08,
+ 0xba,
+ 0x78,
+ 0x25,
+ 0x2e,
+ 0x1c,
+ 0xa6,
+ 0xb4,
+ 0xc6,
+ 0xe8,
+ 0xdd,
+ 0x74,
+ 0x1f,
+ 0x4b,
+ 0xbd,
+ 0x8b,
+ 0x8a,
+ 0x70,
+ 0x3e,
+ 0xb5,
+ 0x66,
+ 0x48,
+ 0x03,
+ 0xf6,
+ 0x0e,
+ 0x61,
+ 0x35,
+ 0x57,
+ 0xb9,
+ 0x86,
+ 0xc1,
+ 0x1d,
+ 0x9e,
+ 0xe1,
+ 0xf8,
+ 0x98,
+ 0x11,
+ 0x69,
+ 0xd9,
+ 0x8e,
+ 0x94,
+ 0x9b,
+ 0x1e,
+ 0x87,
+ 0xe9,
+ 0xce,
+ 0x55,
+ 0x28,
+ 0xdf,
+ 0x8c,
+ 0xa1,
+ 0x89,
+ 0x0d,
+ 0xbf,
+ 0xe6,
+ 0x42,
+ 0x68,
+ 0x41,
+ 0x99,
+ 0x2d,
+ 0x0f,
+ 0xb0,
+ 0x54,
+ 0xbb,
+ 0x16,
+}
+
+-- Round constants (Rcon) for key expansion
+--- @type integer[]
+local RCON = {
+ 0x01,
+ 0x02,
+ 0x04,
+ 0x08,
+ 0x10,
+ 0x20,
+ 0x40,
+ 0x80,
+ 0x1b,
+ 0x36,
+}
+
+--- @alias AESWord [integer, integer, integer, integer]
+--- @alias AESBlock [integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer, integer]
+--- @alias AESState [AESWord, AESWord, AESWord, AESWord]
+
+--- Initialize a 4-element AES word with zeros
+--- @return AESWord word Initialized word
+local function create_aes_word()
+ --- @type AESState
+ return { 0, 0, 0, 0 }
+end
+
+--- Initialize a 4x4 AES state array with zeros
+--- @return AESState state Initialized state
+local function create_aes_state()
+ --- @type AESState
+ return {
+ create_aes_word(),
+ create_aes_word(),
+ create_aes_word(),
+ create_aes_word(),
+ }
+end
+
+-- Pre-allocated state array for aes_encrypt_block()
+local aes_state = create_aes_state()
+
+-- Pre-allocated arrays for mix_columns()
+local mix_a = create_aes_word()
+local mix_b = create_aes_word()
+
+--- XOR two 4-byte words
+--- @param a AESWord 4-byte array
+--- @param b AESWord 4-byte array
+--- @return AESWord result 4-byte array
+local function xor_words(a, b)
+ return {
+ bit32_raw_bxor(a[1], b[1]),
+ bit32_raw_bxor(a[2], b[2]),
+ bit32_raw_bxor(a[3], b[3]),
+ bit32_raw_bxor(a[4], b[4]),
+ }
+end
+
+--- Rotate word (circular left shift by 1 byte)
+--- @param word AESWord 4-byte array
+--- @return AESWord result Rotated 4-byte array
+local function rot_word(word)
+ return { word[2], word[3], word[4], word[1] }
+end
+
+--- Apply S-box substitution to a word
+--- @param word AESWord 4-byte array
+--- @return AESWord result Substituted 4-byte array
+local function sub_word(word)
+ local s_1 = assert(SBOX[word[1] + 1], "Invalid SBOX index " .. (word[1] + 1))
+ local s_2 = assert(SBOX[word[2] + 1], "Invalid SBOX index " .. (word[2] + 1))
+ local s_3 = assert(SBOX[word[3] + 1], "Invalid SBOX index " .. (word[3] + 1))
+ local s_4 = assert(SBOX[word[4] + 1], "Invalid SBOX index " .. (word[4] + 1))
+ return { s_1, s_2, s_3, s_4 }
+end
+
+--- AES key expansion
+--- @param key string Encryption key (16, 24, or 32 bytes)
+--- @return table expanded_key Array of round keys
+--- @return integer nr Number of rounds
+local function key_expansion(key)
+ local key_len = #key
+ local nr -- Number of rounds
+ local nk -- Number of 32-bit words in key
+
+ if key_len == 16 then
+ nr = 10
+ nk = 4
+ elseif key_len == 24 then
+ nr = 12
+ nk = 6
+ elseif key_len == 32 then
+ nr = 14
+ nk = 8
+ else
+ error("Invalid key length. Must be 16, 24, or 32 bytes")
+ end
+
+ -- Convert key to words
+ --- @type AESState
+ local w = {}
+ for i = 1, nk do
+ w[i] = {
+ string_byte(key, (i - 1) * 4 + 1),
+ string_byte(key, (i - 1) * 4 + 2),
+ string_byte(key, (i - 1) * 4 + 3),
+ string_byte(key, (i - 1) * 4 + 4),
+ }
+ end
+
+ -- Expand key
+ for i = nk + 1, 4 * (nr + 1) do
+ local temp = w[i - 1]
+ local idx = i - 1 -- 0-based index for modulo arithmetic
+ if idx % nk == 0 then
+ local t = assert(RCON[idx / nk], "Invalid RCON index " .. (idx / nk))
+ temp = xor_words(sub_word(rot_word(temp)), { t, 0, 0, 0 })
+ elseif nk > 6 and idx % nk == 4 then
+ temp = sub_word(temp)
+ end
+ w[i] = xor_words(w[i - nk], temp)
+ end
+
+ return w, nr
+end
+
+--- MixColumns transformation
+--- @param state AESState 4x4 state matrix
+local function mix_columns(state)
+ -- Reuse pre-allocated arrays
+ local a = mix_a
+ local b = mix_b
+ for c = 1, 4 do
+ for i = 1, 4 do
+ a[i] = state[i][c]
+ b[i] = bit32_raw_band(state[i][c], 0x80) ~= 0
+ and bit32_raw_bxor(bit32_raw_band(bit32_raw_lshift(state[i][c], 1), 0xFF), 0x1B)
+ or bit32_raw_band(bit32_raw_lshift(state[i][c], 1), 0xFF)
+ end
+
+ state[1][c] = bit32_raw_bxor(bit32_raw_bxor(bit32_raw_bxor(b[1], a[2]), bit32_raw_bxor(b[2], a[3])), a[4])
+ state[2][c] = bit32_raw_bxor(bit32_raw_bxor(bit32_raw_bxor(a[1], b[2]), bit32_raw_bxor(a[3], b[3])), a[4])
+ state[3][c] = bit32_raw_bxor(bit32_raw_bxor(bit32_raw_bxor(a[1], a[2]), bit32_raw_bxor(b[3], a[4])), b[4])
+ state[4][c] = bit32_raw_bxor(bit32_raw_bxor(bit32_raw_bxor(a[1], b[1]), bit32_raw_bxor(a[2], a[3])), b[4])
+ end
+end
+
+--- SubBytes transformation
+--- @param state AESState 4x4 state matrix
+local function sub_bytes(state)
+ for i = 1, 4 do
+ for j = 1, 4 do
+ local s_index = state[i][j] + 1
+ state[i][j] = assert(SBOX[s_index], "Invalid SBOX index " .. s_index)
+ end
+ end
+end
+
+--- ShiftRows transformation
+--- @param state AESState 4x4 state matrix
+local function shift_rows(state)
+ -- Row 1: no shift
+ -- Row 2: shift left by 1
+ local temp = state[2][1]
+ state[2][1] = state[2][2]
+ state[2][2] = state[2][3]
+ state[2][3] = state[2][4]
+ state[2][4] = temp
+
+ -- Row 3: shift left by 2
+ temp = state[3][1]
+ state[3][1] = state[3][3]
+ state[3][3] = temp
+ temp = state[3][2]
+ state[3][2] = state[3][4]
+ state[3][4] = temp
+
+ -- Row 4: shift left by 3 (or right by 1)
+ temp = state[4][4]
+ state[4][4] = state[4][3]
+ state[4][3] = state[4][2]
+ state[4][2] = state[4][1]
+ state[4][1] = temp
+end
+
+--- AddRoundKey transformation
+--- @param state AESState 4x4 state matrix
+--- @param round_key table Round key words
+--- @param round integer Round number
+local function add_round_key(state, round_key, round)
+ for c = 1, 4 do
+ local key_word = round_key[round * 4 + c]
+ for r = 1, 4 do
+ state[r][c] = bit32_raw_bxor(state[r][c], key_word[r])
+ end
+ end
+end
+
+--- AES block encryption
+--- @param input string 16-byte plaintext block
+--- @param expanded_key table Expanded key
+--- @param nr integer Number of rounds
+--- @return string ciphertext 16-byte encrypted block
+local function aes_encrypt_block(input, expanded_key, nr)
+ -- Reuse pre-allocated state array
+ local state = aes_state
+ for i = 1, 4 do
+ for j = 1, 4 do
+ state[i][j] = string_byte(input, (j - 1) * 4 + i)
+ end
+ end
+
+ -- Initial round
+ add_round_key(state, expanded_key, 0)
+
+ -- Main rounds
+ for round = 1, nr - 1 do
+ sub_bytes(state)
+ shift_rows(state)
+ mix_columns(state)
+ add_round_key(state, expanded_key, round)
+ end
+
+ -- Final round (no MixColumns)
+ sub_bytes(state)
+ shift_rows(state)
+ add_round_key(state, expanded_key, nr)
+
+ -- Convert state to output (optimized with table)
+ local output_bytes = {}
+ local idx = 1
+ for j = 1, 4 do
+ for i = 1, 4 do
+ output_bytes[idx] = string_char(state[i][j])
+ idx = idx + 1
+ end
+ end
+
+ return table_concat(output_bytes)
+end
+
+-- ============================================================================
+-- CCM MODE IMPLEMENTATION
+-- ============================================================================
+
+--- XOR two byte strings of equal length.
+--- @param a string First string
+--- @param b string Second string
+--- @return string result XOR result
+local function xor_strings(a, b)
+ local result = {}
+ for i = 1, #a do
+ result[i] = string_char(bit32_raw_bxor(string_byte(a, i), string_byte(b, i)))
+ end
+ return table_concat(result)
+end
+
+--- Generate CTR counter blocks.
+--- @param nonce string CCM nonce
+--- @param counter integer Counter value (0 for CBC-MAC tag encryption, 1+ for CTR)
+--- @param L integer Size of length field (typically 2 for BTHome)
+--- @return string block 16-byte counter block
+local function generate_counter_block(nonce, counter, L)
+ -- Counter block format: [Flags][Nonce][Counter]
+ -- Flags = L-1 (for CTR blocks)
+ local flags = math_floor(L - 1)
+
+ -- Build counter block
+ local block = string_char(flags) .. nonce
+
+ -- Append counter (big-endian, L bytes)
+ local counter_bytes = {}
+ local temp_counter = counter
+ for i = L, 1, -1 do
+ counter_bytes[i] = string_char(math_floor(temp_counter % 256))
+ temp_counter = math_floor(temp_counter / 256)
+ end
+
+ return block .. table_concat(counter_bytes)
+end
+
+--- Compute CBC-MAC authentication tag.
+--- @param expanded_key table Pre-expanded AES key
+--- @param nr integer Number of rounds
+--- @param nonce string CCM nonce
+--- @param aad string Associated authenticated data (can be empty)
+--- @param plaintext string Plaintext to authenticate
+--- @param M integer Tag length in bytes (4 for BTHome)
+--- @param L integer Length field size (typically 2)
+--- @return string tag Authentication tag (M bytes)
+local function cbc_mac(expanded_key, nr, nonce, aad, plaintext, M, L)
+ -- Build B0 block
+ -- Flags: [Reserved (1)][Adata (1)][M' (3)][L' (3)]
+ -- M' = (M-2)/2, L' = L-1
+ local adata_flag = #aad > 0 and 0x40 or 0x00
+ local m_field = math_floor((M - 2) / 2) * 8 -- Shift left 3 bits
+ local l_field = L - 1
+
+ local flags = math_floor(adata_flag + m_field + l_field)
+
+ -- B0 = Flags || Nonce || Q (message length, L bytes, big-endian)
+ local b0 = string_char(flags) .. nonce
+
+ -- Append message length (L bytes, big-endian)
+ local msg_len = #plaintext
+ local len_bytes = {}
+ for i = L, 1, -1 do
+ len_bytes[i] = string_char(math_floor(msg_len % 256))
+ msg_len = math_floor(msg_len / 256)
+ end
+ b0 = b0 .. table_concat(len_bytes)
+
+ -- Initialize CBC-MAC with B0
+ local y = aes_encrypt_block(b0, expanded_key, nr)
+
+ -- Process AAD if present
+ if #aad > 0 then
+ local aad_block
+ if #aad < 0xFF00 then
+ -- Short encoding: 2-byte length prefix
+ aad_block = string_char(math_floor(#aad / 256), math_floor(#aad % 256)) .. aad
+ else
+ error("AAD too long")
+ end
+
+ -- Pad AAD to multiple of 16 bytes
+ local pad_len = (16 - (#aad_block % 16)) % 16
+ aad_block = aad_block .. string_rep("\0", pad_len)
+
+ -- Process AAD blocks
+ for i = 1, #aad_block, 16 do
+ local block = string_sub(aad_block, i, i + 15)
+ y = aes_encrypt_block(xor_strings(y, block), expanded_key, nr)
+ end
+ end
+
+ -- Process plaintext blocks
+ if #plaintext > 0 then
+ -- Pad plaintext to multiple of 16 bytes
+ local pad_len = (16 - (#plaintext % 16)) % 16
+ local padded = plaintext .. string_rep("\0", pad_len)
+
+ for i = 1, #padded, 16 do
+ local block = string_sub(padded, i, i + 15)
+ y = aes_encrypt_block(xor_strings(y, block), expanded_key, nr)
+ end
+ end
+
+ -- Return first M bytes as tag
+ return string_sub(y, 1, M)
+end
+
+-- ============================================================================
+-- AEAD INTERFACE
+-- ============================================================================
+
+--- Encrypt data using AES-CCM.
+--- @param key string 16-byte AES key
+--- @param nonce string CCM nonce (typically 13 bytes for BTHome V2)
+--- @param aad string Associated authenticated data (empty string for BTHome)
+--- @param plaintext string Data to encrypt
+--- @param tag_length integer Authentication tag length (4 bytes for BTHome)
+--- @return string|nil ciphertext Encrypted data with appended tag
+--- @return string|nil error Error message
+function aes_ccm.encrypt(key, nonce, aad, plaintext, tag_length)
+ if #key ~= 16 then
+ return nil, "key must be 16 bytes"
+ end
+
+ local M = math_floor(tag_length or 4)
+ local L = 16 - 1 - #nonce -- Compute L from nonce length
+
+ if L < 2 or L > 8 then
+ return nil, "invalid nonce length"
+ end
+
+ -- Expand key
+ local expanded_key, nr = key_expansion(key)
+
+ -- Compute CBC-MAC tag
+ local tag = cbc_mac(expanded_key, nr, nonce, aad, plaintext, M, L)
+
+ -- Generate S0 for tag encryption
+ local s0 = aes_encrypt_block(generate_counter_block(nonce, 0, L), expanded_key, nr)
+
+ -- Encrypt tag
+ local encrypted_tag = xor_strings(tag, string_sub(s0, 1, M))
+
+ -- CTR encrypt plaintext
+ local ciphertext = {}
+ local block_num = 1
+
+ for i = 1, #plaintext, 16 do
+ local block = string_sub(plaintext, i, math_min(i + 15, #plaintext))
+ local counter_block = generate_counter_block(nonce, block_num, L)
+ local keystream = aes_encrypt_block(counter_block, expanded_key, nr)
+ ciphertext[#ciphertext + 1] = xor_strings(block, string_sub(keystream, 1, #block))
+ block_num = block_num + 1
+ end
+
+ return table_concat(ciphertext) .. encrypted_tag
+end
+
+--- Decrypt data using AES-CCM.
+--- @param key string 16-byte AES key
+--- @param nonce string CCM nonce
+--- @param aad string Associated authenticated data (empty string for BTHome)
+--- @param ciphertext_and_tag string Encrypted data with appended tag
+--- @param tag_length integer Authentication tag length (4 bytes for BTHome)
+--- @return string|nil plaintext Decrypted data
+--- @return string|nil error Error message (including authentication failure)
+function aes_ccm.decrypt(key, nonce, aad, ciphertext_and_tag, tag_length)
+ if #key ~= 16 and #key ~= 24 and #key ~= 32 then
+ return nil, "Key must be 16, 24, or 32 bytes"
+ end
+
+ local M = math_floor(tag_length or 4)
+ local L = 16 - 1 - #nonce
+
+ if L < 2 or L > 8 then
+ return nil, "invalid nonce length"
+ end
+
+ if #ciphertext_and_tag < M then
+ return nil, "ciphertext too short"
+ end
+
+ -- Split ciphertext and tag
+ local ciphertext_len = #ciphertext_and_tag - M
+ local ciphertext = string_sub(ciphertext_and_tag, 1, ciphertext_len)
+ local encrypted_tag = string_sub(ciphertext_and_tag, ciphertext_len + 1)
+
+ -- Expand key
+ local expanded_key, nr = key_expansion(key)
+
+ -- Generate S0 for tag decryption
+ local s0 = aes_encrypt_block(generate_counter_block(nonce, 0, L), expanded_key, nr)
+
+ -- Decrypt tag
+ local received_tag = xor_strings(encrypted_tag, string_sub(s0, 1, M))
+
+ -- CTR decrypt ciphertext
+ local plaintext = {}
+ local block_num = 1
+
+ for i = 1, #ciphertext, 16 do
+ local block = string_sub(ciphertext, i, math_min(i + 15, #ciphertext))
+ local counter_block = generate_counter_block(nonce, block_num, L)
+ local keystream = aes_encrypt_block(counter_block, expanded_key, nr)
+ plaintext[#plaintext + 1] = xor_strings(block, string_sub(keystream, 1, #block))
+ block_num = block_num + 1
+ end
+
+ local plaintext_str = table_concat(plaintext)
+
+ -- Verify CBC-MAC
+ local computed_tag = cbc_mac(expanded_key, nr, nonce, aad, plaintext_str, M, L)
+
+ -- Constant-time comparison
+ local tag_match = true
+ for i = 1, M do
+ if string_byte(computed_tag, i) ~= string_byte(received_tag, i) then
+ tag_match = false
+ end
+ end
+
+ if not tag_match then
+ return nil, "authentication failed"
+ end
+
+ return plaintext_str
+end
+
+-- ============================================================================
+-- SELF-TEST
+-- ============================================================================
+
+--- Helper to convert hex string to binary
+--- @param hex string Hex string
+--- @return string binary Binary string
+local function hex_to_bin(hex)
+ local bytes = {}
+ for i = 1, #hex, 2 do
+ bytes[#bytes + 1] = string_char(tonumber(string_sub(hex, i, i + 1), 16) or 0)
+ end
+ return table_concat(bytes)
+end
+
+--- Helper to convert binary to hex string
+--- @param bin string Binary string
+--- @return string hex Hex string
+local function bin_to_hex(bin)
+ local hex = {}
+ for i = 1, #bin do
+ hex[#hex + 1] = string_format("%02x", string_byte(bin, i))
+ end
+ return table_concat(hex)
+end
+
+--- Run self-tests using NIST and RFC test vectors.
+--- @return boolean success True if all tests passed
+function aes_ccm.selftest()
+ print("Testing AES-CCM module...")
+ local passed = 0
+ local total = 0
+
+ -- ===========================================================================
+ -- AES-128 Block Cipher Tests (NIST FIPS-197)
+ -- ===========================================================================
+
+ local aes_vectors = {
+ {
+ name = "NIST FIPS-197 Appendix B",
+ key = "2b7e151628aed2a6abf7158809cf4f3c",
+ plaintext = "3243f6a8885a308d313198a2e0370734",
+ ciphertext = "3925841d02dc09fbdc118597196a0b32",
+ },
+ {
+ name = "All zeros",
+ key = "00000000000000000000000000000000",
+ plaintext = "00000000000000000000000000000000",
+ ciphertext = "66e94bd4ef8a2c3b884cfa59ca342b2e",
+ },
+ {
+ name = "NIST SP 800-38A F.1.1",
+ key = "2b7e151628aed2a6abf7158809cf4f3c",
+ plaintext = "6bc1bee22e409f96e93d7e117393172a",
+ ciphertext = "3ad77bb40d7a3660a89ecaf32466ef97",
+ },
+ }
+
+ for _, tv in ipairs(aes_vectors) do
+ total = total + 1
+ local key = hex_to_bin(tv.key)
+ local plaintext = hex_to_bin(tv.plaintext)
+ local expected_ct = hex_to_bin(tv.ciphertext)
+
+ local expanded_key, nr = key_expansion(key)
+ local ciphertext = aes_encrypt_block(plaintext, expanded_key, nr)
+
+ if ciphertext == expected_ct then
+ print(string_format(" PASS: AES-128 %s", tv.name))
+ passed = passed + 1
+ else
+ print(string_format(" FAIL: AES-128 %s", tv.name))
+ print(string_format(" Expected: %s", tv.ciphertext))
+ print(string_format(" Got: %s", bin_to_hex(ciphertext)))
+ end
+ end
+
+ -- ===========================================================================
+ -- AES-CCM Tests (RFC 3610)
+ -- ===========================================================================
+
+ local ccm_vectors = {
+ {
+ name = "RFC 3610 Vector #1",
+ key = "c0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
+ nonce = "00000003020100a0a1a2a3a4a5", -- 13 bytes
+ aad = "0001020304050607",
+ plaintext = "08090a0b0c0d0e0f101112131415161718191a1b1c1d1e",
+ tag_length = 8,
+ ciphertext = "588c979a61c663d2f066d0c2c0f989806d5f6b61dac38417e8d12cfdf926e0",
+ },
+ }
+
+ for _, tv in ipairs(ccm_vectors) do
+ local key = hex_to_bin(tv.key)
+ local nonce = hex_to_bin(tv.nonce)
+ local aad = hex_to_bin(tv.aad)
+ local plaintext = hex_to_bin(tv.plaintext)
+ local expected_ct = hex_to_bin(tv.ciphertext)
+
+ -- Test encryption
+ total = total + 1
+ local ciphertext, err = aes_ccm.encrypt(key, nonce, aad, plaintext, tv.tag_length)
+ if ciphertext and ciphertext == expected_ct then
+ print(string_format(" PASS: CCM %s (encrypt)", tv.name))
+ passed = passed + 1
+ else
+ print(string_format(" FAIL: CCM %s (encrypt)", tv.name))
+ if err then
+ print(string_format(" Error: %s", err))
+ else
+ print(string_format(" Expected: %s", tv.ciphertext))
+ print(string_format(" Got: %s", ciphertext and bin_to_hex(ciphertext) or "nil"))
+ end
+ end
+
+ -- Test decryption
+ total = total + 1
+ local decrypted, derr = aes_ccm.decrypt(key, nonce, aad, expected_ct, tv.tag_length)
+ if decrypted and decrypted == plaintext then
+ print(string_format(" PASS: CCM %s (decrypt)", tv.name))
+ passed = passed + 1
+ else
+ print(string_format(" FAIL: CCM %s (decrypt)", tv.name))
+ print(string_format(" Error: %s", derr or "decrypted data mismatch"))
+ end
+ end
+
+ -- ===========================================================================
+ -- Functional Tests
+ -- ===========================================================================
+
+ -- Roundtrip encryption/decryption
+ total = total + 1
+ local rt_key = hex_to_bin("231d39c1d7cc1ab1aee224cd096db932")
+ local rt_nonce = hex_to_bin("aabbccddeeff00112233") -- 10 bytes -> L=5
+ local rt_plaintext = hex_to_bin("48656c6c6f20576f726c6421") -- "Hello World!"
+
+ local rt_ct, rt_err = aes_ccm.encrypt(rt_key, rt_nonce, "", rt_plaintext, 4)
+ if rt_ct then
+ local rt_dec = aes_ccm.decrypt(rt_key, rt_nonce, "", rt_ct, 4)
+ if rt_dec and rt_dec == rt_plaintext then
+ print(" PASS: Roundtrip encryption/decryption")
+ passed = passed + 1
+ else
+ print(" FAIL: Roundtrip decryption")
+ print(" Decryption did not match original plaintext")
+ end
+ else
+ print(" FAIL: Roundtrip encryption")
+ print(string_format(" Error: %s", rt_err or "unknown"))
+ end
+
+ -- Authentication failure on tampered data
+ total = total + 1
+ if rt_ct then
+ local tampered = string_sub(rt_ct, 1, 1) .. string_char((string_byte(rt_ct, 2) + 1) % 256) .. string_sub(rt_ct, 3)
+ local _, tamper_err = aes_ccm.decrypt(rt_key, rt_nonce, "", tampered, 4)
+ if tamper_err and tamper_err:find("authentication") then
+ print(" PASS: Tampered data rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Tampered data should be rejected")
+ print(string_format(" Error: %s", tamper_err or "no error returned"))
+ end
+ else
+ print(" SKIP: Tampered data test (roundtrip encryption failed)")
+ end
+
+ print(string_format("\nAES-CCM module: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+return aes_ccm
diff --git a/src/bthome/crypto/init.lua b/src/bthome/crypto/init.lua
new file mode 100644
index 0000000..f1e7ce2
--- /dev/null
+++ b/src/bthome/crypto/init.lua
@@ -0,0 +1,17 @@
+--- @module "bthome.crypto"
+--- BTHome cryptographic operations module.
+--- Provides AES-CCM authenticated encryption for encrypted BTHome advertisements.
+---
+--- @class bthome.crypto
+local crypto = {
+ --- @type bthome.crypto.aes_ccm
+ aes_ccm = require("bthome.crypto.aes_ccm"),
+}
+
+--- Run self-tests for crypto module.
+--- @return boolean success True if all tests passed
+function crypto.selftest()
+ return crypto.aes_ccm.selftest()
+end
+
+return crypto
diff --git a/src/bthome/event.lua b/src/bthome/event.lua
new file mode 100644
index 0000000..03c0ac3
--- /dev/null
+++ b/src/bthome/event.lua
@@ -0,0 +1,204 @@
+--- @module "bthome.event"
+--- BTHome button and dimmer event definitions.
+--- Provides decoding for button presses and dimmer rotations.
+--- @see https://bthome.io/format
+--- @class bthome.event
+local event = {}
+
+--- @class BTHomeButtonEvent
+--- @field raw_value integer Raw event byte value
+--- @field event_type integer Event type code
+--- @field event_name string Event name ("press", "double_press", "long_press", etc.)
+--- @field device_number integer|nil Device/button number for multi-button devices
+
+--- @class BTHomeDimmerEvent
+--- @field raw_value integer Raw 2-byte value (little-endian: event_type in low byte, steps in high byte)
+--- @field event_type integer Event type code (0=none, 1=rotate_left, 2=rotate_right)
+--- @field event_name string Event name ("none", "rotate_left", or "rotate_right")
+--- @field steps integer Number of rotation steps
+
+--- Button event types.
+--- The event value encodes both the event type (high nibble) and button number (low nibble).
+--- Event value format: [event_type (4 bits)][device_number (4 bits)]
+--- For single-button devices, device_number is typically 0.
+--- @enum BTHomeButtonEventType
+event.BUTTON = {
+ NONE = 0x00,
+ PRESS = 0x01,
+ DOUBLE_PRESS = 0x02,
+ TRIPLE_PRESS = 0x03,
+ LONG_PRESS = 0x04,
+ LONG_DOUBLE_PRESS = 0x05,
+ LONG_TRIPLE_PRESS = 0x06,
+ HOLD_PRESS = 0x80,
+}
+
+--- Button event names indexed by event type.
+--- @type table
+event.BUTTON_NAMES = {
+ [0x00] = "none",
+ [0x01] = "press",
+ [0x02] = "double_press",
+ [0x03] = "triple_press",
+ [0x04] = "long_press",
+ [0x05] = "long_double_press",
+ [0x06] = "long_triple_press",
+ [0x80] = "hold_press",
+}
+
+--- Dimmer event types.
+--- The dimmer event uses 2 bytes: [event_type][steps]
+--- Event type: 0 = none, 1 = rotate left (counter-clockwise), 2 = rotate right (clockwise)
+--- Steps: number of rotation steps (0-255)
+--- @enum BTHomeDimmerEventType
+event.DIMMER = {
+ NONE = 0x00,
+ ROTATE_LEFT = 0x01, -- Counter-clockwise
+ ROTATE_RIGHT = 0x02, -- Clockwise
+}
+
+--- Dimmer event names indexed by event type.
+--- @type table
+event.DIMMER_NAMES = {
+ [0x00] = "none",
+ [0x01] = "rotate_left",
+ [0x02] = "rotate_right",
+}
+
+--- Decode a button event byte.
+--- @param value integer The raw event byte value
+--- @return BTHomeButtonEvent result Decoded button event with device_number and event_type
+function event.decode_button(value)
+ -- For button events with device numbers (multi-button devices):
+ -- High nibble = event type, Low nibble = device number
+ -- However, most implementations use the full byte as event type
+ -- with separate multi-button handling via multiple object instances.
+
+ local event_type = value
+ local device_number = nil
+
+ -- Check if this is a device-number encoded event (values > 0x06 except 0x80)
+ if value > 0x06 and value ~= 0x80 then
+ -- Multi-button event: low nibble is device number, high nibble is event type
+ device_number = value % 0x10
+ event_type = math.floor(value / 0x10) * 0x10
+ if event_type == 0 then
+ event_type = value -- Fall back to treating entire value as event type
+ device_number = nil
+ end
+ end
+
+ local event_name = event.BUTTON_NAMES[event_type] or "unknown"
+
+ return {
+ raw_value = value,
+ event_type = event_type,
+ event_name = event_name,
+ device_number = device_number,
+ }
+end
+
+--- Decode a dimmer event value.
+--- The format is 2 bytes: [event_type][steps], read as little-endian uint16.
+--- Low byte = event_type (0=none, 1=rotate_left, 2=rotate_right)
+--- High byte = steps (number of rotation steps)
+--- @param value integer The raw 2-byte dimmer value (as little-endian uint16)
+--- @return BTHomeDimmerEvent result Decoded dimmer event with event_type and steps
+function event.decode_dimmer(value)
+ -- Value is read as little-endian uint16: low byte = event_type, high byte = steps
+ local event_type = value % 256
+ local steps = math.floor(value / 256)
+
+ return {
+ raw_value = value,
+ event_type = event_type,
+ event_name = event.DIMMER_NAMES[event_type] or "unknown",
+ steps = steps,
+ }
+end
+
+--- Decode an event based on the event type.
+--- @param event_type string The event type ("button" or "dimmer")
+--- @param value integer The raw event value
+--- @return BTHomeButtonEvent|BTHomeDimmerEvent result Decoded event
+function event.decode(event_type, value)
+ if event_type == "button" then
+ return event.decode_button(value)
+ elseif event_type == "dimmer" then
+ return event.decode_dimmer(value)
+ else
+ -- Return button-like structure for unknown event types
+ return {
+ raw_value = value,
+ event_type = value,
+ event_name = "unknown",
+ device_number = nil,
+ }
+ end
+end
+
+--- Run self-tests.
+--- @return boolean success True if all tests passed
+function event.selftest()
+ print("Testing event module...")
+ local passed = 0
+ local total = 0
+
+ -- ===========================================================================
+ -- Button Event Decoding Tests
+ -- ===========================================================================
+
+ local button_tests = {
+ { value = 0x00, expected_name = "none" },
+ { value = 0x01, expected_name = "press" },
+ { value = 0x02, expected_name = "double_press" },
+ { value = 0x03, expected_name = "triple_press" },
+ { value = 0x04, expected_name = "long_press" },
+ { value = 0x80, expected_name = "hold_press" },
+ }
+
+ for _, test in ipairs(button_tests) do
+ total = total + 1
+ local result = event.decode_button(test.value)
+ if result.event_name == test.expected_name then
+ print(string.format(" PASS: Button 0x%02X = %s", test.value, test.expected_name))
+ passed = passed + 1
+ else
+ print(string.format(" FAIL: Button 0x%02X", test.value))
+ print(string.format(" Expected: %s", test.expected_name))
+ print(string.format(" Got: %s", result.event_name))
+ end
+ end
+
+ -- ===========================================================================
+ -- Dimmer Event Decoding Tests
+ -- ===========================================================================
+
+ -- Dimmer format: 2 bytes [event_type][steps] read as little-endian uint16
+ -- So value = event_type + (steps * 256)
+ local dimmer_tests = {
+ { value = 0x0000, expected_name = "none", expected_steps = 0 }, -- event_type=0, steps=0
+ { value = 0x0301, expected_name = "rotate_left", expected_steps = 3 }, -- event_type=1, steps=3
+ { value = 0x0501, expected_name = "rotate_left", expected_steps = 5 }, -- event_type=1, steps=5
+ { value = 0x0102, expected_name = "rotate_right", expected_steps = 1 }, -- event_type=2, steps=1
+ { value = 0x0A02, expected_name = "rotate_right", expected_steps = 10 }, -- event_type=2, steps=10
+ }
+
+ for _, test in ipairs(dimmer_tests) do
+ total = total + 1
+ local result = event.decode_dimmer(test.value)
+ if result.event_name == test.expected_name and result.steps == test.expected_steps then
+ print(string.format(" PASS: Dimmer 0x%04X = %s, %d steps", test.value, test.expected_name, test.expected_steps))
+ passed = passed + 1
+ else
+ print(string.format(" FAIL: Dimmer 0x%04X", test.value))
+ print(string.format(" Expected: %s, %d steps", test.expected_name, test.expected_steps))
+ print(string.format(" Got: %s, %d steps", result.event_name, result.steps))
+ end
+ end
+
+ print(string.format("\nevent module: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+return event
diff --git a/src/bthome/init.lua b/src/bthome/init.lua
new file mode 100644
index 0000000..e7cdb1c
--- /dev/null
+++ b/src/bthome/init.lua
@@ -0,0 +1,88 @@
+--- @module "bthome"
+--- Pure Lua BTHome BLE advertisement parser library.
+--- This library provides parsing for BTHome V1 and V2 BLE advertisements,
+--- supporting both unencrypted and encrypted payloads.
+---
+--- @usage
+--- local bthome = require("bthome")
+--- print(bthome.version())
+---
+--- -- Parse V2 unencrypted advertisement
+--- local result = bthome.parse(bthome.UUID_V2, service_data)
+---
+--- -- Parse V2 encrypted advertisement
+--- local result = bthome.parse(bthome.UUID_V2, service_data, bind_key, mac_address)
+---
+--- -- Parse V1 encrypted advertisement
+--- local result = bthome.parse(bthome.UUID_V1_ENCRYPTED, service_data, bind_key, mac_address)
+---
+--- @class bthome
+local bthome = {
+ --- @type bthome.const
+ const = require("bthome.const"),
+ --- @type bthome.event
+ event = require("bthome.event"),
+ --- @type bthome.parser
+ parser = require("bthome.parser"),
+ --- @type bthome.crypto
+ crypto = require("bthome.crypto"),
+}
+bthome.UUID_V1_UNENCRYPTED = bthome.parser.UUID_V1_UNENCRYPTED
+bthome.UUID_V1_ENCRYPTED = bthome.parser.UUID_V1_ENCRYPTED
+bthome.UUID_V2 = bthome.parser.UUID_V2
+
+--- Library version (injected at build time for releases).
+local VERSION = "dev"
+
+--- Get the library version string.
+--- @return string version Version string (e.g., "v1.0.0" or "dev")
+function bthome.version()
+ return VERSION
+end
+
+bthome.parse = bthome.parser.parse
+
+--- Run self-tests for all modules.
+--- @return boolean success True if all tests passed
+function bthome.selftest()
+ print("BTHome Library Self-Test")
+ print("========================")
+ print("")
+
+ local all_passed = true
+
+ -- Test const module
+ local const_ok = bthome.const.selftest()
+ if not const_ok then
+ all_passed = false
+ end
+
+ -- Test event module
+ local event_ok = bthome.event.selftest()
+ if not event_ok then
+ all_passed = false
+ end
+
+ -- Test crypto module
+ local crypto_ok = bthome.crypto.selftest()
+ if not crypto_ok then
+ all_passed = false
+ end
+
+ -- Test parser module
+ local parser_ok = bthome.parser.selftest()
+ if not parser_ok then
+ all_passed = false
+ end
+
+ print("")
+ if all_passed then
+ print("All BTHome tests passed!")
+ else
+ print("Some BTHome tests failed!")
+ end
+
+ return all_passed
+end
+
+return bthome
diff --git a/src/bthome/parser.lua b/src/bthome/parser.lua
new file mode 100644
index 0000000..040b338
--- /dev/null
+++ b/src/bthome/parser.lua
@@ -0,0 +1,1319 @@
+--- @module "bthome.parser"
+--- BTHome BLE advertisement parser.
+--- Parses both V1 and V2 BTHome advertisements, including encrypted payloads.
+--- @see https://bthome.io/format
+---
+--- @class bthome.parser
+local parser = {}
+
+--- BTHome V1 unencrypted service UUID.
+--- @type integer
+parser.UUID_V1_UNENCRYPTED = 0x181C
+--- BTHome V1 encrypted service UUID.
+--- @type integer
+parser.UUID_V1_ENCRYPTED = 0x181E
+--- BTHome V2 service UUID.
+--- @type integer
+parser.UUID_V2 = 0xFCD2
+
+--- @class BTHomeDeviceInfo
+--- @field encrypted boolean True if the advertisement is encrypted
+--- @field trigger_based boolean True if this is a trigger-based device (buttons, events)
+--- @field version integer BTHome version (1 or 2)
+
+--- @class BTHomeReading
+--- @field name string Sensor name (e.g., "temperature", "humidity", "button")
+--- @field value integer|number|string The sensor value (scaled by factor)
+--- @field unit string|nil Unit of measurement (e.g., "°C", "%", nil)
+--- @field id integer Object ID from the BTHome specification
+--- @field instance integer Instance number for duplicate sensors (starts at 1)
+--- @field event BTHomeButtonEvent|BTHomeDimmerEvent|nil Decoded event data (for button/dimmer only)
+
+--- @class BTHomeParseResult
+--- @field device_info BTHomeDeviceInfo Parsed device information
+--- @field packet_id integer|nil Packet counter (if present in advertisement)
+--- @field readings BTHomeReading[] Array of sensor readings
+
+local const = require("bthome.const")
+local event = require("bthome.event")
+local crypto = require("bthome.crypto")
+
+--- Read a little-endian unsigned integer from a string.
+--- @param data string Input bytes
+--- @param offset integer Starting offset (1-based)
+--- @param length integer Number of bytes to read
+--- @return integer value Unsigned integer value
+local function read_uint_le(data, offset, length)
+ local value = 0
+ local multiplier = 1
+ for i = 0, length - 1 do
+ value = value + string.byte(data, offset + i) * multiplier
+ multiplier = multiplier * 256
+ end
+ return value
+end
+
+--- Read a little-endian signed integer from a string.
+--- @param data string Input bytes
+--- @param offset integer Starting offset (1-based)
+--- @param length integer Number of bytes to read
+--- @return integer value Signed integer value
+local function read_sint_le(data, offset, length)
+ local value = read_uint_le(data, offset, length)
+ local max_positive = math.floor(math.pow(2, length * 8 - 1))
+ if value >= max_positive then
+ return math.floor(value - math.pow(2, length * 8))
+ end
+ return value
+end
+
+--- Build nonce for BTHome V1 encrypted advertisements.
+--- Nonce format (12 bytes): MAC (6) || UUID16 (2 LE) || counter (4 LE)
+--- @param mac string 6-byte MAC address
+--- @param uuid integer UUID (0x181E for BTHome V1 encrypted)
+--- @param counter integer 32-bit counter
+--- @return string nonce 12-byte nonce
+local function build_v1_nonce(mac, uuid, counter)
+ return mac
+ .. string.char(uuid % 256, math.floor(uuid / 256))
+ .. string.char(
+ counter % 256,
+ math.floor(counter / 256) % 256,
+ math.floor(counter / 65536) % 256,
+ math.floor(counter / 16777216) % 256
+ )
+end
+
+--- Build nonce for BTHome V2 encrypted advertisements.
+--- Nonce format (13 bytes): MAC (6) || UUID (2 LE) || device_info (1) || counter (4 LE)
+--- @param mac string 6-byte MAC address
+--- @param uuid integer UUID (typically 0xFCD2 for BTHome)
+--- @param device_info integer Device info byte
+--- @param counter integer 32-bit counter
+--- @return string nonce 13-byte nonce
+local function build_v2_nonce(mac, uuid, device_info, counter)
+ return mac
+ .. string.char(uuid % 256, math.floor(uuid / 256))
+ .. string.char(device_info)
+ .. string.char(
+ counter % 256,
+ math.floor(counter / 256) % 256,
+ math.floor(counter / 65536) % 256,
+ math.floor(counter / 16777216) % 256
+ )
+end
+
+--- Parse the device info byte to extract flags and version.
+--- @param device_info integer Device info byte
+--- @return BTHomeDeviceInfo info Parsed device info
+local function parse_device_info(device_info)
+ local encrypted = (device_info % 2) == 1
+ local trigger_based = (math.floor(device_info / 4) % 2) == 1
+ local version = math.floor(device_info / 32)
+
+ return {
+ encrypted = encrypted,
+ trigger_based = trigger_based,
+ version = version,
+ }
+end
+
+--- Read a value based on format type.
+--- @param data string Raw data
+--- @param offset integer Starting offset (1-based)
+--- @param format string Format type (uint8, sint8, uint16, etc.)
+--- @param length integer Byte length
+--- @return number|integer|string|nil value Parsed value
+--- @return integer bytes_consumed Number of bytes consumed
+local function read_value(data, offset, format, length)
+ if offset + length - 1 > #data then
+ return nil, 0
+ end
+
+ if format == "string" then
+ -- Variable length: first byte is length
+ local str_len = string.byte(data, offset)
+ if offset + str_len > #data then
+ return nil, 0
+ end
+ return data:sub(offset + 1, offset + str_len), str_len + 1
+ elseif format == "mac" then
+ return data:sub(offset, offset + length - 1), length
+ elseif format:sub(1, 4) == "uint" then
+ return read_uint_le(data, offset, length), length
+ elseif format:sub(1, 4) == "sint" then
+ return read_sint_le(data, offset, length), length
+ else
+ return read_uint_le(data, offset, length), length
+ end
+end
+
+--- Parse firmware version bytes into a version string.
+--- Bytes are read in little-endian order and formatted as "major.minor.patch[.build]"
+--- @param data string Raw data
+--- @param offset integer Starting offset (1-based)
+--- @param length integer Number of bytes (3 or 4)
+--- @return string version Version string (e.g., "1.2.3" or "1.2.3.4")
+local function parse_firmware_version(data, offset, length)
+ local parts = {}
+ -- Read bytes in reverse order (little-endian: LSB first, but version is MSB first)
+ for i = length, 1, -1 do
+ parts[#parts + 1] = tostring(string.byte(data, offset + i - 1))
+ end
+ return table.concat(parts, ".")
+end
+
+--- Parse V2 BTHome payload (object IDs followed by values).
+--- @param payload string Payload data (after device info byte)
+--- @param start_offset integer Starting offset in payload (1-based)
+--- @return BTHomeReading[]|nil readings Array of parsed readings
+--- @return integer|nil packet_id Packet ID if present
+--- @return string|nil error Error message if parsing failed
+local function parse_v2_payload(payload, start_offset)
+ local readings = {}
+ local packet_id = nil
+ local pos = start_offset
+
+ while pos <= #payload do
+ -- Read object ID
+ local object_id = string.byte(payload, pos)
+ pos = pos + 1
+
+ -- Look up object definition
+ local obj_def = const.get_object(object_id)
+ if not obj_def then
+ return nil, nil, string.format("unknown object ID: 0x%02X at position %d", object_id, pos - 1)
+ end
+
+ -- Handle variable-length fields
+ local length = obj_def.length
+ if length == 0 then
+ -- Variable length: first byte is length
+ if pos > #payload then
+ return nil, nil, "truncated variable-length field"
+ end
+ length = string.byte(payload, pos) + 1 -- Include length byte
+ end
+
+ -- Read value
+ local value, consumed = read_value(payload, pos, obj_def.format, length)
+ if value == nil then
+ return nil, nil, string.format("truncated data for object 0x%02X", object_id)
+ end
+ pos = pos + consumed
+
+ -- Handle special object types
+ if object_id == 0x00 and type(value) == "number" then
+ -- Packet ID (always uint8)
+ packet_id = math.floor(value)
+ elseif object_id == 0xF1 or object_id == 0xF2 then
+ -- Firmware version: parse as version string instead of raw integer
+ local fw_version = parse_firmware_version(payload, pos - consumed, consumed)
+ readings[#readings + 1] = {
+ name = obj_def.name,
+ value = fw_version,
+ unit = obj_def.unit,
+ id = object_id,
+ }
+ elseif obj_def.is_event and type(value) ~= "string" then
+ -- Event (button, dimmer)
+ local event_data = event.decode(obj_def.name, math.floor(value))
+ readings[#readings + 1] = {
+ name = obj_def.name,
+ value = value,
+ unit = obj_def.unit,
+ id = object_id,
+ event = event_data,
+ }
+ else
+ -- Regular sensor reading: apply scaling factor
+ if type(value) == "number" and obj_def.factor ~= 1 then
+ value = value * obj_def.factor
+ end
+ readings[#readings + 1] = {
+ name = obj_def.name,
+ value = value,
+ unit = obj_def.unit,
+ id = object_id,
+ }
+ end
+ end
+
+ return readings, packet_id
+end
+
+--- Parse V1 BTHome payload (legacy format).
+--- V1 format per object (from bthome-ble):
+--- Byte 0: Control byte (bits 0-4 = data length incl type byte, bits 5-7 = format type)
+--- Byte 1: Object/measurement type (same IDs as V2)
+--- Bytes 2+: Data value
+--- @param payload string Payload data
+--- @param start_offset integer Starting offset
+--- @return BTHomeReading[]|nil readings Array of parsed readings
+--- @return integer|nil packet_id Packet ID if present
+--- @return string|nil error Error message
+local function parse_v1_payload(payload, start_offset)
+ local readings = {}
+ local packet_id = nil
+ local pos = start_offset
+
+ while pos <= #payload do
+ -- Read control byte
+ local control_byte = string.byte(payload, pos)
+ local data_length_with_type = control_byte % 32 -- bits 0-4: length including type byte
+ local format_type = math.floor(control_byte / 32) -- bits 5-7: format type
+
+ -- Need at least control byte + type byte
+ if pos + 1 > #payload then
+ return nil, nil, "truncated V1 payload: missing type byte"
+ end
+
+ -- Read object type (same as V2 object IDs)
+ local object_id = string.byte(payload, pos + 1)
+
+ -- Calculate positions
+ -- data_length_with_type includes the type byte, so actual data length = data_length_with_type - 1
+ -- next_obj = pos + data_length_with_type + 1 (the +1 is for control byte)
+ local actual_data_length = data_length_with_type - 1
+ local data_start = pos + 2
+ local next_pos = pos + data_length_with_type + 1
+
+ if actual_data_length < 1 then
+ return nil, nil, string.format("invalid V1 data length for object 0x%02X", object_id)
+ end
+
+ if data_start + actual_data_length - 1 > #payload then
+ return nil, nil, string.format("truncated V1 data for object 0x%02X", object_id)
+ end
+
+ -- Get format info for reading the value
+ local v1_format = const.V1_FORMATS[format_type]
+ if not v1_format then
+ return nil, nil, string.format("unknown V1 format type: %d", format_type)
+ end
+
+ -- Read value using actual data length
+ local value, _ = read_value(payload, data_start, v1_format.format, actual_data_length)
+ if value == nil then
+ return nil, nil, string.format("failed to read V1 data for object 0x%02X", object_id)
+ end
+
+ -- Look up object definition (V1 uses same object IDs as V2)
+ local obj_def = const.get_object(object_id)
+ local name = obj_def and obj_def.name or string.format("sensor_%d", object_id)
+ local factor = obj_def and obj_def.factor or 1
+ local unit = obj_def and obj_def.unit or nil
+
+ -- Apply scaling
+ if type(value) == "number" and factor ~= 1 then
+ value = value * factor
+ end
+
+ if object_id == 0 and type(value) == "number" then
+ packet_id = math.floor(value)
+ else
+ readings[#readings + 1] = {
+ name = name,
+ value = value,
+ unit = unit,
+ id = object_id,
+ }
+ end
+
+ pos = next_pos
+ end
+
+ return readings, packet_id
+end
+
+--- Post-process readings to assign instance numbers for duplicate names.
+--- When the same sensor name appears multiple times, each gets an instance number starting at 1.
+--- Example: temperature (instance=1), temperature (instance=2), temperature (instance=3)
+--- @param readings BTHomeReading[] Array of readings to process
+local function assign_instance_numbers(readings)
+ local name_counts = {}
+
+ for _, reading in ipairs(readings) do
+ local name = reading.name
+ if name_counts[name] then
+ name_counts[name] = name_counts[name] + 1
+ else
+ name_counts[name] = 1
+ end
+ reading.instance = name_counts[name]
+ end
+end
+
+--- Decrypt an encrypted BTHome V1 advertisement.
+--- V1 encrypted format: [ciphertext][counter (4 bytes)][MIC (4 bytes)]
+--- V1 uses UUID 0x181E and AAD = 0x11
+--- @param encrypted_payload string Encrypted service data
+--- @param bind_key string 16-byte encryption key
+--- @param mac_address string 6-byte MAC address
+--- @return string|nil decrypted Decrypted payload
+--- @return string|nil error Error message
+local function decrypt_v1(encrypted_payload, bind_key, mac_address)
+ -- Encrypted V1 format:
+ -- [ciphertext][counter (4 bytes)][MIC (4 bytes)]
+
+ if #encrypted_payload < 8 then
+ return nil, "encrypted payload too short"
+ end
+
+ -- Calculate 1-indexed positions for MIC and counter
+ local mic_start = #encrypted_payload - 4 + 1 -- First byte of MIC
+ local counter_start = mic_start - 4 -- First byte of counter
+
+ local counter = read_uint_le(encrypted_payload, counter_start, 4)
+
+ -- Build nonce for V1: MAC (6) + UUID16 (2) + counter (4) = 12 bytes
+ -- V1 encrypted uses UUID 0x181E
+ local nonce = build_v1_nonce(mac_address, 0x181E, counter)
+
+ -- V1 uses AAD = 0x11 (single byte)
+ local aad = string.char(0x11)
+
+ -- Decrypt (ciphertext + MIC, excluding counter bytes)
+ local ciphertext_with_mic = encrypted_payload:sub(1, counter_start - 1) .. encrypted_payload:sub(mic_start)
+
+ local plaintext, err = crypto.aes_ccm.decrypt(bind_key, nonce, aad, ciphertext_with_mic, 4)
+ if not plaintext then
+ return nil, "decryption failed: " .. (err or "unknown error")
+ end
+
+ return plaintext
+end
+
+--- Decrypt an encrypted BTHome V2 advertisement.
+--- @param encrypted_payload string Encrypted portion of service data
+--- @param bind_key string 16-byte encryption key
+--- @param mac_address string 6-byte MAC address
+--- @param device_info integer Device info byte
+--- @return string|nil decrypted Decrypted payload
+--- @return string|nil error Error message
+local function decrypt_v2(encrypted_payload, bind_key, mac_address, device_info)
+ -- Encrypted V2 format:
+ -- [encrypted data][counter (4 bytes)][MIC (4 bytes)]
+
+ if #encrypted_payload < 8 then
+ return nil, "encrypted payload too short"
+ end
+
+ -- Calculate 1-indexed positions for MIC and counter
+ local mic_start = #encrypted_payload - 4 + 1 -- First byte of MIC
+ local counter_start = mic_start - 4 -- First byte of counter
+
+ local counter = read_uint_le(encrypted_payload, counter_start, 4)
+
+ -- Build nonce for V2
+ local nonce = build_v2_nonce(mac_address, 0xFCD2, device_info, counter)
+
+ -- Decrypt (ciphertext + MIC, excluding counter bytes)
+ local ciphertext_with_mic = encrypted_payload:sub(1, counter_start - 1) .. encrypted_payload:sub(mic_start)
+
+ local plaintext, err = crypto.aes_ccm.decrypt(bind_key, nonce, "", ciphertext_with_mic, 4)
+ if not plaintext then
+ return nil, "decryption failed: " .. (err or "unknown error")
+ end
+
+ return plaintext
+end
+
+--- Parse a BTHome BLE advertisement.
+--- Supports both V1 and V2 formats, encrypted and unencrypted.
+---
+--- The service UUID determines the format:
+--- - 0x181C (UUID_V1_UNENCRYPTED): V1 unencrypted
+--- - 0x181E (UUID_V1_ENCRYPTED): V1 encrypted (requires bind_key and mac_address)
+--- - 0xFCD2 (UUID_V2): V2 format (device_info byte determines encryption)
+---
+--- @param uuid integer Service UUID (0x181C, 0x181E, or 0xFCD2)
+--- @param service_data string Raw service data bytes from BLE advertisement
+--- @param bind_key string|nil 16-byte encryption key (required for encrypted ads)
+--- @param mac_address string|nil 6-byte MAC address (required for encrypted ads)
+--- @return BTHomeParseResult|nil result Parsed result with device_info, packet_id, and readings
+--- @return string|nil error Error message if parsing failed
+function parser.parse(uuid, service_data, bind_key, mac_address)
+ if not service_data or #service_data < 1 then
+ return nil, "empty service data"
+ end
+
+ local device_info
+ local payload
+ local readings, packet_id, err
+
+ -- V1 unencrypted (UUID 0x181C)
+ if uuid == parser.UUID_V1_UNENCRYPTED then
+ readings, packet_id, err = parse_v1_payload(service_data, 1)
+ if not readings then
+ return nil, err
+ end
+
+ device_info = {
+ encrypted = false,
+ trigger_based = false,
+ version = 1,
+ }
+
+ assign_instance_numbers(readings)
+
+ return {
+ device_info = device_info,
+ packet_id = packet_id,
+ readings = readings,
+ }
+ end
+
+ -- V1 encrypted (UUID 0x181E)
+ if uuid == parser.UUID_V1_ENCRYPTED then
+ if not bind_key then
+ return nil, "bind_key required for encrypted advertisement"
+ end
+ if not mac_address then
+ return nil, "MAC address required for encrypted advertisement"
+ end
+ if #bind_key ~= 16 then
+ return nil, "bind_key must be 16 bytes"
+ end
+ if #mac_address ~= 6 then
+ return nil, "MAC address must be 6 bytes"
+ end
+
+ local decrypted
+ decrypted, err = decrypt_v1(service_data, bind_key, mac_address)
+ if not decrypted then
+ return nil, err
+ end
+
+ -- V1 encrypted payloads use V1 format internally
+ readings, packet_id, err = parse_v1_payload(decrypted, 1)
+ if not readings then
+ return nil, err
+ end
+
+ device_info = {
+ encrypted = true,
+ trigger_based = false,
+ version = 1,
+ }
+
+ assign_instance_numbers(readings)
+
+ return {
+ device_info = device_info,
+ packet_id = packet_id,
+ readings = readings,
+ }
+ end
+
+ -- V2 (UUID 0xFCD2)
+ if uuid ~= parser.UUID_V2 then
+ return nil, string.format("unknown BTHome service UUID: 0x%04X", uuid)
+ end
+
+ -- Parse device info byte (first byte)
+ local device_info_byte = string.byte(service_data, 1)
+ device_info = parse_device_info(device_info_byte)
+
+ -- Validate version in device_info byte
+ if device_info.version ~= 2 then
+ return nil, string.format("invalid BTHome V2 device_info version: %d", device_info.version)
+ end
+
+ payload = service_data:sub(2)
+
+ if device_info.encrypted then
+ -- Handle encrypted payload
+ if not bind_key then
+ return nil, "bind_key required for encrypted advertisement"
+ end
+ if not mac_address then
+ return nil, "MAC address required for encrypted advertisement"
+ end
+ if #bind_key ~= 16 then
+ return nil, "bind_key must be 16 bytes"
+ end
+ if #mac_address ~= 6 then
+ return nil, "MAC address must be 6 bytes"
+ end
+
+ local decrypted
+ decrypted, err = decrypt_v2(payload, bind_key, mac_address, device_info_byte)
+ if not decrypted then
+ return nil, err
+ end
+
+ payload = decrypted
+ end
+
+ -- Parse V2 payload
+ readings, packet_id, err = parse_v2_payload(payload, 1)
+ if not readings then
+ return nil, err
+ end
+
+ -- Assign instance numbers for duplicate sensors (instance=1, instance=2, etc.)
+ assign_instance_numbers(readings)
+
+ return {
+ device_info = device_info,
+ packet_id = packet_id,
+ readings = readings,
+ }
+end
+
+--- Run self-tests.
+--- Test vectors derived from bthome-ble Python reference implementation.
+--- @see https://github.com/Bluetooth-Devices/bthome-ble
+--- @return boolean success True if all tests passed
+function parser.selftest()
+ print("Testing parser module...")
+ local passed = 0
+ local total = 0
+
+ -- Helper to convert hex string to binary
+ local function hex_to_bin(hex)
+ local bytes = {}
+ for i = 1, #hex, 2 do
+ local byte = tonumber(hex:sub(i, i + 1), 16) or 0
+ bytes[#bytes + 1] = string.char(byte)
+ end
+ return table.concat(bytes)
+ end
+
+ -- Helper to check a single reading value
+ local function check_reading(result, name, expected, tolerance)
+ tolerance = tolerance or 0.01
+ for _, reading in ipairs(result.readings) do
+ if reading.name == name then
+ if type(expected) == "number" then
+ return math.abs(reading.value - expected) < tolerance
+ else
+ return reading.value == expected
+ end
+ end
+ end
+ return false
+ end
+
+ -- Helper to run a simple V2 parse test (most common case)
+ local function run_test(test_name, hex_data, checks)
+ total = total + 1
+ local data = hex_to_bin(hex_data)
+ local result, err = parser.parse(parser.UUID_V2, data)
+ if result then
+ local all_ok = true
+ for name, expected in pairs(checks) do
+ if not check_reading(result, name, expected) then
+ all_ok = false
+ print(string.format(" FAIL: %s", test_name))
+ print(string.format(" Expected %s: %s", name, tostring(expected)))
+ print(" Got readings:")
+ for _, r in ipairs(result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ break
+ end
+ end
+ if all_ok then
+ print(string.format(" PASS: %s", test_name))
+ passed = passed + 1
+ return true
+ end
+ else
+ print(string.format(" FAIL: %s", test_name))
+ print(string.format(" Error: %s", err or "unknown"))
+ end
+ return false
+ end
+
+ -- ===========================================================================
+ -- V2 Basic Sensor Tests (from bthome-ble test_parser_v2.py)
+ -- ===========================================================================
+
+ -- Temperature + Humidity (official test vector)
+ -- 40 02 ca 09 03 bf 13 -> temp=25.06, humidity=50.55
+ run_test("V2 temperature+humidity", "4002ca0903bf13", { temperature = 25.06, humidity = 50.55 })
+
+ -- Pressure: 40 04 13 8a 01 -> 1008.83 mbar
+ run_test("V2 pressure", "4004138a01", { pressure = 1008.83 })
+
+ -- Illuminance: 40 05 13 8a 14 -> 13460.67 lux
+ run_test("V2 illuminance", "4005138a14", { illuminance = 13460.67 })
+
+ -- Mass (kg): 40 06 5e 1f -> 80.30 kg
+ run_test("V2 mass_kg", "40065e1f", { mass_kg = 80.30 })
+
+ -- Mass (lb): 40 07 3e 1d -> 74.86 lb
+ run_test("V2 mass_lb", "40073e1d", { mass_lb = 74.86 })
+
+ -- Dew point: 40 08 ca 06 -> 17.38 °C
+ run_test("V2 dewpoint", "4008ca06", { dewpoint = 17.38 })
+
+ -- Count: 40 09 60 -> 96
+ run_test("V2 count", "400960", { count = 96 })
+
+ -- Energy: 40 0a 13 8a 14 -> 1346.067 kWh
+ run_test("V2 energy", "400a138a14", { energy = 1346.067 })
+
+ -- Power: 40 0b 02 1b 00 -> 69.14 W
+ run_test("V2 power", "400b021b00", { power = 69.14 })
+
+ -- Voltage: 40 0c 02 0c -> 3.074 V
+ run_test("V2 voltage", "400c020c", { voltage = 3.074 })
+
+ -- PM2.5 + PM10: 40 0d 12 0c 0e 02 1c -> PM2.5=3090, PM10=7170
+ run_test("V2 PM sensors", "400d120c0e021c", { pm2_5 = 3090, pm10 = 7170 })
+
+ -- CO2: 40 12 e2 04 -> 1250 ppm
+ run_test("V2 CO2", "4012e204", { co2 = 1250 })
+
+ -- TVOC: 40 13 33 01 -> 307 µg/m³
+ run_test("V2 TVOC", "40133301", { tvoc = 307 })
+
+ -- Moisture: 40 14 02 0c -> 30.74 %
+ run_test("V2 moisture", "4014020c", { moisture = 30.74 })
+
+ -- Battery: 40 01 64 -> 100%
+ run_test("V2 battery", "400164", { battery = 100 })
+
+ -- ===========================================================================
+ -- V2 Boolean Sensor Tests
+ -- ===========================================================================
+
+ -- Generic boolean: 40 0f 01 -> true
+ run_test("V2 generic_boolean", "400f01", { generic_boolean = 1 })
+
+ -- Power on: 40 10 01 -> true
+ run_test("V2 power_on", "401001", { power_on = 1 })
+
+ -- Opening: 40 11 00 -> false (closed)
+ run_test("V2 opening closed", "401100", { opening = 0 })
+
+ -- Opening: 40 11 01 -> true (open)
+ run_test("V2 opening open", "401101", { opening = 1 })
+
+ -- Motion: 40 21 01 -> detected (0x21 = motion)
+ run_test("V2 motion", "402101", { motion = 1 })
+
+ -- Smoke: 40 29 01 -> detected (0x29 = smoke_detected)
+ run_test("V2 smoke_detected", "402901", { smoke_detected = 1 })
+
+ -- Tamper: 40 2B 01 -> detected (0x2B = tamper)
+ run_test("V2 tamper", "402B01", { tamper = 1 })
+
+ -- ===========================================================================
+ -- V2 Extended Numeric Sensors
+ -- ===========================================================================
+
+ -- Current: 40 43 4e 34 -> 13.39 A (0x344E = 13390, * 0.001)
+ run_test("V2 current", "40434e34", { current = 13.390 })
+
+ -- Speed: 40 44 4e 34 -> 133.90 m/s (0x344E = 13390, * 0.01)
+ run_test("V2 speed", "40444e34", { speed = 133.90 })
+
+ -- Temperature 0x45 (sint16, factor 0.1): 40 45 11 01 -> 27.3 °C (0x0111 = 273, * 0.1)
+ run_test("V2 temperature 0x45", "40451101", { temperature = 27.3 })
+
+ -- Temperature 0x57 (sint8, factor 1): 40 57 11 -> 17 °C
+ run_test("V2 temperature 0x57", "405711", { temperature = 17 })
+
+ -- UV Index: 40 46 32 -> 5.0 (0x32 = 50, * 0.1)
+ run_test("V2 UV index", "404632", { uv_index = 5.0 })
+
+ -- Volume (0x47): 40 47 87 56 -> 2215.1 L (0x5687 = 22151, * 0.1)
+ run_test("V2 volume 0x47", "40478756", { volume = 2215.1 })
+
+ -- Volume mL: 40 48 dc 87 -> 34780 mL
+ run_test("V2 volume_ml", "4048dc87", { volume_ml = 34780 })
+
+ -- Distance mm: 40 40 0c 00 -> 12 mm
+ run_test("V2 distance_mm", "40400c00", { distance_mm = 12 })
+
+ -- Distance m: 40 41 4e 00 -> 7.8 m
+ run_test("V2 distance_m", "40414e00", { distance_m = 7.8 })
+
+ -- Duration: 40 42 4e 34 00 -> 13.390 s
+ run_test("V2 duration", "40424e3400", { duration = 13.390 })
+
+ -- Rotation: 40 3f 02 0c -> 307.4 °
+ run_test("V2 rotation", "403f020c", { rotation = 307.4 })
+
+ -- Humidity 0x2E (uint8, factor 1): 40 2E 34 -> 52%
+ run_test("V2 humidity 0x2E", "402E34", { humidity = 52 })
+
+ -- Moisture 0x2F (uint8, factor 1): 40 2F 2D -> 45%
+ run_test("V2 moisture 0x2F", "402F2D", { moisture = 45 })
+
+ -- Voltage 0x4A (uint16, factor 0.1): 40 4A 02 0C -> 307.4V (0x0C02 = 3074, * 0.1)
+ -- Reference: test_parser_v2.py uses this exact vector
+ run_test("V2 voltage 0x4A", "404A020C", { voltage = 307.4 })
+
+ -- Window 0x2D: 40 2D 01 -> 1 (open)
+ -- Reference: test_parser_v2.py uses this exact vector
+ run_test("V2 window", "402D01", { window = 1 })
+
+ -- ===========================================================================
+ -- V2 Firmware Version Tests
+ -- ===========================================================================
+
+ -- Firmware version uint32 (0xF1): 40 F1 04 03 02 01 -> "1.2.3.4"
+ -- Bytes are in little-endian order, parsed as version string
+ total = total + 1
+ local fw32_data = hex_to_bin("40F104030201")
+ local fw32_result = parser.parse(parser.UUID_V2, fw32_data)
+ if fw32_result then
+ local found = false
+ for _, r in ipairs(fw32_result.readings) do
+ if r.name == "firmware_version" and r.value == "1.2.3.4" then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 firmware_version uint32")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 firmware_version uint32")
+ print(" Expected: firmware_version = '1.2.3.4'")
+ print(" Got readings:")
+ for _, r in ipairs(fw32_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V2 firmware_version uint32")
+ print(" Error: parsing failed")
+ end
+
+ -- Firmware version uint24 (0xF2): 40 F2 03 02 01 -> "1.2.3"
+ total = total + 1
+ local fw24_data = hex_to_bin("40F2030201")
+ local fw24_result = parser.parse(parser.UUID_V2, fw24_data)
+ if fw24_result then
+ local found = false
+ for _, r in ipairs(fw24_result.readings) do
+ if r.name == "firmware_version" and r.value == "1.2.3" then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 firmware_version uint24")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 firmware_version uint24")
+ print(" Expected: firmware_version = '1.2.3'")
+ print(" Got readings:")
+ for _, r in ipairs(fw24_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V2 firmware_version uint24")
+ print(" Error: parsing failed")
+ end
+
+ -- Firmware version with realistic values: 40 F1 01 00 05 02 -> "2.5.0.1"
+ total = total + 1
+ local fw_real_data = hex_to_bin("40F101000502")
+ local fw_real_result = parser.parse(parser.UUID_V2, fw_real_data)
+ if fw_real_result then
+ local found = false
+ for _, r in ipairs(fw_real_result.readings) do
+ if r.name == "firmware_version" and r.value == "2.5.0.1" then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 firmware_version realistic")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 firmware_version realistic")
+ print(" Expected: firmware_version = '2.5.0.1'")
+ print(" Got readings:")
+ for _, r in ipairs(fw_real_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V2 firmware_version realistic")
+ print(" Error: parsing failed")
+ end
+
+ -- ===========================================================================
+ -- V2 Event Tests
+ -- ===========================================================================
+
+ -- Button press (short)
+ total = total + 1
+ local btn_data = hex_to_bin("443a01")
+ local btn_result = parser.parse(parser.UUID_V2, btn_data)
+ if btn_result and btn_result.device_info.trigger_based then
+ local found = false
+ for _, r in ipairs(btn_result.readings) do
+ if r.name == "button" and r.event and r.event.event_name == "press" then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 button press event")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 button press event")
+ print(" Expected: button with event_name = 'press'")
+ end
+ else
+ print(" FAIL: V2 button press event")
+ print(" Error: parsing failed or trigger_based not set")
+ end
+
+ -- Button long press
+ total = total + 1
+ local btn_long_data = hex_to_bin("443a04")
+ local btn_long_result = parser.parse(parser.UUID_V2, btn_long_data)
+ if btn_long_result then
+ local found = false
+ for _, r in ipairs(btn_long_result.readings) do
+ if r.name == "button" and r.event and r.event.event_name == "long_press" then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 button long_press event")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 button long_press event")
+ print(" Expected: button with event_name = 'long_press'")
+ end
+ else
+ print(" FAIL: V2 button long_press event")
+ print(" Error: parsing failed")
+ end
+
+ -- Dimmer rotate left: 44 3C 01 03
+ -- device_info=0x44 (trigger-based, V2), object_id=0x3C (dimmer)
+ -- value bytes: 01 03 -> little-endian uint16 = 0x0301 -> event_type=1 (rotate_left), steps=3
+ total = total + 1
+ local dimmer_data = hex_to_bin("443c0103")
+ local dimmer_result = parser.parse(parser.UUID_V2, dimmer_data)
+ if dimmer_result then
+ local found = false
+ for _, r in ipairs(dimmer_result.readings) do
+ if r.name == "dimmer" and r.event and r.event.event_name == "rotate_left" and r.event.steps == 3 then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 dimmer rotate_left event")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 dimmer rotate_left event")
+ print(" Expected: dimmer with event_name = 'rotate_left', steps = 3")
+ end
+ else
+ print(" FAIL: V2 dimmer rotate_left event")
+ print(" Error: parsing failed")
+ end
+
+ -- Dimmer rotate right: 44 3C 02 05
+ -- device_info=0x44, object_id=0x3C
+ -- value bytes: 02 05 -> little-endian uint16 = 0x0502 -> event_type=2 (rotate_right), steps=5
+ total = total + 1
+ local dimmer_right_data = hex_to_bin("443c0205")
+ local dimmer_right_result = parser.parse(parser.UUID_V2, dimmer_right_data)
+ if dimmer_right_result then
+ local found = false
+ for _, r in ipairs(dimmer_right_result.readings) do
+ if r.name == "dimmer" and r.event and r.event.event_name == "rotate_right" and r.event.steps == 5 then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 dimmer rotate_right event")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 dimmer rotate_right event")
+ print(" Expected: dimmer with event_name = 'rotate_right', steps = 5")
+ end
+ else
+ print(" FAIL: V2 dimmer rotate_right event")
+ print(" Error: parsing failed")
+ end
+
+ -- ===========================================================================
+ -- Packet ID Test
+ -- ===========================================================================
+
+ total = total + 1
+ local pkt_data = hex_to_bin("400005020000")
+ local pkt_result = parser.parse(parser.UUID_V2, pkt_data)
+ if pkt_result and pkt_result.packet_id == 5 then
+ print(" PASS: V2 packet ID parsing")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 packet ID parsing")
+ print(string.format(" Expected: packet_id = 5"))
+ print(string.format(" Got: packet_id = %s", pkt_result and tostring(pkt_result.packet_id) or "nil"))
+ end
+
+ -- ===========================================================================
+ -- Multiple Readings Test
+ -- ===========================================================================
+
+ total = total + 1
+ local multi_data = hex_to_bin("40015f02e8030310" .. "27")
+ local multi_result = parser.parse(parser.UUID_V2, multi_data)
+ if multi_result and #multi_result.readings == 3 then
+ print(" PASS: Multiple readings in one advertisement")
+ passed = passed + 1
+ else
+ print(" FAIL: Multiple readings parsing")
+ print(string.format(" Expected: 3 readings"))
+ print(string.format(" Got: %d readings", multi_result and #multi_result.readings or 0))
+ end
+
+ -- ===========================================================================
+ -- Error Handling Tests
+ -- ===========================================================================
+
+ -- Empty data
+ total = total + 1
+ local _, err_empty = parser.parse(parser.UUID_V2, "")
+ if err_empty then
+ print(" PASS: Empty data rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Empty data should be rejected")
+ print(" Expected: error message")
+ print(" Got: no error")
+ end
+
+ -- Invalid version (0)
+ total = total + 1
+ local _, err_ver = parser.parse(parser.UUID_V2, hex_to_bin("00"))
+ if err_ver and err_ver:find("version") then
+ print(" PASS: Invalid version rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Invalid version should be rejected")
+ print(" Expected: error containing 'version'")
+ print(string.format(" Got: %s", err_ver or "no error"))
+ end
+
+ -- Encrypted without bind_key
+ total = total + 1
+ local _, err_key = parser.parse(parser.UUID_V2, hex_to_bin("41"))
+ if err_key and err_key:find("bind_key") then
+ print(" PASS: Encrypted without bind_key rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Encrypted without bind_key should require key")
+ print(" Expected: error containing 'bind_key'")
+ print(string.format(" Got: %s", err_key or "no error"))
+ end
+
+ -- Encrypted without MAC
+ total = total + 1
+ local bind_key = hex_to_bin("231d39c1d7cc1ab1aee224cd096db932")
+ local _, err_mac = parser.parse(parser.UUID_V2, hex_to_bin("41aabbccdd"), bind_key)
+ if err_mac and err_mac:find("MAC") then
+ print(" PASS: Encrypted without MAC rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Encrypted without MAC should require address")
+ print(" Expected: error containing 'MAC'")
+ print(string.format(" Got: %s", err_mac or "no error"))
+ end
+
+ -- Truncated data (object ID without value)
+ total = total + 1
+ local _, err_trunc = parser.parse(parser.UUID_V2, hex_to_bin("4002"))
+ if err_trunc and err_trunc:find("truncated") then
+ print(" PASS: Truncated data rejected")
+ passed = passed + 1
+ else
+ print(" FAIL: Truncated data should be rejected")
+ print(" Expected: error containing 'truncated'")
+ print(string.format(" Got: %s", err_trunc or "no error"))
+ end
+
+ -- ===========================================================================
+ -- Negative Temperature Test (Signed Value)
+ -- ===========================================================================
+
+ -- Temperature: -10.0°C = -1000 = 0xFC18 in little-endian = 18 FC
+ total = total + 1
+ local neg_temp_data = hex_to_bin("400218fc")
+ local neg_temp_result = parser.parse(parser.UUID_V2, neg_temp_data)
+ if neg_temp_result then
+ local found = false
+ for _, r in ipairs(neg_temp_result.readings) do
+ if r.name == "temperature" and math.abs(r.value - -10.0) < 0.01 then
+ found = true
+ end
+ end
+ if found then
+ print(" PASS: V2 negative temperature")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 negative temperature")
+ print(" Expected: temperature = -10.0")
+ print(" Got readings:")
+ for _, r in ipairs(neg_temp_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V2 negative temperature")
+ print(" Error: parsing failed")
+ end
+
+ -- ===========================================================================
+ -- V2 Encrypted Advertisement Test
+ -- ===========================================================================
+
+ -- Official BTHome test vector from https://bthome.io/encryption/
+ -- Decrypted payload: 02ca09 03bf13 = temp 25.06°C, humidity 50.55%
+ -- MAC: 5448E68F80A5
+ -- Bind key: 231d39c1d7cc1ab1aee224cd096db932
+ -- Service data: 41e445f3c9962b332211006c7c4519
+ -- 41 = device_info (encrypted=true, v2)
+ -- e445f3c9962b = encrypted data (6 bytes)
+ -- 33221100 = counter (1122867 in LE)
+ -- 6c7c4519 = MIC (4 bytes)
+ total = total + 1
+ local enc_packet = hex_to_bin("41e445f3c9962b332211006c7c4519")
+ local enc_bind_key = hex_to_bin("231d39c1d7cc1ab1aee224cd096db932")
+ local enc_mac = hex_to_bin("5448E68F80A5")
+ local enc_result, enc_err = parser.parse(parser.UUID_V2, enc_packet, enc_bind_key, enc_mac)
+ if enc_result and enc_result.readings and #enc_result.readings > 0 then
+ -- Check for expected values
+ local found_temp = false
+ local found_hum = false
+ for _, r in ipairs(enc_result.readings) do
+ if r.name == "temperature" and math.abs(r.value - 25.06) < 0.01 then
+ found_temp = true
+ end
+ if r.name == "humidity" and math.abs(r.value - 50.55) < 0.01 then
+ found_hum = true
+ end
+ end
+ if found_temp and found_hum then
+ print(" PASS: V2 encrypted advertisement decryption")
+ passed = passed + 1
+ else
+ print(" FAIL: V2 encrypted advertisement decryption")
+ print(" Expected: temperature = 25.06, humidity = 50.55")
+ print(" Got readings:")
+ for _, r in ipairs(enc_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V2 encrypted advertisement decryption")
+ print(string.format(" Error: %s", enc_err or "unknown"))
+ end
+
+ -- ===========================================================================
+ -- V1 Encrypted Advertisement Test
+ -- ===========================================================================
+
+ -- Official BTHome V1 test vector from bthome-ble test_parser_v1.py
+ -- MAC: 54:48:E6:8F:80:A5
+ -- Bind key: 231d39c1d7cc1ab1aee224cd096db932
+ -- Service data (UUID 0x181E): fba435e4d3c312fb0011223357d90a99
+ -- Decrypted payload uses V1 format: temp 25.06°C, humidity 50.55%
+ total = total + 1
+ local v1_enc_packet = hex_to_bin("fba435e4d3c312fb0011223357d90a99")
+ local v1_enc_bind_key = hex_to_bin("231d39c1d7cc1ab1aee224cd096db932")
+ local v1_enc_mac = hex_to_bin("5448E68F80A5")
+ local v1_enc_result, v1_enc_err = parser.parse(parser.UUID_V1_ENCRYPTED, v1_enc_packet, v1_enc_bind_key, v1_enc_mac)
+ if v1_enc_result and v1_enc_result.readings and #v1_enc_result.readings > 0 then
+ local found_temp = false
+ local found_hum = false
+ for _, r in ipairs(v1_enc_result.readings) do
+ if r.name == "temperature" and math.abs(r.value - 25.06) < 0.01 then
+ found_temp = true
+ end
+ if r.name == "humidity" and math.abs(r.value - 50.55) < 0.01 then
+ found_hum = true
+ end
+ end
+ if found_temp and found_hum and v1_enc_result.device_info.version == 1 and v1_enc_result.device_info.encrypted then
+ print(" PASS: V1 encrypted advertisement decryption")
+ passed = passed + 1
+ else
+ print(" FAIL: V1 encrypted advertisement decryption")
+ print(" Expected: temperature = 25.06, humidity = 50.55, version = 1, encrypted = true")
+ print(
+ string.format(
+ " Got: version = %d, encrypted = %s",
+ v1_enc_result.device_info.version,
+ tostring(v1_enc_result.device_info.encrypted)
+ )
+ )
+ print(" Got readings:")
+ for _, r in ipairs(v1_enc_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V1 encrypted advertisement decryption")
+ print(string.format(" Error: %s", v1_enc_err or "unknown"))
+ end
+
+ -- ===========================================================================
+ -- V1 Unencrypted Advertisement Tests
+ -- ===========================================================================
+
+ -- Official BTHome V1 test vector from bthome-ble test_parser_v1.py
+ -- test_bthome_temperature_humidity: temp 25.06°C, humidity 50.55%
+ -- Data: 23 02 ca 09 03 03 bf 13
+ -- 23 = control (len=3, fmt=1), 02 = temperature, ca09 = 2506 -> 25.06
+ -- 03 = control (len=3, fmt=0), 03 = humidity, bf13 = 5055 -> 50.55
+ total = total + 1
+ local v1_temp_hum_data = hex_to_bin("2302ca090303bf13")
+ local v1_temp_hum_result, v1_temp_hum_err = parser.parse(parser.UUID_V1_UNENCRYPTED, v1_temp_hum_data)
+ if v1_temp_hum_result and v1_temp_hum_result.readings then
+ local found_temp = false
+ local found_hum = false
+ for _, r in ipairs(v1_temp_hum_result.readings) do
+ if r.name == "temperature" and math.abs(r.value - 25.06) < 0.01 then
+ found_temp = true
+ end
+ if r.name == "humidity" and math.abs(r.value - 50.55) < 0.01 then
+ found_hum = true
+ end
+ end
+ if
+ found_temp
+ and found_hum
+ and v1_temp_hum_result.device_info.version == 1
+ and not v1_temp_hum_result.device_info.encrypted
+ then
+ print(" PASS: V1 unencrypted temperature+humidity")
+ passed = passed + 1
+ else
+ print(" FAIL: V1 unencrypted temperature+humidity")
+ print(" Expected: temperature = 25.06, humidity = 50.55, version = 1, encrypted = false")
+ print(" Got readings:")
+ for _, r in ipairs(v1_temp_hum_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V1 unencrypted temperature+humidity")
+ print(string.format(" Error: %s", v1_temp_hum_err or "unknown"))
+ end
+
+ -- Official BTHome V1 test vector from bthome-ble test_parser_v1.py
+ -- test_bthome_pressure: pressure 1008.83 mbar
+ -- Data: 04 04 13 8a 01
+ -- 04 = control (len=4, fmt=0), 04 = pressure, 138a01 = 100883 -> 1008.83
+ total = total + 1
+ local v1_pressure_data = hex_to_bin("0404138a01")
+ local v1_pressure_result, v1_pressure_err = parser.parse(parser.UUID_V1_UNENCRYPTED, v1_pressure_data)
+ if v1_pressure_result and v1_pressure_result.readings then
+ local found_pressure = false
+ for _, r in ipairs(v1_pressure_result.readings) do
+ if r.name == "pressure" and math.abs(r.value - 1008.83) < 0.01 then
+ found_pressure = true
+ end
+ end
+ if found_pressure then
+ print(" PASS: V1 unencrypted pressure")
+ passed = passed + 1
+ else
+ print(" FAIL: V1 unencrypted pressure")
+ print(" Expected: pressure = 1008.83")
+ print(" Got readings:")
+ for _, r in ipairs(v1_pressure_result.readings) do
+ print(string.format(" %s = %s", r.name, tostring(r.value)))
+ end
+ end
+ else
+ print(" FAIL: V1 unencrypted pressure")
+ print(string.format(" Error: %s", v1_pressure_err or "unknown"))
+ end
+
+ -- ===========================================================================
+ -- Duplicate Object ID Tests (instance field)
+ -- ===========================================================================
+
+ -- Two power readings (0x10) and two opening readings (0x11)
+ -- This simulates pvvx firmware on LYWSD03MMC that sends multiple comfort zone triggers
+ -- Format: 40 10 01 10 00 11 01 11 00
+ -- 40 = V2, not encrypted
+ -- 10 01 = power_on (object 0x10) = 1 (on)
+ -- 10 00 = power_on (object 0x10) = 0 (off)
+ -- 11 01 = opening (object 0x11) = 1 (open)
+ -- 11 00 = opening (object 0x11) = 0 (closed)
+ total = total + 1
+ local dup_data = hex_to_bin("401001100011011100")
+ local dup_result = parser.parse(parser.UUID_V2, dup_data)
+ if dup_result and #dup_result.readings == 4 then
+ local r = dup_result.readings
+ -- Check names are unchanged and instances are assigned correctly
+ local all_match = r[1].name == "power_on"
+ and r[1].instance == 1
+ and r[2].name == "power_on"
+ and r[2].instance == 2
+ and r[3].name == "opening"
+ and r[3].instance == 1
+ and r[4].name == "opening"
+ and r[4].instance == 2
+ if all_match then
+ print(" PASS: Duplicate object IDs get instance numbers")
+ passed = passed + 1
+ else
+ print(" FAIL: Duplicate object IDs get instance numbers")
+ print(" Expected: power_on(1), power_on(2), opening(1), opening(2)")
+ print(" Got readings:")
+ for i, reading in ipairs(r) do
+ print(string.format(" [%d] name=%s, instance=%s", i, reading.name, tostring(reading.instance)))
+ end
+ end
+ else
+ print(" FAIL: Duplicate object IDs get instance numbers")
+ print(" Expected: 4 readings")
+ print(string.format(" Got: %d readings", dup_result and #dup_result.readings or 0))
+ end
+
+ -- Three identical temperature readings to test instance=1, 2, 3
+ -- Format: 40 02 ca09 02 bf13 02 1027
+ -- 40 = V2, not encrypted
+ -- 02 ca09 = temperature = 25.06°C
+ -- 02 bf13 = temperature = 50.55°C (using humidity bytes as temp for variety)
+ -- 02 1027 = temperature = 100.00°C
+ total = total + 1
+ local triple_data = hex_to_bin("4002ca0902bf13021027")
+ local triple_result = parser.parse(parser.UUID_V2, triple_data)
+ if triple_result and #triple_result.readings == 3 then
+ local r = triple_result.readings
+ local all_match = r[1].name == "temperature"
+ and r[1].instance == 1
+ and r[2].name == "temperature"
+ and r[2].instance == 2
+ and r[3].name == "temperature"
+ and r[3].instance == 3
+ if all_match then
+ print(" PASS: Triple duplicate gets instance=1, 2, 3")
+ passed = passed + 1
+ else
+ print(" FAIL: Triple duplicate gets instance=1, 2, 3")
+ print(" Expected: temperature(1), temperature(2), temperature(3)")
+ print(" Got readings:")
+ for i, reading in ipairs(r) do
+ print(string.format(" [%d] name=%s, instance=%s", i, reading.name, tostring(reading.instance)))
+ end
+ end
+ else
+ print(" FAIL: Triple duplicate gets instance=1, 2, 3")
+ print(" Expected: 3 readings")
+ print(string.format(" Got: %d readings", triple_result and #triple_result.readings or 0))
+ end
+
+ print(string.format("\nparser module: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+return parser
diff --git a/vendor/bitn.lua b/vendor/bitn.lua
new file mode 100644
index 0000000..92c9cbe
--- /dev/null
+++ b/vendor/bitn.lua
@@ -0,0 +1,3290 @@
+do
+local _ENV = _ENV
+package.preload[ "bitn._compat" ] = function( ... ) local arg = _G.arg;
+--- @diagnostic disable: duplicate-set-field
+--- @module "bitn._compat"
+--- Internal compatibility layer for bitwise operations.
+--- Provides feature detection and optimized primitives for use by bit16/bit32/bit64.
+--- @class bitn._compat
+local _compat = {}
+
+--------------------------------------------------------------------------------
+-- Helper functions (needed by all implementations)
+--------------------------------------------------------------------------------
+
+local math_floor = math.floor
+local math_pow = math.pow or function(x, y)
+ return x ^ y
+end
+
+--- Convert signed 32-bit to unsigned (for LuaJIT which returns signed values)
+--- @param n number Potentially signed 32-bit value
+--- @return number Unsigned 32-bit value
+local function to_unsigned(n)
+ if n < 0 then
+ return n + 0x100000000
+ end
+ return n
+end
+
+_compat.to_unsigned = to_unsigned
+
+-- Constants
+local MASK32 = 0xFFFFFFFF
+
+--------------------------------------------------------------------------------
+-- Implementation 1: Native operators (Lua 5.3+)
+--------------------------------------------------------------------------------
+
+local ok, result = pcall(load, "return function(a,b) return a & b end")
+if ok and result then
+ local fn = result()
+ if fn then
+ -- Native operators available - define all functions using them
+ local native_band = fn
+ local native_bor = assert(load("return function(a,b) return a | b end"))()
+ local native_bxor = assert(load("return function(a,b) return a ~ b end"))()
+ local native_bnot = assert(load("return function(a) return ~a end"))()
+ local native_lshift = assert(load("return function(a,n) return a << n end"))()
+ local native_rshift = assert(load("return function(a,n) return a >> n end"))()
+
+ _compat.has_native_ops = true
+ _compat.has_bit_lib = false
+ _compat.is_luajit = false
+
+ function _compat.impl_name()
+ return "native operators (Lua 5.3+)"
+ end
+
+ function _compat.band(a, b)
+ return native_band(a, b)
+ end
+
+ function _compat.bor(a, b)
+ return native_bor(a, b)
+ end
+
+ function _compat.bxor(a, b)
+ return native_bxor(a, b)
+ end
+
+ function _compat.bnot(a)
+ return native_band(native_bnot(a), MASK32)
+ end
+
+ function _compat.lshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return native_band(native_lshift(a, n), MASK32)
+ end
+
+ function _compat.rshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return native_rshift(native_band(a, MASK32), n)
+ end
+
+ function _compat.arshift(a, n)
+ a = native_band(a, MASK32)
+ local is_negative = a >= 0x80000000
+ if n >= 32 then
+ return is_negative and MASK32 or 0
+ end
+ local r = native_rshift(a, n)
+ if is_negative then
+ local fill_mask = native_lshift(MASK32, 32 - n)
+ r = native_bor(r, native_band(fill_mask, MASK32))
+ end
+ return native_band(r, MASK32)
+ end
+
+ -- Raw operations provide direct access to native bit functions without the
+ -- to_unsigned() wrapper. On Lua 5.3+, these are identical to wrapped versions
+ -- since native operators already return unsigned values.
+ -- Shifts must mask to 32 bits since native operators work on 64-bit values.
+ _compat.raw_band = native_band
+ _compat.raw_bor = native_bor
+ _compat.raw_bxor = native_bxor
+ _compat.raw_bnot = function(a)
+ return native_band(native_bnot(a), MASK32)
+ end
+ _compat.raw_lshift = function(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return native_band(native_lshift(a, n), MASK32)
+ end
+ _compat.raw_rshift = function(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return native_rshift(native_band(a, MASK32), n)
+ end
+ _compat.raw_arshift = _compat.arshift
+ -- No native rol/ror on Lua 5.3+
+ _compat.raw_rol = nil
+ _compat.raw_ror = nil
+
+ return _compat
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Implementation 2: Bit library (LuaJIT or Lua 5.2)
+--------------------------------------------------------------------------------
+
+local bit_lib
+local is_luajit = false
+
+-- Try LuaJIT's bit library first
+ok, result = pcall(require, "bit")
+if ok and result then
+ bit_lib = result
+ is_luajit = true
+else
+ -- Try Lua 5.2's bit32 library (use rawget to avoid recursion with our module name)
+ bit_lib = rawget(_G, "bit32")
+end
+
+if bit_lib then
+ -- Bit library available - define all functions using it
+ local bit_band = assert(bit_lib.band)
+ local bit_bor = assert(bit_lib.bor)
+ local bit_bxor = assert(bit_lib.bxor)
+ local bit_bnot = assert(bit_lib.bnot)
+ local bit_lshift = assert(bit_lib.lshift)
+ local bit_rshift = assert(bit_lib.rshift)
+ local bit_arshift = assert(bit_lib.arshift)
+
+ _compat.has_native_ops = false
+ _compat.has_bit_lib = true
+ _compat.is_luajit = is_luajit
+
+ function _compat.impl_name()
+ return "bit library"
+ end
+
+ if is_luajit then
+ -- LuaJIT returns signed integers, need to convert to unsigned
+ function _compat.band(a, b)
+ return to_unsigned(bit_band(a, b))
+ end
+
+ function _compat.bor(a, b)
+ return to_unsigned(bit_bor(a, b))
+ end
+
+ function _compat.bxor(a, b)
+ return to_unsigned(bit_bxor(a, b))
+ end
+
+ function _compat.bnot(a)
+ return to_unsigned(bit_bnot(a))
+ end
+
+ function _compat.lshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return to_unsigned(bit_lshift(a, n))
+ end
+
+ function _compat.rshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return to_unsigned(bit_rshift(a, n))
+ end
+
+ function _compat.arshift(a, n)
+ a = to_unsigned(bit_band(a, MASK32))
+ if n >= 32 then
+ local is_negative = a >= 0x80000000
+ return is_negative and MASK32 or 0
+ end
+ return to_unsigned(bit_arshift(a, n))
+ end
+ else
+ -- Lua 5.2 bit32 library returns unsigned integers
+ function _compat.band(a, b)
+ return bit_band(a, b)
+ end
+
+ function _compat.bor(a, b)
+ return bit_bor(a, b)
+ end
+
+ function _compat.bxor(a, b)
+ return bit_bxor(a, b)
+ end
+
+ function _compat.bnot(a)
+ return bit_band(bit_bnot(a), MASK32)
+ end
+
+ function _compat.lshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return bit_band(bit_lshift(a, n), MASK32)
+ end
+
+ function _compat.rshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return bit_rshift(bit_band(a, MASK32), n)
+ end
+
+ function _compat.arshift(a, n)
+ a = bit_band(a, MASK32)
+ if n >= 32 then
+ local is_negative = a >= 0x80000000
+ return is_negative and MASK32 or 0
+ end
+ return bit_band(bit_arshift(a, n), MASK32)
+ end
+ end
+
+ -- Raw operations provide direct access to native bit functions without the
+ -- to_unsigned() wrapper. On LuaJIT, these return signed 32-bit integers.
+ -- On Lua 5.2 (bit32 library), these are identical to wrapped versions.
+ _compat.raw_band = bit_band
+ _compat.raw_bor = bit_bor
+ _compat.raw_bxor = bit_bxor
+ _compat.raw_bnot = bit_bnot
+ _compat.raw_lshift = bit_lshift
+ _compat.raw_rshift = bit_rshift
+ _compat.raw_arshift = bit_arshift
+ -- rol/ror only available on LuaJIT (bit library), not Lua 5.2 (bit32 library)
+ if bit_lib.rol then
+ _compat.raw_rol = bit_lib.rol
+ _compat.raw_ror = bit_lib.ror
+ else
+ _compat.raw_rol = nil
+ _compat.raw_ror = nil
+ end
+
+ return _compat
+end
+
+--------------------------------------------------------------------------------
+-- Implementation 3: Pure Lua fallback
+--------------------------------------------------------------------------------
+
+_compat.has_native_ops = false
+_compat.has_bit_lib = false
+_compat.is_luajit = false
+
+function _compat.impl_name()
+ return "pure Lua"
+end
+
+function _compat.band(a, b)
+ local r = 0
+ local bit_val = 1
+ for _ = 0, 31 do
+ if (a % 2 == 1) and (b % 2 == 1) then
+ r = r + bit_val
+ end
+ a = math_floor(a / 2)
+ b = math_floor(b / 2)
+ bit_val = bit_val * 2
+ if a == 0 and b == 0 then
+ break
+ end
+ end
+ return r
+end
+
+function _compat.bor(a, b)
+ local r = 0
+ local bit_val = 1
+ for _ = 0, 31 do
+ if (a % 2 == 1) or (b % 2 == 1) then
+ r = r + bit_val
+ end
+ a = math_floor(a / 2)
+ b = math_floor(b / 2)
+ bit_val = bit_val * 2
+ if a == 0 and b == 0 then
+ break
+ end
+ end
+ return r
+end
+
+function _compat.bxor(a, b)
+ local r = 0
+ local bit_val = 1
+ for _ = 0, 31 do
+ if (a % 2) ~= (b % 2) then
+ r = r + bit_val
+ end
+ a = math_floor(a / 2)
+ b = math_floor(b / 2)
+ bit_val = bit_val * 2
+ if a == 0 and b == 0 then
+ break
+ end
+ end
+ return r
+end
+
+function _compat.bnot(a)
+ return MASK32 - (math_floor(a) % 0x100000000)
+end
+
+function _compat.lshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ return math_floor((a * math_pow(2, n)) % 0x100000000)
+end
+
+function _compat.rshift(a, n)
+ if n >= 32 then
+ return 0
+ end
+ a = math_floor(a) % 0x100000000
+ return math_floor(a / math_pow(2, n))
+end
+
+function _compat.arshift(a, n)
+ a = math_floor(a) % 0x100000000
+ local is_negative = a >= 0x80000000
+ if n >= 32 then
+ return is_negative and MASK32 or 0
+ end
+ local r = math_floor(a / math_pow(2, n))
+ if is_negative then
+ local fill_mask = MASK32 - (math_pow(2, 32 - n) - 1)
+ r = _compat.bor(r, fill_mask)
+ end
+ return r
+end
+
+-- Raw operations for pure Lua fallback are identical to wrapped versions
+-- since there's no native library to bypass.
+_compat.raw_band = _compat.band
+_compat.raw_bor = _compat.bor
+_compat.raw_bxor = _compat.bxor
+_compat.raw_bnot = _compat.bnot
+_compat.raw_lshift = _compat.lshift
+_compat.raw_rshift = _compat.rshift
+_compat.raw_arshift = _compat.arshift
+_compat.raw_rol = nil
+_compat.raw_ror = nil
+
+return _compat
+end
+end
+
+do
+local _ENV = _ENV
+package.preload[ "bitn.bit16" ] = function( ... ) local arg = _G.arg;
+--- @module "bitn.bit16"
+--- 16-bit bitwise operations library.
+--- This module provides a complete, version-agnostic implementation of 16-bit
+--- bitwise operations that works across Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
+--- Uses native bit operations where available for optimal performance.
+--- @class bitn.bit16
+local bit16 = {}
+
+local _compat = require("bitn._compat")
+
+-- Cache methods as locals for faster access
+local compat_band = _compat.band
+local compat_bnot = _compat.bnot
+local compat_bor = _compat.bor
+local compat_bxor = _compat.bxor
+local compat_lshift = _compat.lshift
+local compat_rshift = _compat.rshift
+local impl_name = _compat.impl_name
+local math_floor = math.floor
+
+-- 16-bit mask constant
+local MASK16 = 0xFFFF
+
+--------------------------------------------------------------------------------
+-- Core operations
+--------------------------------------------------------------------------------
+
+--- Ensure value fits in 16-bit unsigned integer.
+--- @param n number Input value
+--- @return integer result 16-bit unsigned integer (0 to 0xFFFF)
+function bit16.mask(n)
+ return compat_band(math_floor(n), MASK16)
+end
+
+--- Bitwise AND operation.
+--- @param a integer First operand (16-bit)
+--- @param b integer Second operand (16-bit)
+--- @return integer result Result of a AND b
+function bit16.band(a, b)
+ return compat_band(compat_band(a, MASK16), compat_band(b, MASK16))
+end
+
+--- Bitwise OR operation.
+--- @param a integer First operand (16-bit)
+--- @param b integer Second operand (16-bit)
+--- @return integer result Result of a OR b
+function bit16.bor(a, b)
+ return compat_band(compat_bor(a, b), MASK16)
+end
+
+--- Bitwise XOR operation.
+--- @param a integer First operand (16-bit)
+--- @param b integer Second operand (16-bit)
+--- @return integer result Result of a XOR b
+function bit16.bxor(a, b)
+ return compat_band(compat_bxor(a, b), MASK16)
+end
+
+--- Bitwise NOT operation.
+--- @param a integer Operand (16-bit)
+--- @return integer result Result of NOT a
+function bit16.bnot(a)
+ return compat_band(compat_bnot(a), MASK16)
+end
+
+--- Left shift operation.
+--- @param a integer Value to shift (16-bit)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a << n
+function bit16.lshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ if n >= 16 then
+ return 0
+ end
+ return compat_band(compat_lshift(compat_band(a, MASK16), n), MASK16)
+end
+
+--- Logical right shift operation (fills with 0s).
+--- @param a integer Value to shift (16-bit)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a >> n (logical)
+function bit16.rshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ if n >= 16 then
+ return 0
+ end
+ return compat_rshift(compat_band(a, MASK16), n)
+end
+
+--- Arithmetic right shift operation (sign-extending, fills with sign bit).
+--- @param a integer Value to shift (16-bit, treated as signed)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a >> n with sign extension
+function bit16.arshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ a = compat_band(a, MASK16)
+
+ -- Check if sign bit is set (bit 15)
+ local is_negative = a >= 0x8000
+
+ if n >= 16 then
+ return is_negative and MASK16 or 0
+ end
+
+ -- Perform logical right shift
+ local result = compat_rshift(a, n)
+
+ -- If original was negative, fill high bits with 1s
+ if is_negative then
+ local fill_mask = compat_band(compat_lshift(MASK16, 16 - n), MASK16)
+ result = compat_bor(result, fill_mask)
+ end
+
+ return compat_band(result, MASK16)
+end
+
+--- Left rotate operation.
+--- @param x integer Value to rotate (16-bit)
+--- @param n integer Number of positions to rotate
+--- @return integer result Result of rotating x left by n positions
+function bit16.rol(x, n)
+ n = n % 16
+ x = compat_band(x, MASK16)
+ return compat_band(compat_bor(compat_lshift(x, n), compat_rshift(x, 16 - n)), MASK16)
+end
+
+--- Right rotate operation.
+--- @param x integer Value to rotate (16-bit)
+--- @param n integer Number of positions to rotate
+--- @return integer result Result of rotating x right by n positions
+function bit16.ror(x, n)
+ n = n % 16
+ x = compat_band(x, MASK16)
+ return compat_band(compat_bor(compat_rshift(x, n), compat_lshift(x, 16 - n)), MASK16)
+end
+
+--- 16-bit addition with overflow handling.
+--- @param a integer First operand (16-bit)
+--- @param b integer Second operand (16-bit)
+--- @return integer result Result of (a + b) mod 2^16
+function bit16.add(a, b)
+ return compat_band(compat_band(a, MASK16) + compat_band(b, MASK16), MASK16)
+end
+
+--------------------------------------------------------------------------------
+-- Byte conversion functions
+--------------------------------------------------------------------------------
+
+local string_char = string.char
+local string_byte = string.byte
+
+--- Convert 16-bit unsigned integer to 2 bytes (big-endian).
+--- @param n integer 16-bit unsigned integer
+--- @return string bytes 2-byte string in big-endian order
+function bit16.u16_to_be_bytes(n)
+ n = compat_band(n, MASK16)
+ return string_char(math_floor(n / 256), n % 256)
+end
+
+--- Convert 16-bit unsigned integer to 2 bytes (little-endian).
+--- @param n integer 16-bit unsigned integer
+--- @return string bytes 2-byte string in little-endian order
+function bit16.u16_to_le_bytes(n)
+ n = compat_band(n, MASK16)
+ return string_char(n % 256, math_floor(n / 256))
+end
+
+--- Convert 2 bytes to 16-bit unsigned integer (big-endian).
+--- @param str string Binary string (at least 2 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return integer n 16-bit unsigned integer
+function bit16.be_bytes_to_u16(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 1, "Insufficient bytes for u16")
+ local b1, b2 = string_byte(str, offset, offset + 1)
+ return b1 * 256 + b2
+end
+
+--- Convert 2 bytes to 16-bit unsigned integer (little-endian).
+--- @param str string Binary string (at least 2 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return integer n 16-bit unsigned integer
+function bit16.le_bytes_to_u16(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 1, "Insufficient bytes for u16")
+ local b1, b2 = string_byte(str, offset, offset + 1)
+ return b1 + b2 * 256
+end
+
+--------------------------------------------------------------------------------
+-- Self-test
+--------------------------------------------------------------------------------
+
+-- Compatibility for unpack
+local unpack_fn = unpack or table.unpack
+
+--- Run comprehensive self-test with test vectors.
+--- @return boolean result True if all tests pass, false otherwise
+function bit16.selftest()
+ print("Running 16-bit operations test vectors...")
+ print(string.format(" Using: %s", impl_name()))
+ local passed = 0
+ local total = 0
+
+ local test_vectors = {
+ -- mask tests
+ { name = "mask(0)", fn = bit16.mask, inputs = { 0 }, expected = 0 },
+ { name = "mask(1)", fn = bit16.mask, inputs = { 1 }, expected = 1 },
+ { name = "mask(0xFFFF)", fn = bit16.mask, inputs = { 0xFFFF }, expected = 0xFFFF },
+ { name = "mask(0x10000)", fn = bit16.mask, inputs = { 0x10000 }, expected = 0 },
+ { name = "mask(0x10001)", fn = bit16.mask, inputs = { 0x10001 }, expected = 1 },
+ { name = "mask(-1)", fn = bit16.mask, inputs = { -1 }, expected = 0xFFFF },
+ { name = "mask(-256)", fn = bit16.mask, inputs = { -256 }, expected = 0xFF00 },
+
+ -- band tests
+ { name = "band(0xFF00, 0x00FF)", fn = bit16.band, inputs = { 0xFF00, 0x00FF }, expected = 0 },
+ { name = "band(0xFFFF, 0xFFFF)", fn = bit16.band, inputs = { 0xFFFF, 0xFFFF }, expected = 0xFFFF },
+ { name = "band(0xAAAA, 0x5555)", fn = bit16.band, inputs = { 0xAAAA, 0x5555 }, expected = 0 },
+ { name = "band(0xF0F0, 0xFF00)", fn = bit16.band, inputs = { 0xF0F0, 0xFF00 }, expected = 0xF000 },
+
+ -- bor tests
+ { name = "bor(0xFF00, 0x00FF)", fn = bit16.bor, inputs = { 0xFF00, 0x00FF }, expected = 0xFFFF },
+ { name = "bor(0, 0)", fn = bit16.bor, inputs = { 0, 0 }, expected = 0 },
+ { name = "bor(0xAAAA, 0x5555)", fn = bit16.bor, inputs = { 0xAAAA, 0x5555 }, expected = 0xFFFF },
+
+ -- bxor tests
+ { name = "bxor(0xFF00, 0x00FF)", fn = bit16.bxor, inputs = { 0xFF00, 0x00FF }, expected = 0xFFFF },
+ { name = "bxor(0xFFFF, 0xFFFF)", fn = bit16.bxor, inputs = { 0xFFFF, 0xFFFF }, expected = 0 },
+ { name = "bxor(0xAAAA, 0x5555)", fn = bit16.bxor, inputs = { 0xAAAA, 0x5555 }, expected = 0xFFFF },
+ { name = "bxor(0x1234, 0x1234)", fn = bit16.bxor, inputs = { 0x1234, 0x1234 }, expected = 0 },
+
+ -- bnot tests
+ { name = "bnot(0)", fn = bit16.bnot, inputs = { 0 }, expected = 0xFFFF },
+ { name = "bnot(0xFFFF)", fn = bit16.bnot, inputs = { 0xFFFF }, expected = 0 },
+ { name = "bnot(0xAAAA)", fn = bit16.bnot, inputs = { 0xAAAA }, expected = 0x5555 },
+ { name = "bnot(0x1234)", fn = bit16.bnot, inputs = { 0x1234 }, expected = 0xEDCB },
+
+ -- lshift tests
+ { name = "lshift(1, 0)", fn = bit16.lshift, inputs = { 1, 0 }, expected = 1 },
+ { name = "lshift(1, 1)", fn = bit16.lshift, inputs = { 1, 1 }, expected = 2 },
+ { name = "lshift(1, 15)", fn = bit16.lshift, inputs = { 1, 15 }, expected = 0x8000 },
+ { name = "lshift(1, 16)", fn = bit16.lshift, inputs = { 1, 16 }, expected = 0 },
+ { name = "lshift(0xFF, 8)", fn = bit16.lshift, inputs = { 0xFF, 8 }, expected = 0xFF00 },
+ { name = "lshift(0x8000, 1)", fn = bit16.lshift, inputs = { 0x8000, 1 }, expected = 0 },
+
+ -- rshift tests
+ { name = "rshift(1, 0)", fn = bit16.rshift, inputs = { 1, 0 }, expected = 1 },
+ { name = "rshift(2, 1)", fn = bit16.rshift, inputs = { 2, 1 }, expected = 1 },
+ { name = "rshift(0x8000, 15)", fn = bit16.rshift, inputs = { 0x8000, 15 }, expected = 1 },
+ { name = "rshift(0x8000, 16)", fn = bit16.rshift, inputs = { 0x8000, 16 }, expected = 0 },
+ { name = "rshift(0xFF00, 8)", fn = bit16.rshift, inputs = { 0xFF00, 8 }, expected = 0xFF },
+ { name = "rshift(0xFFFF, 8)", fn = bit16.rshift, inputs = { 0xFFFF, 8 }, expected = 0xFF },
+
+ -- arshift tests (arithmetic shift - sign extending)
+ { name = "arshift(0x8000, 1)", fn = bit16.arshift, inputs = { 0x8000, 1 }, expected = 0xC000 },
+ { name = "arshift(0x8000, 15)", fn = bit16.arshift, inputs = { 0x8000, 15 }, expected = 0xFFFF },
+ { name = "arshift(0x8000, 16)", fn = bit16.arshift, inputs = { 0x8000, 16 }, expected = 0xFFFF },
+ { name = "arshift(0x7FFF, 1)", fn = bit16.arshift, inputs = { 0x7FFF, 1 }, expected = 0x3FFF },
+ { name = "arshift(0x7FFF, 15)", fn = bit16.arshift, inputs = { 0x7FFF, 15 }, expected = 0 },
+ { name = "arshift(0xFF00, 8)", fn = bit16.arshift, inputs = { 0xFF00, 8 }, expected = 0xFFFF },
+ { name = "arshift(0x0F00, 8)", fn = bit16.arshift, inputs = { 0x0F00, 8 }, expected = 0x000F },
+
+ -- rol tests
+ { name = "rol(1, 0)", fn = bit16.rol, inputs = { 1, 0 }, expected = 1 },
+ { name = "rol(1, 1)", fn = bit16.rol, inputs = { 1, 1 }, expected = 2 },
+ { name = "rol(0x8000, 1)", fn = bit16.rol, inputs = { 0x8000, 1 }, expected = 1 },
+ { name = "rol(1, 16)", fn = bit16.rol, inputs = { 1, 16 }, expected = 1 },
+ { name = "rol(0x1234, 8)", fn = bit16.rol, inputs = { 0x1234, 8 }, expected = 0x3412 },
+ { name = "rol(0x1234, 4)", fn = bit16.rol, inputs = { 0x1234, 4 }, expected = 0x2341 },
+
+ -- ror tests
+ { name = "ror(1, 0)", fn = bit16.ror, inputs = { 1, 0 }, expected = 1 },
+ { name = "ror(1, 1)", fn = bit16.ror, inputs = { 1, 1 }, expected = 0x8000 },
+ { name = "ror(2, 1)", fn = bit16.ror, inputs = { 2, 1 }, expected = 1 },
+ { name = "ror(1, 16)", fn = bit16.ror, inputs = { 1, 16 }, expected = 1 },
+ { name = "ror(0x1234, 8)", fn = bit16.ror, inputs = { 0x1234, 8 }, expected = 0x3412 },
+ { name = "ror(0x1234, 4)", fn = bit16.ror, inputs = { 0x1234, 4 }, expected = 0x4123 },
+
+ -- add tests
+ { name = "add(0, 0)", fn = bit16.add, inputs = { 0, 0 }, expected = 0 },
+ { name = "add(1, 1)", fn = bit16.add, inputs = { 1, 1 }, expected = 2 },
+ { name = "add(0xFFFF, 1)", fn = bit16.add, inputs = { 0xFFFF, 1 }, expected = 0 },
+ { name = "add(0xFFFF, 2)", fn = bit16.add, inputs = { 0xFFFF, 2 }, expected = 1 },
+ { name = "add(0x8000, 0x8000)", fn = bit16.add, inputs = { 0x8000, 0x8000 }, expected = 0 },
+
+ -- u16_to_be_bytes tests
+ { name = "u16_to_be_bytes(0)", fn = bit16.u16_to_be_bytes, inputs = { 0 }, expected = string.char(0x00, 0x00) },
+ { name = "u16_to_be_bytes(1)", fn = bit16.u16_to_be_bytes, inputs = { 1 }, expected = string.char(0x00, 0x01) },
+ {
+ name = "u16_to_be_bytes(0x1234)",
+ fn = bit16.u16_to_be_bytes,
+ inputs = { 0x1234 },
+ expected = string.char(0x12, 0x34),
+ },
+ {
+ name = "u16_to_be_bytes(0xFFFF)",
+ fn = bit16.u16_to_be_bytes,
+ inputs = { 0xFFFF },
+ expected = string.char(0xFF, 0xFF),
+ },
+
+ -- u16_to_le_bytes tests
+ { name = "u16_to_le_bytes(0)", fn = bit16.u16_to_le_bytes, inputs = { 0 }, expected = string.char(0x00, 0x00) },
+ { name = "u16_to_le_bytes(1)", fn = bit16.u16_to_le_bytes, inputs = { 1 }, expected = string.char(0x01, 0x00) },
+ {
+ name = "u16_to_le_bytes(0x1234)",
+ fn = bit16.u16_to_le_bytes,
+ inputs = { 0x1234 },
+ expected = string.char(0x34, 0x12),
+ },
+ {
+ name = "u16_to_le_bytes(0xFFFF)",
+ fn = bit16.u16_to_le_bytes,
+ inputs = { 0xFFFF },
+ expected = string.char(0xFF, 0xFF),
+ },
+
+ -- be_bytes_to_u16 tests
+ {
+ name = "be_bytes_to_u16(0x0000)",
+ fn = bit16.be_bytes_to_u16,
+ inputs = { string.char(0x00, 0x00) },
+ expected = 0,
+ },
+ {
+ name = "be_bytes_to_u16(0x0001)",
+ fn = bit16.be_bytes_to_u16,
+ inputs = { string.char(0x00, 0x01) },
+ expected = 1,
+ },
+ {
+ name = "be_bytes_to_u16(0x1234)",
+ fn = bit16.be_bytes_to_u16,
+ inputs = { string.char(0x12, 0x34) },
+ expected = 0x1234,
+ },
+ {
+ name = "be_bytes_to_u16(0xFFFF)",
+ fn = bit16.be_bytes_to_u16,
+ inputs = { string.char(0xFF, 0xFF) },
+ expected = 0xFFFF,
+ },
+
+ -- le_bytes_to_u16 tests
+ {
+ name = "le_bytes_to_u16(0x0000)",
+ fn = bit16.le_bytes_to_u16,
+ inputs = { string.char(0x00, 0x00) },
+ expected = 0,
+ },
+ {
+ name = "le_bytes_to_u16(0x0001)",
+ fn = bit16.le_bytes_to_u16,
+ inputs = { string.char(0x01, 0x00) },
+ expected = 1,
+ },
+ {
+ name = "le_bytes_to_u16(0x1234)",
+ fn = bit16.le_bytes_to_u16,
+ inputs = { string.char(0x34, 0x12) },
+ expected = 0x1234,
+ },
+ {
+ name = "le_bytes_to_u16(0xFFFF)",
+ fn = bit16.le_bytes_to_u16,
+ inputs = { string.char(0xFF, 0xFF) },
+ expected = 0xFFFF,
+ },
+ }
+
+ for _, test in ipairs(test_vectors) do
+ total = total + 1
+ local result = test.fn(unpack_fn(test.inputs))
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ if type(test.expected) == "string" then
+ if type(result) ~= "string" then
+ print(" Expected: string")
+ print(" Got: " .. type(result))
+ else
+ local exp_hex, got_hex = "", ""
+ for i = 1, #test.expected do
+ exp_hex = exp_hex .. string.format("%02X", string.byte(test.expected, i))
+ end
+ for i = 1, #result do
+ got_hex = got_hex .. string.format("%02X", string.byte(result, i))
+ end
+ print(" Expected: " .. exp_hex)
+ print(" Got: " .. got_hex)
+ end
+ else
+ print(string.format(" Expected: 0x%04X", test.expected))
+ print(string.format(" Got: 0x%04X", result))
+ end
+ end
+ end
+
+ print(string.format("\n16-bit operations: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+--------------------------------------------------------------------------------
+-- Benchmarking
+--------------------------------------------------------------------------------
+
+local benchmark_op = require("bitn.utils.benchmark").benchmark_op
+
+--- Run performance benchmarks for 16-bit operations.
+function bit16.benchmark()
+ local iterations = 100000
+
+ print("16-bit Bitwise Operations:")
+ print(string.format(" Implementation: %s", impl_name()))
+
+ -- Test values
+ local a, b = 0xAAAA, 0x5555
+
+ benchmark_op("band", function()
+ bit16.band(a, b)
+ end, iterations)
+
+ benchmark_op("bor", function()
+ bit16.bor(a, b)
+ end, iterations)
+
+ benchmark_op("bxor", function()
+ bit16.bxor(a, b)
+ end, iterations)
+
+ benchmark_op("bnot", function()
+ bit16.bnot(a)
+ end, iterations)
+
+ print("\n16-bit Shift Operations:")
+
+ benchmark_op("lshift", function()
+ bit16.lshift(a, 4)
+ end, iterations)
+
+ benchmark_op("rshift", function()
+ bit16.rshift(a, 4)
+ end, iterations)
+
+ benchmark_op("arshift", function()
+ bit16.arshift(0x8000, 4)
+ end, iterations)
+
+ print("\n16-bit Rotate Operations:")
+
+ benchmark_op("rol", function()
+ bit16.rol(a, 4)
+ end, iterations)
+
+ benchmark_op("ror", function()
+ bit16.ror(a, 4)
+ end, iterations)
+
+ print("\n16-bit Arithmetic:")
+
+ benchmark_op("add", function()
+ bit16.add(a, b)
+ end, iterations)
+
+ benchmark_op("mask", function()
+ bit16.mask(0x12345)
+ end, iterations)
+
+ print("\n16-bit Byte Conversions:")
+
+ local bytes_be = bit16.u16_to_be_bytes(0x1234)
+ local bytes_le = bit16.u16_to_le_bytes(0x1234)
+
+ benchmark_op("u16_to_be_bytes", function()
+ bit16.u16_to_be_bytes(0x1234)
+ end, iterations)
+
+ benchmark_op("u16_to_le_bytes", function()
+ bit16.u16_to_le_bytes(0x1234)
+ end, iterations)
+
+ benchmark_op("be_bytes_to_u16", function()
+ bit16.be_bytes_to_u16(bytes_be)
+ end, iterations)
+
+ benchmark_op("le_bytes_to_u16", function()
+ bit16.le_bytes_to_u16(bytes_le)
+ end, iterations)
+end
+
+return bit16
+end
+end
+
+do
+local _ENV = _ENV
+package.preload[ "bitn.bit32" ] = function( ... ) local arg = _G.arg;
+--- @module "bitn.bit32"
+--- 32-bit bitwise operations library.
+--- This module provides a complete, version-agnostic implementation of 32-bit
+--- bitwise operations that works across Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
+--- Uses native bit operations where available for optimal performance.
+--- @class bitn.bit32
+local bit32 = {}
+
+local _compat = require("bitn._compat")
+
+-- Cache methods as locals for faster access
+local compat_arshift = _compat.arshift
+local compat_band = _compat.band
+local compat_bnot = _compat.bnot
+local compat_bor = _compat.bor
+local compat_bxor = _compat.bxor
+local compat_lshift = _compat.lshift
+local compat_raw_arshift = _compat.raw_arshift
+local compat_raw_band = _compat.raw_band
+local compat_raw_bnot = _compat.raw_bnot
+local compat_raw_bor = _compat.raw_bor
+local compat_raw_bxor = _compat.raw_bxor
+local compat_raw_lshift = _compat.raw_lshift
+local compat_raw_rol = _compat.raw_rol
+local compat_raw_ror = _compat.raw_ror
+local compat_raw_rshift = _compat.raw_rshift
+local compat_rshift = _compat.rshift
+local compat_to_unsigned = _compat.to_unsigned
+local impl_name = _compat.impl_name
+local math_floor = math.floor
+
+-- 32-bit mask constant
+local MASK32 = 0xFFFFFFFF
+
+--------------------------------------------------------------------------------
+-- Core operations
+--------------------------------------------------------------------------------
+
+--- Convert signed 32-bit value to unsigned.
+--- On LuaJIT, bit operations return signed 32-bit integers. This function
+--- converts them to unsigned by adding 2^32 to negative values.
+--- @param n number Potentially signed 32-bit value
+--- @return integer result Unsigned 32-bit value (0 to 0xFFFFFFFF)
+function bit32.to_unsigned(n)
+ return compat_to_unsigned(n)
+end
+
+--- Ensure value fits in 32-bit unsigned integer.
+--- @param n number Input value
+--- @return integer result 32-bit unsigned integer (0 to 0xFFFFFFFF)
+function bit32.mask(n)
+ return compat_band(math_floor(n), MASK32)
+end
+
+--- Bitwise AND operation.
+--- @param a integer First operand (32-bit)
+--- @param b integer Second operand (32-bit)
+--- @return integer result Result of a AND b
+function bit32.band(a, b)
+ return compat_band(compat_band(a, MASK32), compat_band(b, MASK32))
+end
+
+--- Bitwise OR operation.
+--- @param a integer First operand (32-bit)
+--- @param b integer Second operand (32-bit)
+--- @return integer result Result of a OR b
+function bit32.bor(a, b)
+ return compat_band(compat_bor(a, b), MASK32)
+end
+
+--- Bitwise XOR operation.
+--- @param a integer First operand (32-bit)
+--- @param b integer Second operand (32-bit)
+--- @return integer result Result of a XOR b
+function bit32.bxor(a, b)
+ return compat_band(compat_bxor(a, b), MASK32)
+end
+
+--- Bitwise NOT operation.
+--- @param a integer Operand (32-bit)
+--- @return integer result Result of NOT a
+function bit32.bnot(a)
+ return compat_band(compat_bnot(a), MASK32)
+end
+
+--- Left shift operation.
+--- @param a integer Value to shift (32-bit)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a << n
+function bit32.lshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ if n >= 32 then
+ return 0
+ end
+ return compat_band(compat_lshift(compat_band(a, MASK32), n), MASK32)
+end
+
+--- Logical right shift operation (fills with 0s).
+--- @param a integer Value to shift (32-bit)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a >> n (logical)
+function bit32.rshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ if n >= 32 then
+ return 0
+ end
+ return compat_rshift(compat_band(a, MASK32), n)
+end
+
+--- Arithmetic right shift operation (sign-extending, fills with sign bit).
+--- @param a integer Value to shift (32-bit, treated as signed)
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return integer result Result of a >> n with sign extension
+function bit32.arshift(a, n)
+ assert(n >= 0, "Shift amount must be non-negative")
+ return compat_arshift(a, n)
+end
+
+--- Left rotate operation.
+--- @param x integer Value to rotate (32-bit)
+--- @param n integer Number of positions to rotate
+--- @return integer result Result of rotating x left by n positions
+function bit32.rol(x, n)
+ n = n % 32
+ x = compat_band(x, MASK32)
+ return compat_band(compat_bor(compat_lshift(x, n), compat_rshift(x, 32 - n)), MASK32)
+end
+
+--- Right rotate operation.
+--- @param x integer Value to rotate (32-bit)
+--- @param n integer Number of positions to rotate
+--- @return integer result Result of rotating x right by n positions
+function bit32.ror(x, n)
+ n = n % 32
+ x = compat_band(x, MASK32)
+ return compat_band(compat_bor(compat_rshift(x, n), compat_lshift(x, 32 - n)), MASK32)
+end
+
+--- 32-bit addition with overflow handling.
+--- @param a integer First operand (32-bit)
+--- @param b integer Second operand (32-bit)
+--- @return integer result Result of (a + b) mod 2^32
+function bit32.add(a, b)
+ return compat_band(compat_band(a, MASK32) + compat_band(b, MASK32), MASK32)
+end
+
+--------------------------------------------------------------------------------
+-- Raw (zero-overhead) operations
+--------------------------------------------------------------------------------
+-- These functions provide direct access to the underlying bit library without
+-- unsigned conversion. On LuaJIT, results may be negative when the high bit
+-- is set. The bit pattern is identical to the regular function.
+-- Use for performance-critical code where sign interpretation doesn't matter.
+
+--- Raw bitwise AND (may return signed on LuaJIT).
+--- @type fun(a: integer, b: integer): integer
+--- @see bit32.band For guaranteed unsigned results
+bit32.raw_band = compat_raw_band
+
+--- Raw bitwise OR (may return signed on LuaJIT).
+--- @type fun(a: integer, b: integer): integer
+--- @see bit32.bor For guaranteed unsigned results
+bit32.raw_bor = compat_raw_bor
+
+--- Raw bitwise XOR (may return signed on LuaJIT).
+--- @type fun(a: integer, b: integer): integer
+--- @see bit32.bxor For guaranteed unsigned results
+bit32.raw_bxor = compat_raw_bxor
+
+--- Raw bitwise NOT (may return signed on LuaJIT).
+--- @type fun(a: integer): integer
+--- @see bit32.bnot For guaranteed unsigned results
+bit32.raw_bnot = compat_raw_bnot
+
+--- Raw left shift (may return signed on LuaJIT).
+--- @type fun(a: integer, n: integer): integer
+--- @see bit32.lshift For guaranteed unsigned results
+bit32.raw_lshift = compat_raw_lshift
+
+--- Raw logical right shift (may return signed on LuaJIT).
+--- @type fun(a: integer, n: integer): integer
+--- @see bit32.rshift For guaranteed unsigned results
+bit32.raw_rshift = compat_raw_rshift
+
+--- Raw arithmetic right shift (may return signed on LuaJIT).
+--- @type fun(a: integer, n: integer): integer
+--- @see bit32.arshift For guaranteed unsigned results
+bit32.raw_arshift = compat_raw_arshift
+
+--- Raw left rotate (uses native bit.rol on LuaJIT, falls back to computed otherwise).
+--- @type fun(x: integer, n: integer): integer
+--- @see bit32.rol For guaranteed unsigned results
+bit32.raw_rol = compat_raw_rol or bit32.rol
+
+--- Raw right rotate (uses native bit.ror on LuaJIT, falls back to computed otherwise).
+--- @type fun(x: integer, n: integer): integer
+--- @see bit32.ror For guaranteed unsigned results
+bit32.raw_ror = compat_raw_ror or bit32.ror
+
+--- Raw 32-bit addition with overflow handling.
+--- @param a integer First operand (32-bit)
+--- @param b integer Second operand (32-bit)
+--- @return integer result Result of (a + b) mod 2^32 (signed on LuaJIT, unsigned elsewhere)
+--- @see bit32.add For guaranteed unsigned results
+function bit32.raw_add(a, b)
+ return compat_raw_band(a + b, MASK32)
+end
+
+--------------------------------------------------------------------------------
+-- Byte conversion functions
+--------------------------------------------------------------------------------
+
+local string_char = string.char
+local string_byte = string.byte
+
+--- Convert 32-bit unsigned integer to 4 bytes (big-endian).
+--- @param n integer 32-bit unsigned integer
+--- @return string bytes 4-byte string in big-endian order
+function bit32.u32_to_be_bytes(n)
+ n = compat_band(n, MASK32)
+ return string_char(
+ math_floor(n / 16777216) % 256,
+ math_floor(n / 65536) % 256,
+ math_floor(n / 256) % 256,
+ math_floor(n % 256)
+ )
+end
+
+--- Convert 32-bit unsigned integer to 4 bytes (little-endian).
+--- @param n integer 32-bit unsigned integer
+--- @return string bytes 4-byte string in little-endian order
+function bit32.u32_to_le_bytes(n)
+ n = compat_band(n, MASK32)
+ return string_char(
+ math_floor(n % 256),
+ math_floor(n / 256) % 256,
+ math_floor(n / 65536) % 256,
+ math_floor(n / 16777216) % 256
+ )
+end
+
+--- Convert 4 bytes to 32-bit unsigned integer (big-endian).
+--- @param str string Binary string (at least 4 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return integer n 32-bit unsigned integer
+function bit32.be_bytes_to_u32(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 3, "Insufficient bytes for u32")
+ local b1, b2, b3, b4 = string_byte(str, offset, offset + 3)
+ return b1 * 16777216 + b2 * 65536 + b3 * 256 + b4
+end
+
+--- Convert 4 bytes to 32-bit unsigned integer (little-endian).
+--- @param str string Binary string (at least 4 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return integer n 32-bit unsigned integer
+function bit32.le_bytes_to_u32(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 3, "Insufficient bytes for u32")
+ local b1, b2, b3, b4 = string_byte(str, offset, offset + 3)
+ return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
+end
+
+--------------------------------------------------------------------------------
+-- Self-test
+--------------------------------------------------------------------------------
+
+-- Compatibility for unpack
+local unpack_fn = unpack or table.unpack
+
+--- Run comprehensive self-test with test vectors.
+--- @return boolean result True if all tests pass, false otherwise
+function bit32.selftest()
+ print("Running 32-bit operations test vectors...")
+ print(string.format(" Using: %s", impl_name()))
+ local passed = 0
+ local total = 0
+
+ local test_vectors = {
+ -- mask tests
+ { name = "mask(0)", fn = bit32.mask, inputs = { 0 }, expected = 0 },
+ { name = "mask(1)", fn = bit32.mask, inputs = { 1 }, expected = 1 },
+ { name = "mask(0xFFFFFFFF)", fn = bit32.mask, inputs = { 0xFFFFFFFF }, expected = 0xFFFFFFFF },
+ { name = "mask(0x100000000)", fn = bit32.mask, inputs = { 0x100000000 }, expected = 0 },
+ { name = "mask(0x100000001)", fn = bit32.mask, inputs = { 0x100000001 }, expected = 1 },
+ { name = "mask(-1)", fn = bit32.mask, inputs = { -1 }, expected = 0xFFFFFFFF },
+ { name = "mask(-256)", fn = bit32.mask, inputs = { -256 }, expected = 0xFFFFFF00 },
+
+ -- to_unsigned tests
+ { name = "to_unsigned(0)", fn = bit32.to_unsigned, inputs = { 0 }, expected = 0 },
+ { name = "to_unsigned(1)", fn = bit32.to_unsigned, inputs = { 1 }, expected = 1 },
+ { name = "to_unsigned(0x7FFFFFFF)", fn = bit32.to_unsigned, inputs = { 0x7FFFFFFF }, expected = 0x7FFFFFFF },
+ { name = "to_unsigned(-1)", fn = bit32.to_unsigned, inputs = { -1 }, expected = 0xFFFFFFFF },
+ { name = "to_unsigned(-2147483648)", fn = bit32.to_unsigned, inputs = { -2147483648 }, expected = 0x80000000 },
+ { name = "to_unsigned(-2147483647)", fn = bit32.to_unsigned, inputs = { -2147483647 }, expected = 0x80000001 },
+
+ -- band tests
+ { name = "band(0xFF00FF00, 0x00FF00FF)", fn = bit32.band, inputs = { 0xFF00FF00, 0x00FF00FF }, expected = 0 },
+ {
+ name = "band(0xFFFFFFFF, 0xFFFFFFFF)",
+ fn = bit32.band,
+ inputs = { 0xFFFFFFFF, 0xFFFFFFFF },
+ expected = 0xFFFFFFFF,
+ },
+ { name = "band(0xAAAAAAAA, 0x55555555)", fn = bit32.band, inputs = { 0xAAAAAAAA, 0x55555555 }, expected = 0 },
+ {
+ name = "band(0xF0F0F0F0, 0xFF00FF00)",
+ fn = bit32.band,
+ inputs = { 0xF0F0F0F0, 0xFF00FF00 },
+ expected = 0xF000F000,
+ },
+ { name = "band(0, 0xFFFFFFFF)", fn = bit32.band, inputs = { 0, 0xFFFFFFFF }, expected = 0 },
+
+ -- bor tests
+ {
+ name = "bor(0xFF00FF00, 0x00FF00FF)",
+ fn = bit32.bor,
+ inputs = { 0xFF00FF00, 0x00FF00FF },
+ expected = 0xFFFFFFFF,
+ },
+ { name = "bor(0, 0)", fn = bit32.bor, inputs = { 0, 0 }, expected = 0 },
+ {
+ name = "bor(0xAAAAAAAA, 0x55555555)",
+ fn = bit32.bor,
+ inputs = { 0xAAAAAAAA, 0x55555555 },
+ expected = 0xFFFFFFFF,
+ },
+ {
+ name = "bor(0xF0F0F0F0, 0x0F0F0F0F)",
+ fn = bit32.bor,
+ inputs = { 0xF0F0F0F0, 0x0F0F0F0F },
+ expected = 0xFFFFFFFF,
+ },
+
+ -- bxor tests
+ {
+ name = "bxor(0xFF00FF00, 0x00FF00FF)",
+ fn = bit32.bxor,
+ inputs = { 0xFF00FF00, 0x00FF00FF },
+ expected = 0xFFFFFFFF,
+ },
+ { name = "bxor(0xFFFFFFFF, 0xFFFFFFFF)", fn = bit32.bxor, inputs = { 0xFFFFFFFF, 0xFFFFFFFF }, expected = 0 },
+ {
+ name = "bxor(0xAAAAAAAA, 0x55555555)",
+ fn = bit32.bxor,
+ inputs = { 0xAAAAAAAA, 0x55555555 },
+ expected = 0xFFFFFFFF,
+ },
+ { name = "bxor(0x12345678, 0x12345678)", fn = bit32.bxor, inputs = { 0x12345678, 0x12345678 }, expected = 0 },
+
+ -- bnot tests
+ { name = "bnot(0)", fn = bit32.bnot, inputs = { 0 }, expected = 0xFFFFFFFF },
+ { name = "bnot(0xFFFFFFFF)", fn = bit32.bnot, inputs = { 0xFFFFFFFF }, expected = 0 },
+ { name = "bnot(0xAAAAAAAA)", fn = bit32.bnot, inputs = { 0xAAAAAAAA }, expected = 0x55555555 },
+ { name = "bnot(0x12345678)", fn = bit32.bnot, inputs = { 0x12345678 }, expected = 0xEDCBA987 },
+
+ -- lshift tests
+ { name = "lshift(1, 0)", fn = bit32.lshift, inputs = { 1, 0 }, expected = 1 },
+ { name = "lshift(1, 1)", fn = bit32.lshift, inputs = { 1, 1 }, expected = 2 },
+ { name = "lshift(1, 31)", fn = bit32.lshift, inputs = { 1, 31 }, expected = 0x80000000 },
+ { name = "lshift(1, 32)", fn = bit32.lshift, inputs = { 1, 32 }, expected = 0 },
+ { name = "lshift(0xFF, 8)", fn = bit32.lshift, inputs = { 0xFF, 8 }, expected = 0xFF00 },
+ { name = "lshift(0x80000000, 1)", fn = bit32.lshift, inputs = { 0x80000000, 1 }, expected = 0 },
+
+ -- rshift tests
+ { name = "rshift(1, 0)", fn = bit32.rshift, inputs = { 1, 0 }, expected = 1 },
+ { name = "rshift(2, 1)", fn = bit32.rshift, inputs = { 2, 1 }, expected = 1 },
+ { name = "rshift(0x80000000, 31)", fn = bit32.rshift, inputs = { 0x80000000, 31 }, expected = 1 },
+ { name = "rshift(0x80000000, 32)", fn = bit32.rshift, inputs = { 0x80000000, 32 }, expected = 0 },
+ { name = "rshift(0xFF00, 8)", fn = bit32.rshift, inputs = { 0xFF00, 8 }, expected = 0xFF },
+ { name = "rshift(0xFFFFFFFF, 16)", fn = bit32.rshift, inputs = { 0xFFFFFFFF, 16 }, expected = 0xFFFF },
+
+ -- arshift tests (arithmetic shift - sign extending)
+ { name = "arshift(0x80000000, 1)", fn = bit32.arshift, inputs = { 0x80000000, 1 }, expected = 0xC0000000 },
+ { name = "arshift(0x80000000, 31)", fn = bit32.arshift, inputs = { 0x80000000, 31 }, expected = 0xFFFFFFFF },
+ { name = "arshift(0x80000000, 32)", fn = bit32.arshift, inputs = { 0x80000000, 32 }, expected = 0xFFFFFFFF },
+ { name = "arshift(0x7FFFFFFF, 1)", fn = bit32.arshift, inputs = { 0x7FFFFFFF, 1 }, expected = 0x3FFFFFFF },
+ { name = "arshift(0x7FFFFFFF, 31)", fn = bit32.arshift, inputs = { 0x7FFFFFFF, 31 }, expected = 0 },
+ { name = "arshift(0xFF000000, 8)", fn = bit32.arshift, inputs = { 0xFF000000, 8 }, expected = 0xFFFF0000 },
+ { name = "arshift(0x0F000000, 8)", fn = bit32.arshift, inputs = { 0x0F000000, 8 }, expected = 0x000F0000 },
+
+ -- rol tests
+ { name = "rol(1, 0)", fn = bit32.rol, inputs = { 1, 0 }, expected = 1 },
+ { name = "rol(1, 1)", fn = bit32.rol, inputs = { 1, 1 }, expected = 2 },
+ { name = "rol(0x80000000, 1)", fn = bit32.rol, inputs = { 0x80000000, 1 }, expected = 1 },
+ { name = "rol(1, 32)", fn = bit32.rol, inputs = { 1, 32 }, expected = 1 },
+ { name = "rol(0x12345678, 8)", fn = bit32.rol, inputs = { 0x12345678, 8 }, expected = 0x34567812 },
+ { name = "rol(0x12345678, 16)", fn = bit32.rol, inputs = { 0x12345678, 16 }, expected = 0x56781234 },
+
+ -- ror tests
+ { name = "ror(1, 0)", fn = bit32.ror, inputs = { 1, 0 }, expected = 1 },
+ { name = "ror(1, 1)", fn = bit32.ror, inputs = { 1, 1 }, expected = 0x80000000 },
+ { name = "ror(2, 1)", fn = bit32.ror, inputs = { 2, 1 }, expected = 1 },
+ { name = "ror(1, 32)", fn = bit32.ror, inputs = { 1, 32 }, expected = 1 },
+ { name = "ror(0x12345678, 8)", fn = bit32.ror, inputs = { 0x12345678, 8 }, expected = 0x78123456 },
+ { name = "ror(0x12345678, 16)", fn = bit32.ror, inputs = { 0x12345678, 16 }, expected = 0x56781234 },
+
+ -- add tests
+ { name = "add(0, 0)", fn = bit32.add, inputs = { 0, 0 }, expected = 0 },
+ { name = "add(1, 1)", fn = bit32.add, inputs = { 1, 1 }, expected = 2 },
+ { name = "add(0xFFFFFFFF, 1)", fn = bit32.add, inputs = { 0xFFFFFFFF, 1 }, expected = 0 },
+ { name = "add(0xFFFFFFFF, 2)", fn = bit32.add, inputs = { 0xFFFFFFFF, 2 }, expected = 1 },
+ { name = "add(0x80000000, 0x80000000)", fn = bit32.add, inputs = { 0x80000000, 0x80000000 }, expected = 0 },
+
+ -- u32_to_be_bytes tests
+ {
+ name = "u32_to_be_bytes(0)",
+ fn = bit32.u32_to_be_bytes,
+ inputs = { 0 },
+ expected = string_char(0x00, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u32_to_be_bytes(1)",
+ fn = bit32.u32_to_be_bytes,
+ inputs = { 1 },
+ expected = string_char(0x00, 0x00, 0x00, 0x01),
+ },
+ {
+ name = "u32_to_be_bytes(0x12345678)",
+ fn = bit32.u32_to_be_bytes,
+ inputs = { 0x12345678 },
+ expected = string_char(0x12, 0x34, 0x56, 0x78),
+ },
+ {
+ name = "u32_to_be_bytes(0xFFFFFFFF)",
+ fn = bit32.u32_to_be_bytes,
+ inputs = { 0xFFFFFFFF },
+ expected = string_char(0xFF, 0xFF, 0xFF, 0xFF),
+ },
+
+ -- u32_to_le_bytes tests
+ {
+ name = "u32_to_le_bytes(0)",
+ fn = bit32.u32_to_le_bytes,
+ inputs = { 0 },
+ expected = string_char(0x00, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u32_to_le_bytes(1)",
+ fn = bit32.u32_to_le_bytes,
+ inputs = { 1 },
+ expected = string_char(0x01, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u32_to_le_bytes(0x12345678)",
+ fn = bit32.u32_to_le_bytes,
+ inputs = { 0x12345678 },
+ expected = string_char(0x78, 0x56, 0x34, 0x12),
+ },
+ {
+ name = "u32_to_le_bytes(0xFFFFFFFF)",
+ fn = bit32.u32_to_le_bytes,
+ inputs = { 0xFFFFFFFF },
+ expected = string_char(0xFF, 0xFF, 0xFF, 0xFF),
+ },
+
+ -- be_bytes_to_u32 tests
+ {
+ name = "be_bytes_to_u32(0x00000000)",
+ fn = bit32.be_bytes_to_u32,
+ inputs = { string_char(0x00, 0x00, 0x00, 0x00) },
+ expected = 0,
+ },
+ {
+ name = "be_bytes_to_u32(0x00000001)",
+ fn = bit32.be_bytes_to_u32,
+ inputs = { string_char(0x00, 0x00, 0x00, 0x01) },
+ expected = 1,
+ },
+ {
+ name = "be_bytes_to_u32(0x12345678)",
+ fn = bit32.be_bytes_to_u32,
+ inputs = { string_char(0x12, 0x34, 0x56, 0x78) },
+ expected = 0x12345678,
+ },
+ {
+ name = "be_bytes_to_u32(0xFFFFFFFF)",
+ fn = bit32.be_bytes_to_u32,
+ inputs = { string_char(0xFF, 0xFF, 0xFF, 0xFF) },
+ expected = 0xFFFFFFFF,
+ },
+
+ -- le_bytes_to_u32 tests
+ {
+ name = "le_bytes_to_u32(0x00000000)",
+ fn = bit32.le_bytes_to_u32,
+ inputs = { string_char(0x00, 0x00, 0x00, 0x00) },
+ expected = 0,
+ },
+ {
+ name = "le_bytes_to_u32(0x00000001)",
+ fn = bit32.le_bytes_to_u32,
+ inputs = { string_char(0x01, 0x00, 0x00, 0x00) },
+ expected = 1,
+ },
+ {
+ name = "le_bytes_to_u32(0x12345678)",
+ fn = bit32.le_bytes_to_u32,
+ inputs = { string_char(0x78, 0x56, 0x34, 0x12) },
+ expected = 0x12345678,
+ },
+ {
+ name = "le_bytes_to_u32(0xFFFFFFFF)",
+ fn = bit32.le_bytes_to_u32,
+ inputs = { string_char(0xFF, 0xFF, 0xFF, 0xFF) },
+ expected = 0xFFFFFFFF,
+ },
+ }
+
+ for _, test in ipairs(test_vectors) do
+ total = total + 1
+ local result = test.fn(unpack_fn(test.inputs))
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ if type(test.expected) == "string" then
+ local exp_hex, got_hex = "", ""
+ for i = 1, #test.expected do
+ exp_hex = exp_hex .. string.format("%02X", string_byte(test.expected, i))
+ end
+ for i = 1, #result do
+ got_hex = got_hex .. string.format("%02X", string_byte(result, i))
+ end
+ print(" Expected: " .. exp_hex)
+ print(" Got: " .. got_hex)
+ else
+ print(string.format(" Expected: 0x%08X", test.expected))
+ print(string.format(" Got: 0x%08X", result))
+ end
+ end
+ end
+
+ -- Test raw_* operations
+ print("\n Testing raw_* operations...")
+
+ local raw_tests = {
+ -- Core bitwise (test high-bit cases where sign matters)
+ {
+ name = "raw_band(0xFFFFFFFF, 0x80000000)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_band(0xFFFFFFFF, 0x80000000))
+ end,
+ expected = bit32.band(0xFFFFFFFF, 0x80000000),
+ },
+ {
+ name = "raw_bor(0x80000000, 0x00000001)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_bor(0x80000000, 0x00000001))
+ end,
+ expected = bit32.bor(0x80000000, 0x00000001),
+ },
+ {
+ name = "raw_bxor(0xAAAAAAAA, 0x55555555)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_bxor(0xAAAAAAAA, 0x55555555))
+ end,
+ expected = bit32.bxor(0xAAAAAAAA, 0x55555555),
+ },
+ {
+ name = "raw_bnot(0)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_bnot(0))
+ end,
+ expected = bit32.bnot(0),
+ },
+ {
+ name = "raw_bnot(0x80000000)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_bnot(0x80000000))
+ end,
+ expected = bit32.bnot(0x80000000),
+ },
+
+ -- Shifts
+ {
+ name = "raw_lshift(1, 31)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_lshift(1, 31))
+ end,
+ expected = bit32.lshift(1, 31),
+ },
+ {
+ name = "raw_rshift(0x80000000, 1)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_rshift(0x80000000, 1))
+ end,
+ expected = bit32.rshift(0x80000000, 1),
+ },
+ {
+ name = "raw_arshift(0x80000000, 1)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_arshift(0x80000000, 1))
+ end,
+ expected = bit32.arshift(0x80000000, 1),
+ },
+
+ -- Shift masking (ensure 32-bit semantics on all platforms)
+ -- Note: n >= 32 behavior is platform-specific for raw shifts; callers should use n in 0-31
+ {
+ name = "raw_lshift(0x12345678, 16) masks to 32 bits",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_lshift(0x12345678, 16))
+ end,
+ expected = 0x56780000,
+ },
+ {
+ name = "raw_rshift(0xFFFFFFFF, 16) masks to 32 bits",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_rshift(0xFFFFFFFF, 16))
+ end,
+ expected = 0x0000FFFF,
+ },
+
+ -- Addition overflow
+ {
+ name = "raw_add(0xFFFFFFFF, 1)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_add(0xFFFFFFFF, 1))
+ end,
+ expected = bit32.add(0xFFFFFFFF, 1),
+ },
+ {
+ name = "raw_add(0x80000000, 0x80000000)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_add(0x80000000, 0x80000000))
+ end,
+ expected = bit32.add(0x80000000, 0x80000000),
+ },
+ }
+
+ for _, test in ipairs(raw_tests) do
+ total = total + 1
+ local result = test.fn()
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(string.format(" Expected: 0x%08X", test.expected))
+ print(string.format(" Got: 0x%08X", result))
+ end
+ end
+
+ -- Test raw_rol/raw_ror (always available - falls back to computed if no native)
+ print("\n Testing raw_rol/raw_ror...")
+ local rol_ror_tests = {
+ {
+ name = "raw_rol(0x80000000, 1)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_rol(0x80000000, 1))
+ end,
+ expected = bit32.rol(0x80000000, 1),
+ },
+ {
+ name = "raw_rol(0x12345678, 8)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_rol(0x12345678, 8))
+ end,
+ expected = bit32.rol(0x12345678, 8),
+ },
+ {
+ name = "raw_ror(1, 1)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_ror(1, 1))
+ end,
+ expected = bit32.ror(1, 1),
+ },
+ {
+ name = "raw_ror(0x12345678, 8)",
+ fn = function()
+ return bit32.to_unsigned(bit32.raw_ror(0x12345678, 8))
+ end,
+ expected = bit32.ror(0x12345678, 8),
+ },
+ }
+
+ for _, test in ipairs(rol_ror_tests) do
+ total = total + 1
+ local result = test.fn()
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(string.format(" Expected: 0x%08X", test.expected))
+ print(string.format(" Got: 0x%08X", result))
+ end
+ end
+
+ -- Test zero-overhead on LuaJIT (identity check)
+ if _compat.is_luajit then
+ print("\n Testing zero-overhead (LuaJIT function identity)...")
+ local bit = require("bit")
+
+ local identity_tests = {
+ { name = "raw_band == bit.band", got = bit32.raw_band, expected = bit.band },
+ { name = "raw_bor == bit.bor", got = bit32.raw_bor, expected = bit.bor },
+ { name = "raw_bxor == bit.bxor", got = bit32.raw_bxor, expected = bit.bxor },
+ { name = "raw_bnot == bit.bnot", got = bit32.raw_bnot, expected = bit.bnot },
+ { name = "raw_lshift == bit.lshift", got = bit32.raw_lshift, expected = bit.lshift },
+ { name = "raw_rshift == bit.rshift", got = bit32.raw_rshift, expected = bit.rshift },
+ { name = "raw_arshift == bit.arshift", got = bit32.raw_arshift, expected = bit.arshift },
+ { name = "raw_rol == bit.rol", got = bit32.raw_rol, expected = bit.rol },
+ { name = "raw_ror == bit.ror", got = bit32.raw_ror, expected = bit.ror },
+ }
+
+ for _, test in ipairs(identity_tests) do
+ total = total + 1
+ if rawequal(test.got, test.expected) then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name .. " (not identical function reference)")
+ end
+ end
+ end
+
+ print(string.format("\n32-bit operations: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+--------------------------------------------------------------------------------
+-- Benchmarking
+--------------------------------------------------------------------------------
+
+local benchmark_op = require("bitn.utils.benchmark").benchmark_op
+
+--- Run performance benchmarks for 32-bit operations.
+function bit32.benchmark()
+ local iterations = 100000
+
+ print("32-bit Bitwise Operations:")
+ print(string.format(" Implementation: %s", impl_name()))
+
+ -- Test values
+ local a, b = 0xAAAAAAAA, 0x55555555
+
+ benchmark_op("band", function()
+ bit32.band(a, b)
+ end, iterations)
+
+ benchmark_op("bor", function()
+ bit32.bor(a, b)
+ end, iterations)
+
+ benchmark_op("bxor", function()
+ bit32.bxor(a, b)
+ end, iterations)
+
+ benchmark_op("bnot", function()
+ bit32.bnot(a)
+ end, iterations)
+
+ print("\n32-bit Shift Operations:")
+
+ benchmark_op("lshift", function()
+ bit32.lshift(a, 8)
+ end, iterations)
+
+ benchmark_op("rshift", function()
+ bit32.rshift(a, 8)
+ end, iterations)
+
+ benchmark_op("arshift", function()
+ bit32.arshift(0x80000000, 8)
+ end, iterations)
+
+ print("\n32-bit Rotate Operations:")
+
+ benchmark_op("rol", function()
+ bit32.rol(a, 8)
+ end, iterations)
+
+ benchmark_op("ror", function()
+ bit32.ror(a, 8)
+ end, iterations)
+
+ print("\n32-bit Arithmetic:")
+
+ benchmark_op("add", function()
+ bit32.add(a, b)
+ end, iterations)
+
+ benchmark_op("mask", function()
+ bit32.mask(0x123456789)
+ end, iterations)
+
+ print("\n32-bit Byte Conversions:")
+
+ local bytes_be = bit32.u32_to_be_bytes(0x12345678)
+ local bytes_le = bit32.u32_to_le_bytes(0x12345678)
+
+ benchmark_op("u32_to_be_bytes", function()
+ bit32.u32_to_be_bytes(0x12345678)
+ end, iterations)
+
+ benchmark_op("u32_to_le_bytes", function()
+ bit32.u32_to_le_bytes(0x12345678)
+ end, iterations)
+
+ benchmark_op("be_bytes_to_u32", function()
+ bit32.be_bytes_to_u32(bytes_be)
+ end, iterations)
+
+ benchmark_op("le_bytes_to_u32", function()
+ bit32.le_bytes_to_u32(bytes_le)
+ end, iterations)
+end
+
+return bit32
+end
+end
+
+do
+local _ENV = _ENV
+package.preload[ "bitn.bit64" ] = function( ... ) local arg = _G.arg;
+--- @module "bitn.bit64"
+--- 64-bit bitwise operations library.
+--- This module provides 64-bit bitwise operations using {high, low} pairs,
+--- where high is the upper 32 bits and low is the lower 32 bits.
+--- Works across Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
+--- Uses native bit operations where available for optimal performance.
+--- @class bitn.bit64
+local bit64 = {}
+
+local bit32 = require("bitn.bit32")
+local _compat = require("bitn._compat")
+
+-- Cache methods as locals for faster access
+local bit32_arshift = bit32.arshift
+local bit32_band = bit32.band
+local bit32_be_bytes_to_u32 = bit32.be_bytes_to_u32
+local bit32_bnot = bit32.bnot
+local bit32_bor = bit32.bor
+local bit32_bxor = bit32.bxor
+local bit32_le_bytes_to_u32 = bit32.le_bytes_to_u32
+local bit32_lshift = bit32.lshift
+local bit32_raw_arshift = bit32.raw_arshift
+local bit32_raw_band = bit32.raw_band
+local bit32_raw_bnot = bit32.raw_bnot
+local bit32_raw_bor = bit32.raw_bor
+local bit32_raw_bxor = bit32.raw_bxor
+local bit32_raw_lshift = bit32.raw_lshift
+local bit32_raw_rshift = bit32.raw_rshift
+local bit32_rshift = bit32.rshift
+local bit32_u32_to_be_bytes = bit32.u32_to_be_bytes
+local bit32_u32_to_le_bytes = bit32.u32_to_le_bytes
+local impl_name = _compat.impl_name
+
+-- Private metatable for Int64 type identification
+local Int64Meta = { __name = "Int64" }
+
+-- Type definitions
+--- @alias Int64HighLow [integer, integer] Array with [1]=high 32 bits, [2]=low 32 bits
+
+--------------------------------------------------------------------------------
+-- Constructor and type checking
+--------------------------------------------------------------------------------
+
+--- Create a new Int64 value with metatable marker.
+--- Normalizes signed 32-bit values to unsigned (for LuaJIT raw_* compatibility).
+--- @param high? integer Upper 32 bits (default: 0)
+--- @param low? integer Lower 32 bits (default: 0)
+--- @return Int64HighLow value Int64 value with metatable marker
+function bit64.new(high, low)
+ high = high or 0
+ low = low or 0
+ -- Normalize signed to unsigned (handles LuaJIT raw_* results)
+ if high < 0 then
+ high = high + 0x100000000
+ end
+ if low < 0 then
+ low = low + 0x100000000
+ end
+ return setmetatable({ high, low }, Int64Meta)
+end
+
+--- Check if a value is an Int64 (created by bit64 functions).
+--- @param value any Value to check
+--- @return boolean is_int64 True if value is an Int64
+function bit64.is_int64(value)
+ return type(value) == "table" and getmetatable(value) == Int64Meta
+end
+
+--------------------------------------------------------------------------------
+-- Bitwise operations
+--------------------------------------------------------------------------------
+
+--- Bitwise AND operation.
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} AND result
+function bit64.band(a, b)
+ return bit64.new(bit32_band(a[1], b[1]), bit32_band(a[2], b[2]))
+end
+
+--- Bitwise OR operation.
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} OR result
+function bit64.bor(a, b)
+ return bit64.new(bit32_bor(a[1], b[1]), bit32_bor(a[2], b[2]))
+end
+
+--- Bitwise XOR operation.
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} XOR result
+function bit64.bxor(a, b)
+ return bit64.new(bit32_bxor(a[1], b[1]), bit32_bxor(a[2], b[2]))
+end
+
+--- Bitwise NOT operation.
+--- @param a Int64HighLow Operand {high, low}
+--- @return Int64HighLow result {high, low} NOT result
+function bit64.bnot(a)
+ return bit64.new(bit32_bnot(a[1]), bit32_bnot(a[2]))
+end
+
+--------------------------------------------------------------------------------
+-- Shift operations
+--------------------------------------------------------------------------------
+
+--- Left shift operation.
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.lshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ elseif n >= 64 then
+ return bit64.new(0, 0)
+ elseif n >= 32 then
+ -- Shift by 32 or more: low becomes 0, high gets bits from low
+ return bit64.new(bit32_lshift(x[2], n - 32), 0)
+ else
+ -- Shift by less than 32
+ local new_high = bit32_bor(bit32_lshift(x[1], n), bit32_rshift(x[2], 32 - n))
+ local new_low = bit32_lshift(x[2], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Logical right shift operation (fills with 0s).
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.rshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ elseif n >= 64 then
+ return bit64.new(0, 0)
+ elseif n >= 32 then
+ -- Shift by 32 or more: high becomes 0, low gets bits from high
+ return bit64.new(0, bit32_rshift(x[1], n - 32))
+ else
+ -- Shift by less than 32
+ local new_low = bit32_bor(bit32_rshift(x[2], n), bit32_lshift(x[1], 32 - n))
+ local new_high = bit32_rshift(x[1], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Arithmetic right shift operation (sign-extending, fills with sign bit).
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.arshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ -- Check sign bit (bit 31 of high word)
+ local is_negative = bit32_band(x[1], 0x80000000) ~= 0
+
+ if n >= 64 then
+ -- All bits shift out, result is all 1s if negative, all 0s if positive
+ if is_negative then
+ return bit64.new(0xFFFFFFFF, 0xFFFFFFFF)
+ else
+ return bit64.new(0, 0)
+ end
+ elseif n >= 32 then
+ -- High word shifts into low, high fills with sign
+ local new_low = bit32_arshift(x[1], n - 32)
+ local new_high = is_negative and 0xFFFFFFFF or 0
+ return bit64.new(new_high, new_low)
+ else
+ -- Shift by less than 32
+ local new_low = bit32_bor(bit32_rshift(x[2], n), bit32_lshift(x[1], 32 - n))
+ local new_high = bit32_arshift(x[1], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Rotate operations
+--------------------------------------------------------------------------------
+
+--- Left rotate operation.
+--- @param x Int64HighLow Value to rotate {high, low}
+--- @param n integer Number of positions to rotate
+--- @return Int64HighLow result {high, low} rotated value
+function bit64.rol(x, n)
+ n = n % 64
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ local high, low = x[1], x[2]
+
+ if n == 32 then
+ -- Special case: swap high and low
+ return bit64.new(low, high)
+ elseif n < 32 then
+ -- Rotate within 32-bit boundaries
+ local new_high = bit32_bor(bit32_lshift(high, n), bit32_rshift(low, 32 - n))
+ local new_low = bit32_bor(bit32_lshift(low, n), bit32_rshift(high, 32 - n))
+ return bit64.new(new_high, new_low)
+ else
+ -- n > 32: rotate by (n - 32) after swapping
+ n = n - 32
+ local new_high = bit32_bor(bit32_lshift(low, n), bit32_rshift(high, 32 - n))
+ local new_low = bit32_bor(bit32_lshift(high, n), bit32_rshift(low, 32 - n))
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Right rotate operation.
+--- @param x Int64HighLow Value to rotate {high, low}
+--- @param n integer Number of positions to rotate
+--- @return Int64HighLow result {high, low} rotated value
+function bit64.ror(x, n)
+ n = n % 64
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ local high, low = x[1], x[2]
+
+ if n == 32 then
+ -- Special case: swap high and low
+ return bit64.new(low, high)
+ elseif n < 32 then
+ -- Rotate within 32-bit boundaries
+ local new_low = bit32_bor(bit32_rshift(low, n), bit32_lshift(high, 32 - n))
+ local new_high = bit32_bor(bit32_rshift(high, n), bit32_lshift(low, 32 - n))
+ return bit64.new(new_high, new_low)
+ else
+ -- n > 32: rotate by (n - 32) after swapping
+ n = n - 32
+ local new_low = bit32_bor(bit32_rshift(high, n), bit32_lshift(low, 32 - n))
+ local new_high = bit32_bor(bit32_rshift(low, n), bit32_lshift(high, 32 - n))
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Arithmetic operations
+--------------------------------------------------------------------------------
+
+--- 64-bit addition with overflow handling.
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} sum
+function bit64.add(a, b)
+ local low = a[2] + b[2]
+ local high = a[1] + b[1]
+
+ -- Handle carry from low to high
+ if low >= 0x100000000 then
+ high = high + 1
+ low = low % 0x100000000
+ end
+
+ -- Keep high within 32 bits
+ high = high % 0x100000000
+
+ return bit64.new(high, low)
+end
+
+--------------------------------------------------------------------------------
+-- Byte conversion functions
+--------------------------------------------------------------------------------
+
+--- Convert 64-bit value to 8 bytes (big-endian).
+--- @param x Int64HighLow 64-bit value {high, low}
+--- @return string bytes 8-byte string in big-endian order
+function bit64.u64_to_be_bytes(x)
+ return bit32_u32_to_be_bytes(x[1]) .. bit32_u32_to_be_bytes(x[2])
+end
+
+--- Convert 64-bit value to 8 bytes (little-endian).
+--- @param x Int64HighLow 64-bit value {high, low}
+--- @return string bytes 8-byte string in little-endian order
+function bit64.u64_to_le_bytes(x)
+ return bit32_u32_to_le_bytes(x[2]) .. bit32_u32_to_le_bytes(x[1])
+end
+
+--- Convert 8 bytes to 64-bit value (big-endian).
+--- @param str string Binary string (at least 8 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return Int64HighLow value {high, low} 64-bit value
+function bit64.be_bytes_to_u64(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 7, "Insufficient bytes for u64")
+ local high = bit32_be_bytes_to_u32(str, offset)
+ local low = bit32_be_bytes_to_u32(str, offset + 4)
+ return bit64.new(high, low)
+end
+
+--- Convert 8 bytes to 64-bit value (little-endian).
+--- @param str string Binary string (at least 8 bytes from offset)
+--- @param offset? integer Starting position (default: 1)
+--- @return Int64HighLow value {high, low} 64-bit value
+function bit64.le_bytes_to_u64(str, offset)
+ offset = offset or 1
+ assert(#str >= offset + 7, "Insufficient bytes for u64")
+ local low = bit32_le_bytes_to_u32(str, offset)
+ local high = bit32_le_bytes_to_u32(str, offset + 4)
+ return bit64.new(high, low)
+end
+
+--------------------------------------------------------------------------------
+-- Utility functions
+--------------------------------------------------------------------------------
+
+--- Converts a {high, low} pair to a 16-character hexadecimal string.
+--- @param value Int64HighLow The {high_32, low_32} pair.
+--- @return string hex The hexadecimal string (e.g., "0000180000001000").
+function bit64.to_hex(value)
+ return string.format("%08X%08X", value[1], value[2])
+end
+
+--- Converts a {high, low} pair to a Lua number.
+--- Warning: Lua numbers use 64-bit IEEE 754 doubles with 53-bit mantissa precision.
+--- Values exceeding 53 bits (greater than 9007199254740991) will lose precision.
+--- To maintain full 64-bit precision, keep values in {high, low} format.
+--- @param value number|integer|Int64HighLow The {high_32, low_32} pair (or number to pass through).
+--- @param strict? boolean If true, errors when value exceeds 53-bit precision.
+--- @return number|integer result The value as a Lua number (may lose precision for large values unless strict).
+--- @overload fun(value: number, strict?: boolean): number
+--- @overload fun(value: integer, strict?: boolean): integer
+--- @overload fun(value: Int64HighLow, strict?: boolean): integer
+--- @overload fun(value: number): number
+--- @overload fun(value: integer): integer
+--- @overload fun(value: Int64HighLow): integer
+function bit64.to_number(value, strict)
+ if type(value) == "number" then
+ return value
+ end
+ if not bit64.is_int64(value) then
+ error("Value is not a valid Int64HighLow pair", 2)
+ end
+ if strict and value[1] > 0x001FFFFF then
+ error("Value exceeds 53-bit precision (max: 9007199254740991)", 2)
+ end
+ return value[1] * 0x100000000 + value[2]
+end
+
+--- Creates a {high, low} pair from a Lua number.
+--- @param value number|Int64HighLow The number to convert (or Int64HighLow to pass through).
+--- @return Int64HighLow pair The {high_32, low_32} pair.
+function bit64.from_number(value)
+ if bit64.is_int64(value) then
+ --- @cast value Int64HighLow
+ return value
+ end
+ --- @cast value -Int64HighLow
+ local low = math.floor(value % 0x100000000)
+ local high = math.floor(value / 0x100000000)
+ return bit64.new(high, low)
+end
+
+--- Checks if two {high, low} pairs are equal.
+--- @param a Int64HighLow The first {high_32, low_32} pair.
+--- @param b Int64HighLow The second {high_32, low_32} pair.
+--- @return boolean equal True if the values are equal.
+function bit64.eq(a, b)
+ return a[1] == b[1] and a[2] == b[2]
+end
+
+--- Checks if a {high, low} pair is zero.
+--- @param value Int64HighLow The {high_32, low_32} pair.
+--- @return boolean is_zero True if the value is zero.
+function bit64.is_zero(value)
+ return value[1] == 0 and value[2] == 0
+end
+
+--------------------------------------------------------------------------------
+-- Aliases for compatibility
+--------------------------------------------------------------------------------
+
+--- Alias for bxor (compatibility with older API).
+bit64.xor = bit64.bxor
+
+--- Alias for rshift (compatibility with older API).
+bit64.shr = bit64.rshift
+
+--- Alias for lshift (compatibility with older API).
+bit64.lsl = bit64.lshift
+
+--- Alias for arshift (compatibility with older API).
+bit64.asr = bit64.arshift
+
+--- Alias for is_int64 (compatibility with older API).
+bit64.isInt64 = bit64.is_int64
+
+--------------------------------------------------------------------------------
+-- Raw (zero-overhead) operations
+--------------------------------------------------------------------------------
+-- These functions use bit32.raw_* internally for performance-critical code.
+-- On LuaJIT, the internal 32-bit values may be signed, but bit patterns are correct.
+-- Use for crypto code and tight loops where sign interpretation doesn't matter.
+
+--- Raw bitwise AND (uses bit32.raw_band internally).
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} AND result
+function bit64.raw_band(a, b)
+ return bit64.new(bit32_raw_band(a[1], b[1]), bit32_raw_band(a[2], b[2]))
+end
+
+--- Raw bitwise OR (uses bit32.raw_bor internally).
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} OR result
+function bit64.raw_bor(a, b)
+ return bit64.new(bit32_raw_bor(a[1], b[1]), bit32_raw_bor(a[2], b[2]))
+end
+
+--- Raw bitwise XOR (uses bit32.raw_bxor internally).
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} XOR result
+function bit64.raw_bxor(a, b)
+ return bit64.new(bit32_raw_bxor(a[1], b[1]), bit32_raw_bxor(a[2], b[2]))
+end
+
+--- Raw bitwise NOT (uses bit32.raw_bnot internally).
+--- @param a Int64HighLow Operand {high, low}
+--- @return Int64HighLow result {high, low} NOT result
+function bit64.raw_bnot(a)
+ return bit64.new(bit32_raw_bnot(a[1]), bit32_raw_bnot(a[2]))
+end
+
+--- Raw left shift (uses bit32.raw_* internally).
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.raw_lshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ elseif n >= 64 then
+ return bit64.new(0, 0)
+ elseif n >= 32 then
+ return bit64.new(bit32_raw_lshift(x[2], n - 32), 0)
+ else
+ local new_high = bit32_raw_bor(bit32_raw_lshift(x[1], n), bit32_raw_rshift(x[2], 32 - n))
+ local new_low = bit32_raw_lshift(x[2], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Raw logical right shift (uses bit32.raw_* internally).
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.raw_rshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ elseif n >= 64 then
+ return bit64.new(0, 0)
+ elseif n >= 32 then
+ return bit64.new(0, bit32_raw_rshift(x[1], n - 32))
+ else
+ local new_low = bit32_raw_bor(bit32_raw_rshift(x[2], n), bit32_raw_lshift(x[1], 32 - n))
+ local new_high = bit32_raw_rshift(x[1], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Raw arithmetic right shift (uses bit32.raw_* internally).
+--- @param x Int64HighLow Value to shift {high, low}
+--- @param n integer Number of positions to shift (must be >= 0)
+--- @return Int64HighLow result {high, low} shifted value
+function bit64.raw_arshift(x, n)
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ local is_negative = bit32_raw_band(x[1], 0x80000000) ~= 0
+
+ if n >= 64 then
+ if is_negative then
+ return bit64.new(0xFFFFFFFF, 0xFFFFFFFF)
+ else
+ return bit64.new(0, 0)
+ end
+ elseif n >= 32 then
+ local new_low = bit32_raw_arshift(x[1], n - 32)
+ local new_high = is_negative and 0xFFFFFFFF or 0
+ return bit64.new(new_high, new_low)
+ else
+ local new_low = bit32_raw_bor(bit32_raw_rshift(x[2], n), bit32_raw_lshift(x[1], 32 - n))
+ local new_high = bit32_raw_arshift(x[1], n)
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Raw left rotate (uses bit32.raw_* internally).
+--- @param x Int64HighLow Value to rotate {high, low}
+--- @param n integer Number of positions to rotate
+--- @return Int64HighLow result {high, low} rotated value
+function bit64.raw_rol(x, n)
+ n = n % 64
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ local high, low = x[1], x[2]
+
+ if n == 32 then
+ return bit64.new(low, high)
+ elseif n < 32 then
+ local new_high = bit32_raw_bor(bit32_raw_lshift(high, n), bit32_raw_rshift(low, 32 - n))
+ local new_low = bit32_raw_bor(bit32_raw_lshift(low, n), bit32_raw_rshift(high, 32 - n))
+ return bit64.new(new_high, new_low)
+ else
+ n = n - 32
+ local new_high = bit32_raw_bor(bit32_raw_lshift(low, n), bit32_raw_rshift(high, 32 - n))
+ local new_low = bit32_raw_bor(bit32_raw_lshift(high, n), bit32_raw_rshift(low, 32 - n))
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Raw right rotate (uses bit32.raw_* internally).
+--- @param x Int64HighLow Value to rotate {high, low}
+--- @param n integer Number of positions to rotate
+--- @return Int64HighLow result {high, low} rotated value
+function bit64.raw_ror(x, n)
+ n = n % 64
+ if n == 0 then
+ return bit64.new(x[1], x[2])
+ end
+
+ local high, low = x[1], x[2]
+
+ if n == 32 then
+ return bit64.new(low, high)
+ elseif n < 32 then
+ local new_low = bit32_raw_bor(bit32_raw_rshift(low, n), bit32_raw_lshift(high, 32 - n))
+ local new_high = bit32_raw_bor(bit32_raw_rshift(high, n), bit32_raw_lshift(low, 32 - n))
+ return bit64.new(new_high, new_low)
+ else
+ n = n - 32
+ local new_low = bit32_raw_bor(bit32_raw_rshift(high, n), bit32_raw_lshift(low, 32 - n))
+ local new_high = bit32_raw_bor(bit32_raw_rshift(low, n), bit32_raw_lshift(high, 32 - n))
+ return bit64.new(new_high, new_low)
+ end
+end
+
+--- Raw 64-bit addition (uses bit32.raw_band for masking).
+--- @param a Int64HighLow First operand {high, low}
+--- @param b Int64HighLow Second operand {high, low}
+--- @return Int64HighLow result {high, low} sum
+function bit64.raw_add(a, b)
+ local low = a[2] + b[2]
+ local high = a[1] + b[1]
+
+ if low >= 0x100000000 then
+ high = high + 1
+ low = low % 0x100000000
+ end
+
+ high = high % 0x100000000
+
+ return bit64.new(high, low)
+end
+
+--------------------------------------------------------------------------------
+-- Self-test
+--------------------------------------------------------------------------------
+
+-- Compatibility for unpack
+local unpack_fn = unpack or table.unpack
+
+--- Compare two 64-bit values (high/low pairs).
+--- @param a Int64HighLow First value {high, low}
+--- @param b Int64HighLow Second value {high, low}
+--- @return boolean equal True if equal
+local function eq64(a, b)
+ return a[1] == b[1] and a[2] == b[2]
+end
+
+--- Format 64-bit value as hex string.
+--- @param x Int64HighLow Value {high, low}
+--- @return string formatted Hex string
+local function fmt64(x)
+ return string.format("{0x%08X, 0x%08X}", x[1], x[2])
+end
+
+--- Run comprehensive self-test with test vectors.
+--- @return boolean result True if all tests pass, false otherwise
+function bit64.selftest()
+ print("Running 64-bit operations test vectors...")
+ print(string.format(" Using: %s", impl_name()))
+ local passed = 0
+ local total = 0
+
+ local test_vectors = {
+ -- band tests
+ {
+ name = "band({0xFFFFFFFF, 0}, {0, 0xFFFFFFFF})",
+ fn = bit64.band,
+ inputs = { { 0xFFFFFFFF, 0 }, { 0, 0xFFFFFFFF } },
+ expected = { 0, 0 },
+ },
+ {
+ name = "band({0xFFFFFFFF, 0xFFFFFFFF}, {0xFFFFFFFF, 0xFFFFFFFF})",
+ fn = bit64.band,
+ inputs = { { 0xFFFFFFFF, 0xFFFFFFFF }, { 0xFFFFFFFF, 0xFFFFFFFF } },
+ expected = { 0xFFFFFFFF, 0xFFFFFFFF },
+ },
+ {
+ name = "band({0xAAAAAAAA, 0x55555555}, {0x55555555, 0xAAAAAAAA})",
+ fn = bit64.band,
+ inputs = { { 0xAAAAAAAA, 0x55555555 }, { 0x55555555, 0xAAAAAAAA } },
+ expected = { 0, 0 },
+ },
+
+ -- bor tests
+ {
+ name = "bor({0xFFFF0000, 0}, {0, 0x0000FFFF})",
+ fn = bit64.bor,
+ inputs = { { 0xFFFF0000, 0 }, { 0, 0x0000FFFF } },
+ expected = { 0xFFFF0000, 0x0000FFFF },
+ },
+ { name = "bor({0, 0}, {0, 0})", fn = bit64.bor, inputs = { { 0, 0 }, { 0, 0 } }, expected = { 0, 0 } },
+ {
+ name = "bor({0xAAAAAAAA, 0x55555555}, {0x55555555, 0xAAAAAAAA})",
+ fn = bit64.bor,
+ inputs = { { 0xAAAAAAAA, 0x55555555 }, { 0x55555555, 0xAAAAAAAA } },
+ expected = { 0xFFFFFFFF, 0xFFFFFFFF },
+ },
+
+ -- bxor tests
+ {
+ name = "bxor({0xFFFFFFFF, 0}, {0, 0xFFFFFFFF})",
+ fn = bit64.bxor,
+ inputs = { { 0xFFFFFFFF, 0 }, { 0, 0xFFFFFFFF } },
+ expected = { 0xFFFFFFFF, 0xFFFFFFFF },
+ },
+ {
+ name = "bxor({0x12345678, 0x9ABCDEF0}, {0x12345678, 0x9ABCDEF0})",
+ fn = bit64.bxor,
+ inputs = { { 0x12345678, 0x9ABCDEF0 }, { 0x12345678, 0x9ABCDEF0 } },
+ expected = { 0, 0 },
+ },
+
+ -- bnot tests
+ { name = "bnot({0, 0})", fn = bit64.bnot, inputs = { { 0, 0 } }, expected = { 0xFFFFFFFF, 0xFFFFFFFF } },
+ {
+ name = "bnot({0xFFFFFFFF, 0xFFFFFFFF})",
+ fn = bit64.bnot,
+ inputs = { { 0xFFFFFFFF, 0xFFFFFFFF } },
+ expected = { 0, 0 },
+ },
+ {
+ name = "bnot({0xAAAAAAAA, 0x55555555})",
+ fn = bit64.bnot,
+ inputs = { { 0xAAAAAAAA, 0x55555555 } },
+ expected = { 0x55555555, 0xAAAAAAAA },
+ },
+
+ -- lshift tests
+ { name = "lshift({0, 1}, 0)", fn = bit64.lshift, inputs = { { 0, 1 }, 0 }, expected = { 0, 1 } },
+ { name = "lshift({0, 1}, 1)", fn = bit64.lshift, inputs = { { 0, 1 }, 1 }, expected = { 0, 2 } },
+ { name = "lshift({0, 1}, 32)", fn = bit64.lshift, inputs = { { 0, 1 }, 32 }, expected = { 1, 0 } },
+ { name = "lshift({0, 1}, 63)", fn = bit64.lshift, inputs = { { 0, 1 }, 63 }, expected = { 0x80000000, 0 } },
+ { name = "lshift({0, 1}, 64)", fn = bit64.lshift, inputs = { { 0, 1 }, 64 }, expected = { 0, 0 } },
+ {
+ name = "lshift({0, 0xFFFFFFFF}, 8)",
+ fn = bit64.lshift,
+ inputs = { { 0, 0xFFFFFFFF }, 8 },
+ expected = { 0xFF, 0xFFFFFF00 },
+ },
+
+ -- rshift tests
+ { name = "rshift({0, 1}, 0)", fn = bit64.rshift, inputs = { { 0, 1 }, 0 }, expected = { 0, 1 } },
+ { name = "rshift({0, 2}, 1)", fn = bit64.rshift, inputs = { { 0, 2 }, 1 }, expected = { 0, 1 } },
+ { name = "rshift({1, 0}, 32)", fn = bit64.rshift, inputs = { { 1, 0 }, 32 }, expected = { 0, 1 } },
+ {
+ name = "rshift({0x80000000, 0}, 63)",
+ fn = bit64.rshift,
+ inputs = { { 0x80000000, 0 }, 63 },
+ expected = { 0, 1 },
+ },
+ { name = "rshift({1, 0}, 64)", fn = bit64.rshift, inputs = { { 1, 0 }, 64 }, expected = { 0, 0 } },
+ {
+ name = "rshift({0xFF000000, 0}, 8)",
+ fn = bit64.rshift,
+ inputs = { { 0xFF000000, 0 }, 8 },
+ expected = { 0x00FF0000, 0 },
+ },
+
+ -- arshift tests (sign-extending)
+ {
+ name = "arshift({0x80000000, 0}, 1)",
+ fn = bit64.arshift,
+ inputs = { { 0x80000000, 0 }, 1 },
+ expected = { 0xC0000000, 0 },
+ },
+ {
+ name = "arshift({0x80000000, 0}, 32)",
+ fn = bit64.arshift,
+ inputs = { { 0x80000000, 0 }, 32 },
+ expected = { 0xFFFFFFFF, 0x80000000 },
+ },
+ {
+ name = "arshift({0x80000000, 0}, 63)",
+ fn = bit64.arshift,
+ inputs = { { 0x80000000, 0 }, 63 },
+ expected = { 0xFFFFFFFF, 0xFFFFFFFF },
+ },
+ {
+ name = "arshift({0x80000000, 0}, 64)",
+ fn = bit64.arshift,
+ inputs = { { 0x80000000, 0 }, 64 },
+ expected = { 0xFFFFFFFF, 0xFFFFFFFF },
+ },
+ {
+ name = "arshift({0x7FFFFFFF, 0xFFFFFFFF}, 1)",
+ fn = bit64.arshift,
+ inputs = { { 0x7FFFFFFF, 0xFFFFFFFF }, 1 },
+ expected = { 0x3FFFFFFF, 0xFFFFFFFF },
+ },
+ {
+ name = "arshift({0x7FFFFFFF, 0}, 63)",
+ fn = bit64.arshift,
+ inputs = { { 0x7FFFFFFF, 0 }, 63 },
+ expected = { 0, 0 },
+ },
+
+ -- rol tests
+ { name = "rol({0, 1}, 0)", fn = bit64.rol, inputs = { { 0, 1 }, 0 }, expected = { 0, 1 } },
+ { name = "rol({0, 1}, 1)", fn = bit64.rol, inputs = { { 0, 1 }, 1 }, expected = { 0, 2 } },
+ { name = "rol({0x80000000, 0}, 1)", fn = bit64.rol, inputs = { { 0x80000000, 0 }, 1 }, expected = { 0, 1 } },
+ { name = "rol({0, 1}, 32)", fn = bit64.rol, inputs = { { 0, 1 }, 32 }, expected = { 1, 0 } },
+ { name = "rol({0, 1}, 64)", fn = bit64.rol, inputs = { { 0, 1 }, 64 }, expected = { 0, 1 } },
+ {
+ name = "rol({0x12345678, 0x9ABCDEF0}, 16)",
+ fn = bit64.rol,
+ inputs = { { 0x12345678, 0x9ABCDEF0 }, 16 },
+ expected = { 0x56789ABC, 0xDEF01234 },
+ },
+
+ -- ror tests
+ { name = "ror({0, 1}, 0)", fn = bit64.ror, inputs = { { 0, 1 }, 0 }, expected = { 0, 1 } },
+ { name = "ror({0, 1}, 1)", fn = bit64.ror, inputs = { { 0, 1 }, 1 }, expected = { 0x80000000, 0 } },
+ { name = "ror({0, 2}, 1)", fn = bit64.ror, inputs = { { 0, 2 }, 1 }, expected = { 0, 1 } },
+ { name = "ror({1, 0}, 32)", fn = bit64.ror, inputs = { { 1, 0 }, 32 }, expected = { 0, 1 } },
+ { name = "ror({0, 1}, 64)", fn = bit64.ror, inputs = { { 0, 1 }, 64 }, expected = { 0, 1 } },
+ {
+ name = "ror({0x12345678, 0x9ABCDEF0}, 16)",
+ fn = bit64.ror,
+ inputs = { { 0x12345678, 0x9ABCDEF0 }, 16 },
+ expected = { 0xDEF01234, 0x56789ABC },
+ },
+
+ -- add tests
+ { name = "add({0, 0}, {0, 0})", fn = bit64.add, inputs = { { 0, 0 }, { 0, 0 } }, expected = { 0, 0 } },
+ { name = "add({0, 1}, {0, 1})", fn = bit64.add, inputs = { { 0, 1 }, { 0, 1 } }, expected = { 0, 2 } },
+ {
+ name = "add({0, 0xFFFFFFFF}, {0, 1})",
+ fn = bit64.add,
+ inputs = { { 0, 0xFFFFFFFF }, { 0, 1 } },
+ expected = { 1, 0 },
+ },
+ {
+ name = "add({0xFFFFFFFF, 0xFFFFFFFF}, {0, 1})",
+ fn = bit64.add,
+ inputs = { { 0xFFFFFFFF, 0xFFFFFFFF }, { 0, 1 } },
+ expected = { 0, 0 },
+ },
+ {
+ name = "add({0xFFFFFFFF, 0xFFFFFFFF}, {0, 2})",
+ fn = bit64.add,
+ inputs = { { 0xFFFFFFFF, 0xFFFFFFFF }, { 0, 2 } },
+ expected = { 0, 1 },
+ },
+
+ -- u64_to_be_bytes tests
+ {
+ name = "u64_to_be_bytes({0, 0})",
+ fn = bit64.u64_to_be_bytes,
+ inputs = { { 0, 0 } },
+ expected = string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u64_to_be_bytes({0, 1})",
+ fn = bit64.u64_to_be_bytes,
+ inputs = { { 0, 1 } },
+ expected = string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01),
+ },
+ {
+ name = "u64_to_be_bytes({0x12345678, 0x9ABCDEF0})",
+ fn = bit64.u64_to_be_bytes,
+ inputs = { { 0x12345678, 0x9ABCDEF0 } },
+ expected = string.char(0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0),
+ },
+
+ -- u64_to_le_bytes tests
+ {
+ name = "u64_to_le_bytes({0, 0})",
+ fn = bit64.u64_to_le_bytes,
+ inputs = { { 0, 0 } },
+ expected = string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u64_to_le_bytes({0, 1})",
+ fn = bit64.u64_to_le_bytes,
+ inputs = { { 0, 1 } },
+ expected = string.char(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
+ },
+ {
+ name = "u64_to_le_bytes({0x12345678, 0x9ABCDEF0})",
+ fn = bit64.u64_to_le_bytes,
+ inputs = { { 0x12345678, 0x9ABCDEF0 } },
+ expected = string.char(0xF0, 0xDE, 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12),
+ },
+
+ -- be_bytes_to_u64 tests
+ {
+ name = "be_bytes_to_u64(zeros)",
+ fn = bit64.be_bytes_to_u64,
+ inputs = { string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) },
+ expected = { 0, 0 },
+ },
+ {
+ name = "be_bytes_to_u64(one)",
+ fn = bit64.be_bytes_to_u64,
+ inputs = { string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01) },
+ expected = { 0, 1 },
+ },
+ {
+ name = "be_bytes_to_u64(0x123456789ABCDEF0)",
+ fn = bit64.be_bytes_to_u64,
+ inputs = { string.char(0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0) },
+ expected = { 0x12345678, 0x9ABCDEF0 },
+ },
+
+ -- le_bytes_to_u64 tests
+ {
+ name = "le_bytes_to_u64(zeros)",
+ fn = bit64.le_bytes_to_u64,
+ inputs = { string.char(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) },
+ expected = { 0, 0 },
+ },
+ {
+ name = "le_bytes_to_u64(one)",
+ fn = bit64.le_bytes_to_u64,
+ inputs = { string.char(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) },
+ expected = { 0, 1 },
+ },
+ {
+ name = "le_bytes_to_u64(0x123456789ABCDEF0)",
+ fn = bit64.le_bytes_to_u64,
+ inputs = { string.char(0xF0, 0xDE, 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12) },
+ expected = { 0x12345678, 0x9ABCDEF0 },
+ },
+
+ -- to_hex tests
+ {
+ name = "to_hex({0x00001800, 0x00001000})",
+ fn = bit64.to_hex,
+ inputs = { { 0x00001800, 0x00001000 } },
+ expected = "0000180000001000",
+ },
+ {
+ name = "to_hex({0xFFFFFFFF, 0xFFFFFFFF})",
+ fn = bit64.to_hex,
+ inputs = { { 0xFFFFFFFF, 0xFFFFFFFF } },
+ expected = "FFFFFFFFFFFFFFFF",
+ },
+ {
+ name = "to_hex({0x00000000, 0x00000000})",
+ fn = bit64.to_hex,
+ inputs = { { 0x00000000, 0x00000000 } },
+ expected = "0000000000000000",
+ },
+
+ -- to_number tests
+ {
+ name = "to_number({0x00000000, 0x00000001})",
+ fn = bit64.to_number,
+ inputs = { bit64.new(0x00000000, 0x00000001) },
+ expected = 1,
+ },
+ {
+ name = "to_number({0x00000000, 0xFFFFFFFF})",
+ fn = bit64.to_number,
+ inputs = { bit64.new(0x00000000, 0xFFFFFFFF) },
+ expected = 4294967295,
+ },
+ {
+ name = "to_number({0x00000001, 0x00000000})",
+ fn = bit64.to_number,
+ inputs = { bit64.new(0x00000001, 0x00000000) },
+ expected = 4294967296,
+ },
+
+ -- from_number tests
+ {
+ name = "from_number(1)",
+ fn = bit64.from_number,
+ inputs = { 1 },
+ expected = { 0x00000000, 0x00000001 },
+ },
+ {
+ name = "from_number(4294967296)",
+ fn = bit64.from_number,
+ inputs = { 4294967296 },
+ expected = { 0x00000001, 0x00000000 },
+ },
+ {
+ name = "from_number(0)",
+ fn = bit64.from_number,
+ inputs = { 0 },
+ expected = { 0x00000000, 0x00000000 },
+ },
+
+ -- eq tests
+ { name = "eq({1,2}, {1,2})", fn = bit64.eq, inputs = { { 1, 2 }, { 1, 2 } }, expected = true },
+ { name = "eq({1,2}, {1,3})", fn = bit64.eq, inputs = { { 1, 2 }, { 1, 3 } }, expected = false },
+ { name = "eq({1,2}, {2,2})", fn = bit64.eq, inputs = { { 1, 2 }, { 2, 2 } }, expected = false },
+
+ -- is_zero tests
+ { name = "is_zero({0,0})", fn = bit64.is_zero, inputs = { { 0, 0 } }, expected = true },
+ { name = "is_zero({0,1})", fn = bit64.is_zero, inputs = { { 0, 1 } }, expected = false },
+ { name = "is_zero({1,0})", fn = bit64.is_zero, inputs = { { 1, 0 } }, expected = false },
+
+ -- to_number strict mode tests (values within 53-bit range)
+ {
+ name = "to_number({0x001FFFFF, 0xFFFFFFFF}, true) -- max 53-bit",
+ fn = bit64.to_number,
+ inputs = { bit64.new(0x001FFFFF, 0xFFFFFFFF), true },
+ expected = 9007199254740991,
+ },
+ {
+ name = "to_number({0, 1}, true)",
+ fn = bit64.to_number,
+ inputs = { bit64.new(0, 1), true },
+ expected = 1,
+ },
+ }
+
+ for _, test in ipairs(test_vectors) do
+ total = total + 1
+ local result = test.fn(unpack_fn(test.inputs))
+
+ if type(test.expected) == "table" then
+ -- 64-bit comparison
+ if eq64(result, test.expected) then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(" Expected: " .. fmt64(test.expected))
+ print(" Got: " .. fmt64(result))
+ end
+ elseif type(test.expected) == "string" then
+ -- Byte string comparison
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ if type(result) ~= "string" then
+ print(" Expected: string")
+ print(" Got: " .. type(result))
+ else
+ local exp_hex, got_hex = "", ""
+ for i = 1, #test.expected do
+ exp_hex = exp_hex .. string.format("%02X", string.byte(test.expected, i))
+ end
+ for i = 1, #result do
+ got_hex = got_hex .. string.format("%02X", string.byte(result, i))
+ end
+ print(" Expected: " .. exp_hex)
+ print(" Got: " .. got_hex)
+ end
+ end
+ else
+ if result == test.expected then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(" Expected: " .. tostring(test.expected))
+ print(" Got: " .. tostring(result))
+ end
+ end
+ end
+
+ -- Int64 type identification tests
+ print("\nRunning Int64 type identification tests...")
+
+ -- Test bit64.new() creates Int64 values
+ total = total + 1
+ local new_val = bit64.new(0x12345678, 0x9ABCDEF0)
+ if bit64.is_int64(new_val) and new_val[1] == 0x12345678 and new_val[2] == 0x9ABCDEF0 then
+ print(" PASS: new() creates Int64 with correct values")
+ passed = passed + 1
+ else
+ print(" FAIL: new() creates Int64 with correct values")
+ end
+
+ -- Test bit64.new() with defaults
+ total = total + 1
+ local zero_val = bit64.new()
+ if bit64.is_int64(zero_val) and zero_val[1] == 0 and zero_val[2] == 0 then
+ print(" PASS: new() with no args creates {0, 0}")
+ passed = passed + 1
+ else
+ print(" FAIL: new() with no args creates {0, 0}")
+ end
+
+ -- Test bit64.new() normalizes negative values (LuaJIT raw_* compatibility)
+ total = total + 1
+ local neg_val = bit64.new(-1, -2147483648) -- -1 -> 0xFFFFFFFF, -2147483648 -> 0x80000000
+ if bit64.is_int64(neg_val) and neg_val[1] == 0xFFFFFFFF and neg_val[2] == 0x80000000 then
+ print(" PASS: new() normalizes negative values to unsigned")
+ passed = passed + 1
+ else
+ print(" FAIL: new() normalizes negative values to unsigned")
+ print(string.format(" Expected: {0x%08X, 0x%08X}", 0xFFFFFFFF, 0x80000000))
+ print(string.format(" Got: {0x%08X, 0x%08X}", neg_val[1], neg_val[2]))
+ end
+
+ -- Test is_int64() returns false for regular tables
+ total = total + 1
+ local plain_table = { 0x12345678, 0x9ABCDEF0 }
+ if not bit64.is_int64(plain_table) then
+ print(" PASS: is_int64() returns false for plain table")
+ passed = passed + 1
+ else
+ print(" FAIL: is_int64() returns false for plain table")
+ end
+
+ -- Test is_int64() returns false for non-tables
+ total = total + 1
+ if not bit64.is_int64(123) and not bit64.is_int64("string") and not bit64.is_int64(nil) then
+ print(" PASS: is_int64() returns false for non-tables")
+ passed = passed + 1
+ else
+ print(" FAIL: is_int64() returns false for non-tables")
+ end
+
+ -- Test all operations return Int64 values
+ local ops_returning_int64 = {
+ {
+ name = "band",
+ fn = function()
+ return bit64.band(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "bor",
+ fn = function()
+ return bit64.bor(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "bxor",
+ fn = function()
+ return bit64.bxor(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "bnot",
+ fn = function()
+ return bit64.bnot(bit64.new(1, 2))
+ end,
+ },
+ {
+ name = "lshift",
+ fn = function()
+ return bit64.lshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "rshift",
+ fn = function()
+ return bit64.rshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "arshift",
+ fn = function()
+ return bit64.arshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "rol",
+ fn = function()
+ return bit64.rol(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "ror",
+ fn = function()
+ return bit64.ror(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "add",
+ fn = function()
+ return bit64.add(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "be_bytes_to_u64",
+ fn = function()
+ return bit64.be_bytes_to_u64("\0\0\0\1\0\0\0\2")
+ end,
+ },
+ {
+ name = "le_bytes_to_u64",
+ fn = function()
+ return bit64.le_bytes_to_u64("\2\0\0\0\1\0\0\0")
+ end,
+ },
+ }
+
+ for _, op in ipairs(ops_returning_int64) do
+ total = total + 1
+ local result = op.fn()
+ if bit64.is_int64(result) then
+ print(" PASS: " .. op.name .. "() returns Int64")
+ passed = passed + 1
+ else
+ print(" FAIL: " .. op.name .. "() returns Int64")
+ end
+ end
+
+ -- Test to_number strict mode error case
+ print("\nRunning to_number/from_number edge case tests...")
+ total = total + 1
+ local ok, err = pcall(function()
+ bit64.to_number(bit64.new(0x00200000, 0x00000000), true) -- 2^53, exceeds 53-bit
+ end)
+ if not ok and type(err) == "string" and string.find(err, "53%-bit precision") then
+ print(" PASS: to_number strict mode errors on values > 53 bits")
+ passed = passed + 1
+ else
+ print(" FAIL: to_number strict mode errors on values > 53 bits")
+ if ok then
+ print(" Expected error but got success")
+ else
+ print(" Expected '53-bit precision' error but got: " .. tostring(err))
+ end
+ end
+
+ -- Test to_number pass-through for number input
+ total = total + 1
+ local num_input = 12345
+ local num_result = bit64.to_number(num_input)
+ if num_result == num_input then
+ print(" PASS: to_number passes through number input unchanged")
+ passed = passed + 1
+ else
+ print(" FAIL: to_number passes through number input unchanged")
+ print(" Expected: " .. tostring(num_input))
+ print(" Got: " .. tostring(num_result))
+ end
+
+ -- Test to_number errors on plain table (non-Int64)
+ total = total + 1
+ ok, err = pcall(function()
+ bit64.to_number({ 1, 2 }) -- plain table, not Int64
+ end)
+ if not ok and type(err) == "string" and string.find(err, "not a valid Int64") then
+ print(" PASS: to_number errors on plain table (non-Int64)")
+ passed = passed + 1
+ else
+ print(" FAIL: to_number errors on plain table (non-Int64)")
+ if ok then
+ print(" Expected error but got success")
+ else
+ print(" Expected 'not a valid Int64' error but got: " .. tostring(err))
+ end
+ end
+
+ -- Test from_number pass-through for Int64 input
+ total = total + 1
+ local int64_input = bit64.new(0x12345678, 0x9ABCDEF0)
+ local int64_result = bit64.from_number(int64_input)
+ if rawequal(int64_result, int64_input) then
+ print(" PASS: from_number passes through Int64 input unchanged")
+ passed = passed + 1
+ else
+ print(" FAIL: from_number passes through Int64 input unchanged")
+ print(" Expected same reference, got different object")
+ end
+
+ -- Test raw_* operations
+ print("\n Testing raw_* operations...")
+
+ local raw_tests = {
+ -- Core bitwise (test high-bit cases where sign matters)
+ {
+ name = "raw_band(new(0xFFFFFFFF, 0x80000000), new(0x80000000, 0xFFFFFFFF))",
+ fn = function()
+ return bit64.raw_band(bit64.new(0xFFFFFFFF, 0x80000000), bit64.new(0x80000000, 0xFFFFFFFF))
+ end,
+ expected = bit64.new(0x80000000, 0x80000000),
+ },
+ {
+ name = "raw_bor(new(0x80000000, 0), new(0, 0x80000000))",
+ fn = function()
+ return bit64.raw_bor(bit64.new(0x80000000, 0), bit64.new(0, 0x80000000))
+ end,
+ expected = bit64.new(0x80000000, 0x80000000),
+ },
+ {
+ name = "raw_bxor(new(0xAAAAAAAA, 0x55555555), new(0x55555555, 0xAAAAAAAA))",
+ fn = function()
+ return bit64.raw_bxor(bit64.new(0xAAAAAAAA, 0x55555555), bit64.new(0x55555555, 0xAAAAAAAA))
+ end,
+ expected = bit64.new(0xFFFFFFFF, 0xFFFFFFFF),
+ },
+ {
+ name = "raw_bnot(new(0, 0))",
+ fn = function()
+ return bit64.raw_bnot(bit64.new(0, 0))
+ end,
+ expected = bit64.new(0xFFFFFFFF, 0xFFFFFFFF),
+ },
+
+ -- Shifts
+ {
+ name = "raw_lshift(new(0, 1), 63)",
+ fn = function()
+ return bit64.raw_lshift(bit64.new(0, 1), 63)
+ end,
+ expected = bit64.new(0x80000000, 0),
+ },
+ {
+ name = "raw_rshift(new(0x80000000, 0), 63)",
+ fn = function()
+ return bit64.raw_rshift(bit64.new(0x80000000, 0), 63)
+ end,
+ expected = bit64.new(0, 1),
+ },
+ {
+ name = "raw_arshift(new(0x80000000, 0), 32)",
+ fn = function()
+ return bit64.raw_arshift(bit64.new(0x80000000, 0), 32)
+ end,
+ expected = bit64.new(0xFFFFFFFF, 0x80000000),
+ },
+
+ -- Rotates
+ {
+ name = "raw_rol(new(0x12345678, 0x9ABCDEF0), 16)",
+ fn = function()
+ return bit64.raw_rol(bit64.new(0x12345678, 0x9ABCDEF0), 16)
+ end,
+ expected = bit64.new(0x56789ABC, 0xDEF01234),
+ },
+ {
+ name = "raw_ror(new(0x12345678, 0x9ABCDEF0), 16)",
+ fn = function()
+ return bit64.raw_ror(bit64.new(0x12345678, 0x9ABCDEF0), 16)
+ end,
+ expected = bit64.new(0xDEF01234, 0x56789ABC),
+ },
+
+ -- Addition
+ {
+ name = "raw_add(new(0xFFFFFFFF, 0xFFFFFFFF), new(0, 1))",
+ fn = function()
+ return bit64.raw_add(bit64.new(0xFFFFFFFF, 0xFFFFFFFF), bit64.new(0, 1))
+ end,
+ expected = bit64.new(0, 0),
+ },
+ }
+
+ for _, test in ipairs(raw_tests) do
+ total = total + 1
+ local result = test.fn()
+ if eq64(result, test.expected) then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(" Expected: " .. fmt64(test.expected))
+ print(" Got: " .. fmt64(result))
+ end
+ end
+
+ -- Test that raw_* operations return Int64
+ print("\n Testing raw_* operations return Int64...")
+ local raw_ops_returning_int64 = {
+ {
+ name = "raw_band",
+ fn = function()
+ return bit64.raw_band(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "raw_bor",
+ fn = function()
+ return bit64.raw_bor(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "raw_bxor",
+ fn = function()
+ return bit64.raw_bxor(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ {
+ name = "raw_bnot",
+ fn = function()
+ return bit64.raw_bnot(bit64.new(1, 2))
+ end,
+ },
+ {
+ name = "raw_lshift",
+ fn = function()
+ return bit64.raw_lshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "raw_rshift",
+ fn = function()
+ return bit64.raw_rshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "raw_arshift",
+ fn = function()
+ return bit64.raw_arshift(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "raw_rol",
+ fn = function()
+ return bit64.raw_rol(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "raw_ror",
+ fn = function()
+ return bit64.raw_ror(bit64.new(1, 2), 1)
+ end,
+ },
+ {
+ name = "raw_add",
+ fn = function()
+ return bit64.raw_add(bit64.new(1, 2), bit64.new(3, 4))
+ end,
+ },
+ }
+
+ for _, op in ipairs(raw_ops_returning_int64) do
+ total = total + 1
+ local result = op.fn()
+ if bit64.is_int64(result) then
+ print(" PASS: " .. op.name .. "() returns Int64")
+ passed = passed + 1
+ else
+ print(" FAIL: " .. op.name .. "() returns Int64")
+ end
+ end
+
+ print(string.format("\n64-bit operations: %d/%d tests passed\n", passed, total))
+ return passed == total
+end
+
+--------------------------------------------------------------------------------
+-- Benchmarking
+--------------------------------------------------------------------------------
+
+local benchmark_op = require("bitn.utils.benchmark").benchmark_op
+
+--- Run performance benchmarks for 64-bit operations.
+function bit64.benchmark()
+ local iterations = 100000
+
+ print("64-bit Bitwise Operations:")
+ print(string.format(" Implementation: %s", impl_name()))
+
+ -- Test values
+ local a = bit64.new(0xAAAAAAAA, 0x55555555)
+ local b = bit64.new(0x55555555, 0xAAAAAAAA)
+
+ benchmark_op("band", function()
+ bit64.band(a, b)
+ end, iterations)
+
+ benchmark_op("bor", function()
+ bit64.bor(a, b)
+ end, iterations)
+
+ benchmark_op("bxor", function()
+ bit64.bxor(a, b)
+ end, iterations)
+
+ benchmark_op("bnot", function()
+ bit64.bnot(a)
+ end, iterations)
+
+ print("\n64-bit Shift Operations:")
+
+ benchmark_op("lshift (small)", function()
+ bit64.lshift(a, 8)
+ end, iterations)
+
+ benchmark_op("lshift (large)", function()
+ bit64.lshift(a, 40)
+ end, iterations)
+
+ benchmark_op("rshift (small)", function()
+ bit64.rshift(a, 8)
+ end, iterations)
+
+ benchmark_op("rshift (large)", function()
+ bit64.rshift(a, 40)
+ end, iterations)
+
+ benchmark_op("arshift", function()
+ bit64.arshift(bit64.new(0x80000000, 0), 8)
+ end, iterations)
+
+ print("\n64-bit Rotate Operations:")
+
+ benchmark_op("rol (small)", function()
+ bit64.rol(a, 8)
+ end, iterations)
+
+ benchmark_op("rol (large)", function()
+ bit64.rol(a, 40)
+ end, iterations)
+
+ benchmark_op("ror (small)", function()
+ bit64.ror(a, 8)
+ end, iterations)
+
+ benchmark_op("ror (large)", function()
+ bit64.ror(a, 40)
+ end, iterations)
+
+ print("\n64-bit Arithmetic:")
+
+ benchmark_op("add", function()
+ bit64.add(a, b)
+ end, iterations)
+
+ benchmark_op("add (with carry)", function()
+ bit64.add(bit64.new(0, 0xFFFFFFFF), bit64.new(0, 1))
+ end, iterations)
+
+ print("\n64-bit Byte Conversions:")
+
+ local val = bit64.new(0x12345678, 0x9ABCDEF0)
+ local bytes_be = bit64.u64_to_be_bytes(val)
+ local bytes_le = bit64.u64_to_le_bytes(val)
+
+ benchmark_op("u64_to_be_bytes", function()
+ bit64.u64_to_be_bytes(val)
+ end, iterations)
+
+ benchmark_op("u64_to_le_bytes", function()
+ bit64.u64_to_le_bytes(val)
+ end, iterations)
+
+ benchmark_op("be_bytes_to_u64", function()
+ bit64.be_bytes_to_u64(bytes_be)
+ end, iterations)
+
+ benchmark_op("le_bytes_to_u64", function()
+ bit64.le_bytes_to_u64(bytes_le)
+ end, iterations)
+
+ print("\n64-bit Utility Functions:")
+
+ benchmark_op("new", function()
+ bit64.new(0x12345678, 0x9ABCDEF0)
+ end, iterations)
+
+ benchmark_op("is_int64", function()
+ bit64.is_int64(a)
+ end, iterations)
+
+ benchmark_op("to_hex", function()
+ bit64.to_hex(a)
+ end, iterations)
+
+ benchmark_op("to_number", function()
+ bit64.to_number(a)
+ end, iterations)
+
+ benchmark_op("from_number", function()
+ bit64.from_number(12345678901234)
+ end, iterations)
+
+ benchmark_op("eq", function()
+ bit64.eq(a, b)
+ end, iterations)
+
+ benchmark_op("is_zero", function()
+ bit64.is_zero(a)
+ end, iterations)
+end
+
+return bit64
+end
+end
+
+do
+local _ENV = _ENV
+package.preload[ "bitn.utils.benchmark" ] = function( ... ) local arg = _G.arg;
+--- @module "bitn.utils.benchmark"
+--- Common benchmarking utilities for performance testing
+--- @class bitn.utils.benchmark
+local benchmark = {}
+
+--- Run a benchmarked operation with warmup and timing
+--- @param name string Operation name for display
+--- @param func function Function to benchmark
+--- @param iterations? integer Number of iterations (default: 100)
+--- @return number ms_per_op Milliseconds per operation
+function benchmark.benchmark_op(name, func, iterations)
+ iterations = iterations or 100
+
+ -- Warmup
+ for _ = 1, 3 do
+ func()
+ end
+
+ -- Actual benchmark
+ local start = os.clock()
+ for _ = 1, iterations do
+ func()
+ end
+ local elapsed = os.clock() - start
+
+ local per_op = (elapsed / iterations) * 1000 -- ms
+ local ops_per_sec = iterations / elapsed
+
+ print(string.format("%-30s: %8.3f ms/op, %8.1f ops/sec", name, per_op, ops_per_sec))
+
+ return per_op
+end
+
+return benchmark
+end
+end
+
+--- @module "bitn"
+--- Portable bitwise operations library with automatic optimization.
+--- This library provides standalone, version-agnostic implementations of
+--- bitwise operations for 16-bit, 32-bit, and 64-bit integers. It works
+--- across Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT with zero external dependencies.
+--- Automatically uses native bit operations when available for optimal performance.
+---
+--- @usage
+--- local bitn = require("bitn")
+--- print(bitn.version())
+---
+--- -- 32-bit operations
+--- local result = bitn.bit32.band(0xFF00, 0x0FF0) -- 0x0F00
+---
+--- -- 64-bit operations (using {high, low} pairs)
+--- local sum = bitn.bit64.add({0, 1}, {0, 2}) -- {0, 3}
+---
+--- -- 16-bit operations
+--- local shifted = bitn.bit16.lshift(1, 8) -- 256
+---
+--- @class bitn
+local bitn = {
+ --- @type bitn.bit16 16-bit bitwise operations
+ bit16 = require("bitn.bit16"),
+ --- @type bitn.bit32 32-bit bitwise operations
+ bit32 = require("bitn.bit32"),
+ --- @type bitn.bit64 64-bit bitwise operations
+ bit64 = require("bitn.bit64"),
+}
+
+--- Library version (injected at build time for releases).
+local VERSION = "v0.6.0"
+
+--- Get the library version string.
+--- @return string version Version string (e.g., "v1.0.0" or "dev")
+function bitn.version()
+ return VERSION
+end
+
+return bitn