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 + +--- + +Buy Me A Coffee 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