diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea30d7..37b2072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,221 +12,16 @@ on: branches: [main] permissions: - contents: read + contents: write + security-events: write + pages: write + id-token: write jobs: - build-test: - name: MIX_ENV=test mix compile - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/install-elixir@59199173e18eee6748b65d01626ef82d51c6e963 # main - - name: Set OS deps compile partition count - run: echo "MIX_OS_DEPS_COMPILE_PARTITION_COUNT=$(($(lscpu -p | grep -v '^#' | sort -u -t, -k 2,4 | wc -l) / 2))" >> $GITHUB_ENV - - uses: team-alembic/staple-actions/actions/mix-compile@04f27881d51ef973841fc40c549aefb7b52db7f7 # main - with: - mix-env: test - args: "--warnings-as-errors" - - test: - name: mix test - runs-on: ubuntu-latest - needs: build-test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-test@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: test - - credo: - name: mix credo --strict - runs-on: ubuntu-latest - needs: build-test - permissions: - security-events: write - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-credo@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: test - - name: Run Credo SAST - uses: team-alembic/staple-actions/actions/mix-task@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - task: credo --format sarif > results.sarif - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - with: - sarif_file: results.sarif - category: credo - - formatter: - name: mix format --check-formatted - runs-on: ubuntu-latest - needs: build-test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-format@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: test - - dialyzer: - name: mix dialyzer - runs-on: ubuntu-latest - needs: build-test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-dialyzer@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: dev - - audit: - name: mix deps.audit + hex.audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-hex-audit@59199173e18eee6748b65d01626ef82d51c6e963 # main - - uses: team-alembic/staple-actions/actions/mix-task@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - task: deps.audit - - unused-deps: - name: mix deps.unlock --check-unused - runs-on: ubuntu-latest - needs: build-test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-task@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: test - task: deps.unlock --check-unused - - reuse: - name: REUSE compliance - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: REUSE compliance check - uses: fsfe/reuse-action@v6 - - build-docs: - name: mix docs - runs-on: ubuntu-latest - needs: - - build-test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: team-alembic/staple-actions/actions/mix-docs@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: dev - use-cache: false - - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 - with: - path: doc/ - - deploy-docs: - name: Deploy docs to GitHub Pages - runs-on: ubuntu-latest - needs: build-docs - if: github.ref == 'refs/heads/main' - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 - - release: - name: Publish to Hex.pm - runs-on: ubuntu-latest - needs: - - test - - dialyzer - - credo - - formatter - - audit - - unused-deps - - reuse - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Extract release notes from CHANGELOG.md - id: extract-notes - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - - # Extract the section for this version from CHANGELOG.md - awk -v version="$VERSION" ' - /^## \[v?[0-9]/ { - if (found) exit - if (index($0, "[v" version "]") || index($0, "[" version "]")) { - found = 1 - next - } - } - found { - if (/^## \[v?[0-9]/) exit - print - } - ' CHANGELOG.md > release_notes.md - - # Check if notes were found - if [ -s release_notes.md ]; then - echo "has_notes=true" >> $GITHUB_OUTPUT - echo "Release notes extracted for version $VERSION" - else - echo "has_notes=false" >> $GITHUB_OUTPUT - echo "No release notes found for version $VERSION, will use auto-generated notes" - fi - - name: Create prerelease with changelog notes - if: ${{ (contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre')) && steps.extract-notes.outputs.has_notes == 'true' }} - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create \ - --repo ${{ github.repository }} \ - --title ${GITHUB_REF#refs/tags/} \ - --prerelease \ - --notes-file release_notes.md \ - ${GITHUB_REF#refs/tags/} - - name: Create prerelease with generated notes - if: ${{ (contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre')) && steps.extract-notes.outputs.has_notes != 'true' }} - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create \ - --repo ${{ github.repository }} \ - --title ${GITHUB_REF#refs/tags/} \ - --prerelease \ - --generate-notes \ - ${GITHUB_REF#refs/tags/} - - name: Create release with changelog notes - if: ${{ (!contains(github.ref, '-rc') && !contains(github.ref, '-beta') && !contains(github.ref, '-alpha') && !contains(github.ref, '-pre')) && steps.extract-notes.outputs.has_notes == 'true' }} - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create \ - --repo ${{ github.repository }} \ - --title ${GITHUB_REF#refs/tags/} \ - --notes-file release_notes.md \ - ${GITHUB_REF#refs/tags/} - - name: Create release with generated notes - if: ${{ (!contains(github.ref, '-rc') && !contains(github.ref, '-beta') && !contains(github.ref, '-alpha') && !contains(github.ref, '-pre')) && steps.extract-notes.outputs.has_notes != 'true' }} - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create \ - --repo ${{ github.repository }} \ - --title ${GITHUB_REF#refs/tags/} \ - --generate-notes \ - ${GITHUB_REF#refs/tags/} - - uses: team-alembic/staple-actions/actions/mix-hex-publish@59199173e18eee6748b65d01626ef82d51c6e963 # main - with: - mix-env: dev - hex-api-key: ${{ secrets.HEX_API_KEY }} + CI: + uses: beam-bots/.github/.github/workflows/elixir-ci.yml@61ed48adfb0acec3baa9a32114d377546ee70478 # main + with: + enable-docs-deploy: true + enable-release: true + secrets: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} diff --git a/lib/feetech/control_table.ex b/lib/feetech/control_table.ex index b2ea738..ff91498 100644 --- a/lib/feetech/control_table.ex +++ b/lib/feetech/control_table.ex @@ -35,12 +35,14 @@ defmodule Feetech.ControlTable do * `nil` - No conversion, raw integer value * `:bool` - 0/1 to false/true * `float` - Scale factor (e.g., `0.1` for voltage in 0.1V units) - * `:position` - Steps to radians (servo-specific) + * `:position` - Steps to radians (unsigned, servo-specific) + * `:position_signed` - Steps to radians with sign-magnitude encoding (bit 15 = sign) * `:speed` - Speed units to rad/s - * `:speed_signed` - Signed speed to rad/s - * `:load_signed` - Signed load percentage + * `:speed_signed` - Signed speed to rad/s (sign-magnitude, bit 15 = sign) + * `:load_signed` - Signed load percentage (sign-magnitude, bit 10 = sign) * `:mode` - Operating mode enum * `:baud_rate` - Baud rate enum + * `{:sign_magnitude, sign_bit}` - Raw sign-magnitude with specified sign bit * `{module, decode_fun, encode_fun}` - Custom conversion functions """ @@ -62,12 +64,14 @@ defmodule Feetech.ControlTable do nil | :bool | :position + | :position_signed | :speed | :speed_signed | :load_signed | :mode | :baud_rate | float() + | {:sign_magnitude, non_neg_integer()} | {module(), atom(), atom()} @typedoc "Register definition tuple" @@ -147,6 +151,11 @@ defmodule Feetech.ControlTable do Protocol.encode_int(steps, length) end + defp encode_value(value, length, :position_signed, table) do + steps = round(value / table.position_scale()) + Protocol.encode_sign_magnitude(steps, 15, length) + end + defp encode_value(value, length, :speed, table) do raw = round(value / table.speed_scale()) Protocol.encode_int(raw, length) @@ -154,12 +163,16 @@ defmodule Feetech.ControlTable do defp encode_value(value, length, :speed_signed, table) do raw = round(value / table.speed_scale()) - Protocol.encode_int(raw, length) + Protocol.encode_sign_magnitude(raw, 15, length) end defp encode_value(value, length, :load_signed, _table) do raw = round(value * 10) - Protocol.encode_int(raw, length) + Protocol.encode_sign_magnitude(raw, 10, length) + end + + defp encode_value(value, length, {:sign_magnitude, sign_bit}, _table) do + Protocol.encode_sign_magnitude(value, sign_bit, length) end defp encode_value(value, length, :mode, table) do @@ -193,16 +206,24 @@ defmodule Feetech.ControlTable do Protocol.decode_int(data) * table.position_scale() end + defp decode_value(data, :position_signed, table) do + Protocol.decode_sign_magnitude(data, 15) * table.position_scale() + end + defp decode_value(data, :speed, table) do Protocol.decode_int(data) * table.speed_scale() end defp decode_value(data, :speed_signed, table) do - Protocol.decode_int_signed(data) * table.speed_scale() + Protocol.decode_sign_magnitude(data, 15) * table.speed_scale() end defp decode_value(data, :load_signed, _table) do - Protocol.decode_int_signed(data) * 0.1 + Protocol.decode_sign_magnitude(data, 10) * 0.1 + end + + defp decode_value(data, {:sign_magnitude, sign_bit}, _table) do + Protocol.decode_sign_magnitude(data, sign_bit) end defp decode_value(data, :mode, table) do diff --git a/lib/feetech/control_table/sts3215.ex b/lib/feetech/control_table/sts3215.ex index 12deced..5ad3785 100644 --- a/lib/feetech/control_table/sts3215.ex +++ b/lib/feetech/control_table/sts3215.ex @@ -80,7 +80,7 @@ defmodule Feetech.ControlTable.STS3215 do ccw_dead_band: {27, 1, nil}, overload_current: {28, 2, nil}, angular_resolution: {30, 1, nil}, - position_offset: {31, 2, nil}, + position_offset: {31, 2, {:sign_magnitude, 11}}, mode: {33, 1, :mode}, protection_torque: {34, 1, nil}, protection_time: {35, 1, nil}, @@ -89,14 +89,14 @@ defmodule Feetech.ControlTable.STS3215 do # SRAM - volatile settings (address 40-54) torque_enable: {40, 1, :bool}, acceleration: {41, 1, nil}, - goal_position: {42, 2, :position}, + goal_position: {42, 2, :position_signed}, goal_time: {44, 2, nil}, goal_speed: {46, 2, :speed}, torque_limit: {48, 2, 0.001}, lock: {55, 1, :bool}, # SRAM - read-only feedback (address 56+) - present_position: {56, 2, :position}, + present_position: {56, 2, :position_signed}, present_speed: {58, 2, :speed_signed}, present_load: {60, 2, :load_signed}, present_voltage: {62, 1, 0.1}, diff --git a/lib/feetech/protocol.ex b/lib/feetech/protocol.ex index 63d8c19..e661461 100644 --- a/lib/feetech/protocol.ex +++ b/lib/feetech/protocol.ex @@ -324,6 +324,61 @@ defmodule Feetech.Protocol do if value > 2_147_483_647, do: value - 4_294_967_296, else: value end + @doc """ + Encodes a signed integer using sign-magnitude encoding. + + The sign bit position determines where the sign is stored: + - Bit 11 for homing_offset (range: -2047 to +2047) + - Bit 15 for position values (range: -32767 to +32767) + + ## Examples + + iex> Feetech.Protocol.encode_sign_magnitude(-1000, 11, 2) + <<0xE8, 0x0B>> + + iex> Feetech.Protocol.encode_sign_magnitude(1000, 11, 2) + <<0xE8, 0x03>> + """ + @spec encode_sign_magnitude(integer(), non_neg_integer(), 1 | 2) :: binary() + def encode_sign_magnitude(value, sign_bit, length) do + raw = + if value < 0 do + Bitwise.bor(1 <<< sign_bit, abs(value)) + else + value + end + + encode_int(raw, length) + end + + @doc """ + Decodes a sign-magnitude encoded integer. + + The sign bit position determines where the sign is stored: + - Bit 11 for homing_offset + - Bit 15 for position values + + ## Examples + + iex> Feetech.Protocol.decode_sign_magnitude(<<0xE8, 0x0B>>, 11) + -1000 + + iex> Feetech.Protocol.decode_sign_magnitude(<<0xE8, 0x03>>, 11) + 1000 + """ + @spec decode_sign_magnitude(binary(), non_neg_integer()) :: integer() + def decode_sign_magnitude(data, sign_bit) do + raw = decode_int(data) + sign_mask = 1 <<< sign_bit + magnitude_mask = sign_mask - 1 + + if Bitwise.band(raw, sign_mask) != 0 do + -Bitwise.band(raw, magnitude_mask) + else + Bitwise.band(raw, magnitude_mask) + end + end + defp build_packet(id, instruction, params) do length = byte_size(params) + 2 body = <> <> params diff --git a/test/feetech/control_table/sts3215_test.exs b/test/feetech/control_table/sts3215_test.exs index a8c3371..5321753 100644 --- a/test/feetech/control_table/sts3215_test.exs +++ b/test/feetech/control_table/sts3215_test.exs @@ -60,6 +60,26 @@ defmodule Feetech.ControlTable.STS3215Test do # Should be within one step of original assert_in_delta decoded, original, STS3215.position_scale() end + + test "encodes negative radians to signed steps" do + {:ok, data} = ControlTable.encode(STS3215, :goal_position, -:math.pi()) + # -π radians = -2048 steps, encoded with bit 15 sign + # 0x8000 | 2048 = 0x8800 little-endian = <<0x00, 0x88>> + assert data == <<0x00, 0x88>> + end + + test "decodes negative signed steps to radians" do + # -2048 steps encoded with bit 15 sign = 0x8800 + {:ok, value} = ControlTable.decode(STS3215, :present_position, <<0x00, 0x88>>) + assert_in_delta value, -:math.pi(), 0.001 + end + + test "round-trip conversion preserves negative value" do + original = -1.5 + {:ok, encoded} = ControlTable.encode(STS3215, :goal_position, original) + {:ok, decoded} = ControlTable.decode(STS3215, :goal_position, encoded) + assert_in_delta decoded, original, STS3215.position_scale() + end end describe "boolean conversion" do @@ -156,11 +176,79 @@ defmodule Feetech.ControlTable.STS3215Test do {:ok, {address, length, conversion}} = ControlTable.get_register(STS3215, :goal_position) assert address == 42 assert length == 2 - assert conversion == :position + assert conversion == :position_signed end test "returns error for unknown register" do assert {:error, :unknown_register} = ControlTable.get_register(STS3215, :nonexistent) end end + + describe "position_offset conversion" do + test "encodes positive offset" do + {:ok, data} = ControlTable.encode(STS3215, :position_offset, 1000) + assert data == <<0xE8, 0x03>> + end + + test "encodes negative offset with bit 11 sign" do + {:ok, data} = ControlTable.encode(STS3215, :position_offset, -1000) + # -1000 with bit 11: 0x800 | 1000 = 3048 = 0x0BE8 + assert data == <<0xE8, 0x0B>> + end + + test "decodes negative offset" do + {:ok, value} = ControlTable.decode(STS3215, :position_offset, <<0xE8, 0x0B>>) + assert value == -1000 + end + + test "round-trips offset values" do + for offset <- [-2000, -1000, 0, 1000, 2000] do + {:ok, encoded} = ControlTable.encode(STS3215, :position_offset, offset) + {:ok, decoded} = ControlTable.decode(STS3215, :position_offset, encoded) + assert decoded == offset + end + end + end + + describe "signed speed conversion" do + test "decodes positive speed" do + # 10 speed units + {:ok, value} = ControlTable.decode(STS3215, :present_speed, <<0x0A, 0x00>>) + expected = 10 * STS3215.speed_scale() + assert_in_delta value, expected, 0.001 + end + + test "decodes negative speed" do + # -10 speed units with bit 15 sign: 0x8000 | 10 = 0x800A + {:ok, value} = ControlTable.decode(STS3215, :present_speed, <<0x0A, 0x80>>) + expected = -10 * STS3215.speed_scale() + assert_in_delta value, expected, 0.001 + end + end + + describe "signed load conversion" do + test "decodes positive load percentage" do + # 500 raw = 50% load + {:ok, value} = ControlTable.decode(STS3215, :present_load, <<0xF4, 0x01>>) + assert_in_delta value, 50.0, 0.1 + end + + test "decodes negative load with bit 10 sign" do + # -500 raw with bit 10 sign: 0x400 | 500 = 1524 = 0x05F4 + {:ok, value} = ControlTable.decode(STS3215, :present_load, <<0xF4, 0x05>>) + assert_in_delta value, -50.0, 0.1 + end + + test "encodes positive load percentage" do + {:ok, data} = ControlTable.encode(STS3215, :present_load, 50.0) + # 50% = 500 raw + assert data == <<0xF4, 0x01>> + end + + test "encodes negative load percentage with bit 10 sign" do + {:ok, data} = ControlTable.encode(STS3215, :present_load, -50.0) + # -50% = -500 raw, with bit 10 sign: 0x400 | 500 = 1524 = 0x05F4 + assert data == <<0xF4, 0x05>> + end + end end diff --git a/test/feetech/protocol_test.exs b/test/feetech/protocol_test.exs index 8f07697..5a91ffd 100644 --- a/test/feetech/protocol_test.exs +++ b/test/feetech/protocol_test.exs @@ -195,4 +195,68 @@ defmodule Feetech.ProtocolTest do assert Protocol.decode_int_signed(<<0x80>>) == -128 end end + + describe "encode_sign_magnitude/3" do + test "encodes positive value with bit 11 sign" do + assert Protocol.encode_sign_magnitude(1000, 11, 2) == <<0xE8, 0x03>> + end + + test "encodes zero" do + assert Protocol.encode_sign_magnitude(0, 11, 2) == <<0x00, 0x00>> + end + + test "encodes negative value with bit 11 sign" do + # -1000: set bit 11 (0x800), magnitude 1000 + # Result: 2048 + 1000 = 3048 = 0x0BE8 little-endian + assert Protocol.encode_sign_magnitude(-1000, 11, 2) == <<0xE8, 0x0B>> + end + + test "encodes negative value with bit 15 sign" do + # -1000: set bit 15 (0x8000), magnitude 1000 + # Result: 32768 + 1000 = 33768 = 0x83E8 little-endian + assert Protocol.encode_sign_magnitude(-1000, 15, 2) == <<0xE8, 0x83>> + end + + test "encodes negative value with bit 10 sign" do + # -500: set bit 10 (0x400), magnitude 500 + # Result: 1024 + 500 = 1524 = 0x05F4 little-endian + assert Protocol.encode_sign_magnitude(-500, 10, 2) == <<0xF4, 0x05>> + end + end + + describe "decode_sign_magnitude/2" do + test "decodes positive value with bit 11 sign" do + assert Protocol.decode_sign_magnitude(<<0xE8, 0x03>>, 11) == 1000 + end + + test "decodes zero" do + assert Protocol.decode_sign_magnitude(<<0x00, 0x00>>, 11) == 0 + end + + test "decodes negative value with bit 11 sign" do + assert Protocol.decode_sign_magnitude(<<0xE8, 0x0B>>, 11) == -1000 + end + + test "decodes negative value with bit 15 sign" do + assert Protocol.decode_sign_magnitude(<<0xE8, 0x83>>, 15) == -1000 + end + + test "decodes negative value with bit 10 sign" do + assert Protocol.decode_sign_magnitude(<<0xF4, 0x05>>, 10) == -500 + end + + test "round-trips positive values" do + for value <- [0, 1, 100, 1000, 2047] do + encoded = Protocol.encode_sign_magnitude(value, 11, 2) + assert Protocol.decode_sign_magnitude(encoded, 11) == value + end + end + + test "round-trips negative values" do + for value <- [-1, -100, -1000, -2047] do + encoded = Protocol.encode_sign_magnitude(value, 11, 2) + assert Protocol.decode_sign_magnitude(encoded, 11) == value + end + end + end end