Skip to content

Make fields private across 7 modules, delete dead TransferActions fields#35

Merged
kaidokert merged 1 commit into
mainfrom
pr29_1
May 1, 2026
Merged

Make fields private across 7 modules, delete dead TransferActions fields#35
kaidokert merged 1 commit into
mainfrom
pr29_1

Conversation

@kaidokert
Copy link
Copy Markdown
Owner

@kaidokert kaidokert commented May 1, 2026

Summary

Batch visibility tightening across 7 files:

  • PhasePositions: 3 fields private, new() constructor
  • KissTelemPacket: struct + 10 fields private (internal-only)
  • TransferState: 4 internal fields private
  • TransferActions: delete dead save_settings/play_tone (set but never read)
  • ServoState: 7 internal fields private
  • EdtScheduler: 3 internal fields private
  • BrushedState/BrushedOutput: fields private/pub(crate)

Test plan

  • 226 unit tests pass
  • 46 blackbox tests pass, 1 xfail
  • All 4 MCU cross-builds pass
  • Clippy clean

Summary by CodeRabbit

  • Refactor
    • Restricted visibility of internal state fields across control modules.
    • Removed save_settings and play_tone fields from actions.
    • Added new constructor for three-phase initialization.
    • Made telemetry packet structure private.

…ields

PhasePositions: fields private, add new() constructor with 120° offsets.
KissTelemPacket: struct + all 10 fields private (internal-only).
TransferState: 4 internal fields private, servo stays pub.
TransferActions: delete dead save_settings/play_tone (set but never read).
ServoState: 7 internal fields private, 4 calibration fields stay pub.
EdtScheduler: 3 internal fields private, init/deinit flags stay pub.
BrushedState: 2 fields private. BrushedOutput: 2 fields pub(crate).
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @kaidokert, you have reached your weekly rate limit of 1500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

📝 Walkthrough

Walkthrough

These changes restrict field visibility across six motor control modules (brushed, edt, servo, sine, telemetry, transfer), making internal state private or crate-scoped. A new PhasePositions::new() constructor is added, initialization code is updated to use it, and TransferActions loses two fields with related calibration logic removed.

Changes

Cohort / File(s) Summary
State Field Encapsulation
rm32/src/brushed.rs, rm32/src/edt.rs, rm32/src/servo.rs, rm32/src/transfer.rs
Fields in BrushedState, BrushedOutput, EdtScheduler, ServoState, and TransferState changed from pub to private or pub(crate). #[allow(dead_code)] added to BrushedOutput.
Sine Phase Constructor
rm32/src/sine.rs
PhasePositions fields (a, b, c) made private. New PhasePositions::new() constructor added for three-phase initialization (0°, 120°, 240°).
Constructor Usage Update
rm32_stm32/src/bin/main.rs
Sine mode initialization changed from hardcoded struct literal to PhasePositions::new() constructor call.
Telemetry Type Privatization
rm32/src/telemetry.rs
KissTelemPacket struct and its fields changed from public to private. #[allow(dead_code)] attribute added.
Transfer Action Fields Removed
rm32/src/transfer.rs
TransferActions struct loses save_settings and play_tone fields. Related calibration action assignments in process() removed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Whiskers twitching with encapsulation glee,
Private fields hide what shouldn't be seen with glee,
Constructors now guide the phase's ballet,
No more public mess in the motor's relay,
Cleaner API, secrets tucked away neat!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: restricting field visibility across multiple modules and removing unused TransferActions fields.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr29_1

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 1, 2026

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
272 2 270 1
View the full list of 2 ❄️ flaky test(s)
tests.blackbox.test_vectors::test_vector[signal_timeout]

Flake rate in main: 100.00% (Passed 0 times, Failed 48 times)

Stack Traces | 0.002s run time
harness = <harness.AM32Harness object at 0x7f1899f31790>
vector_file = PosixPath('.../blackbox/vectors/signal_timeout.txt')

    @pytest.mark.parametrize(
        "vector_file",
        get_vector_files(),
        ids=lambda p: p.stem,
    )
    def test_vector(harness, vector_file):
        """Run a single test vector file."""
        if vector_file.stem in XFAIL_VECTORS:
            pytest.xfail(f"Known Rust vs C difference: {vector_file.stem}")
>       run_test_vectors(harness, vector_file)

tests/blackbox/test_vectors.py:54: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

harness = <harness.AM32Harness object at 0x7f1899f31790>
vectors_file = PosixPath('.../blackbox/vectors/signal_timeout.txt')

    def run_test_vectors(harness, vectors_file):
        """
        Run test vectors from a file.
    
        Format:
            # Comments start with #
            # Blank lines are ignored
    
            @config
            key=value
            key=value
    
            @sequence
            # tick_spec | inputs | assertions
            tick 1      | throttle=0          |
            ticks 20000 | throttle=0          | armed=1
            tick 1      | throttle=500        | running=1 duty_cycle>0
        """
        import re
    
        section = None
        config_lines = []
        sequence_lines = []
    
        with open(vectors_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if line == "@config":
                    section = "config"
                    continue
                if line == "@sequence":
                    section = "sequence"
                    continue
                if section == "config":
                    config_lines.append(line)
                elif section == "sequence":
                    sequence_lines.append(line)
    
        # Apply config
        harness.reset()
        for cl in config_lines:
            if "=" not in cl:
                continue
            k, v = cl.split("=", 1)
            harness.config(**{k.strip(): v.strip()})
    
        # Operator dispatch table
        ops = {
            "=": lambda a, b: a == b,
            ">": lambda a, b: a > b,
            "<": lambda a, b: a < b,
            ">=": lambda a, b: a >= b,
            "<=": lambda a, b: a <= b,
        }
    
        # Run sequence
        results = []
        for seq_line in sequence_lines:
            # Inline config commands
            if seq_line.startswith("config "):
                kvs = seq_line[7:]
                for token in kvs.split():
                    if "=" in token:
                        k, v = token.split("=", 1)
                        harness.config(**{k: v})
                continue
    
            # Inline commands
            if seq_line == "load_eeprom":
                harness.load_eeprom()
                continue
    
            parts = [p.strip() for p in seq_line.split("|")]
            cmd_part = parts[0]
            input_part = parts[1] if len(parts) > 1 else ""
            assert_part = parts[2] if len(parts) > 2 else ""
    
            # Parse inputs
            inputs = {}
            for token in input_part.split():
                if "=" in token:
                    k, v = token.split("=", 1)
                    inputs[k] = int(v)
    
            # Execute
            tokens = cmd_part.split()
            cmd = tokens[0]
            if cmd == "tick":
                state = harness.tick(**inputs)
            elif cmd == "ticks":
                n = int(tokens[1])
                state = harness.ticks(n, **inputs)
            elif cmd == "gcr_encode":
                com_time = int(tokens[1])
                state = harness.gcr_encode(com_time, **inputs)
            else:
                raise ValueError(f"Unknown command: {cmd}")
    
            # Check assertions
            if assert_part:
                for assertion in assert_part.split():
                    # Support: key=value, key>value, key<value, key>=value, key<=value
                    # Value can be numeric or string (for gcr= comma-separated buffers)
                    m = re.match(r"(\w+)(>=|<=|>|<|=)([\w,.-]+)", assertion)
                    if not m:
                        raise ValueError(f"Bad assertion: {assertion}")
                    key, op, expected_str = m.group(1), m.group(2), m.group(3)
                    actual = state.get(key)
                    if actual is None:
>                       raise KeyError(f"Key '{key}' not in state")
E                       KeyError: "Key 'armed' not in state"

tests/blackbox/harness.py:255: KeyError
tests.blackbox.test_vectors::test_vector[telemetry]

Flake rate in main: 100.00% (Passed 0 times, Failed 48 times)

Stack Traces | 0.002s run time
harness = <harness.AM32Harness object at 0x7f1899f33320>
vector_file = PosixPath('.../blackbox/vectors/telemetry.txt')

    @pytest.mark.parametrize(
        "vector_file",
        get_vector_files(),
        ids=lambda p: p.stem,
    )
    def test_vector(harness, vector_file):
        """Run a single test vector file."""
        if vector_file.stem in XFAIL_VECTORS:
            pytest.xfail(f"Known Rust vs C difference: {vector_file.stem}")
>       run_test_vectors(harness, vector_file)

tests/blackbox/test_vectors.py:54: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

harness = <harness.AM32Harness object at 0x7f1899f33320>
vectors_file = PosixPath('.../blackbox/vectors/telemetry.txt')

    def run_test_vectors(harness, vectors_file):
        """
        Run test vectors from a file.
    
        Format:
            # Comments start with #
            # Blank lines are ignored
    
            @config
            key=value
            key=value
    
            @sequence
            # tick_spec | inputs | assertions
            tick 1      | throttle=0          |
            ticks 20000 | throttle=0          | armed=1
            tick 1      | throttle=500        | running=1 duty_cycle>0
        """
        import re
    
        section = None
        config_lines = []
        sequence_lines = []
    
        with open(vectors_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if line == "@config":
                    section = "config"
                    continue
                if line == "@sequence":
                    section = "sequence"
                    continue
                if section == "config":
                    config_lines.append(line)
                elif section == "sequence":
                    sequence_lines.append(line)
    
        # Apply config
        harness.reset()
        for cl in config_lines:
            if "=" not in cl:
                continue
            k, v = cl.split("=", 1)
            harness.config(**{k.strip(): v.strip()})
    
        # Operator dispatch table
        ops = {
            "=": lambda a, b: a == b,
            ">": lambda a, b: a > b,
            "<": lambda a, b: a < b,
            ">=": lambda a, b: a >= b,
            "<=": lambda a, b: a <= b,
        }
    
        # Run sequence
        results = []
        for seq_line in sequence_lines:
            # Inline config commands
            if seq_line.startswith("config "):
                kvs = seq_line[7:]
                for token in kvs.split():
                    if "=" in token:
                        k, v = token.split("=", 1)
                        harness.config(**{k: v})
                continue
    
            # Inline commands
            if seq_line == "load_eeprom":
                harness.load_eeprom()
                continue
    
            parts = [p.strip() for p in seq_line.split("|")]
            cmd_part = parts[0]
            input_part = parts[1] if len(parts) > 1 else ""
            assert_part = parts[2] if len(parts) > 2 else ""
    
            # Parse inputs
            inputs = {}
            for token in input_part.split():
                if "=" in token:
                    k, v = token.split("=", 1)
                    inputs[k] = int(v)
    
            # Execute
            tokens = cmd_part.split()
            cmd = tokens[0]
            if cmd == "tick":
                state = harness.tick(**inputs)
            elif cmd == "ticks":
                n = int(tokens[1])
                state = harness.ticks(n, **inputs)
            elif cmd == "gcr_encode":
                com_time = int(tokens[1])
                state = harness.gcr_encode(com_time, **inputs)
            else:
                raise ValueError(f"Unknown command: {cmd}")
    
            # Check assertions
            if assert_part:
                for assertion in assert_part.split():
                    # Support: key=value, key>value, key<value, key>=value, key<=value
                    # Value can be numeric or string (for gcr= comma-separated buffers)
                    m = re.match(r"(\w+)(>=|<=|>|<|=)([\w,.-]+)", assertion)
                    if not m:
                        raise ValueError(f"Bad assertion: {assertion}")
                    key, op, expected_str = m.group(1), m.group(2), m.group(3)
                    actual = state.get(key)
                    if actual is None:
>                       raise KeyError(f"Key '{key}' not in state")
E                       KeyError: "Key 'send_telemetry' not in state"

tests/blackbox/harness.py:255: KeyError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
rm32/src/sine.rs (1)

213-220: ⚡ Quick win

Align Default with PhasePositions::new() to avoid split initialization semantics.

Now that new() defines the canonical 0°/120°/240° phase offsets, #[derive(Default)] still yields 0°/0°/0°. Making Default delegate to new() avoids accidental zero-offset startup state.

Suggested patch
-#[derive(Clone, Default)]
+#[derive(Clone)]
 pub struct PhasePositions {
     a: i16,
     b: i16,
     c: i16,
 }

 impl PhasePositions {
@@
     pub fn new() -> Self {
         Self {
             a: 0,
             b: 120,
             c: 240,
         }
     }
@@
 }
+
+impl Default for PhasePositions {
+    fn default() -> Self {
+        Self::new()
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rm32/src/sine.rs` around lines 213 - 220, The Default implementation for
PhasePositions should match the canonical offsets returned by
PhasePositions::new(); replace the derived Default with an explicit impl Default
for PhasePositions that returns PhasePositions::new() (or have Default call
PhasePositions::new()) so constructing with Default yields 0°/120°/240° instead
of 0/0/0; update/remove #[derive(Default)] and add impl Default for
PhasePositions { fn default() -> Self { Self::new() } } referencing the
PhasePositions type and its new() constructor.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@rm32/src/sine.rs`:
- Around line 213-220: The Default implementation for PhasePositions should
match the canonical offsets returned by PhasePositions::new(); replace the
derived Default with an explicit impl Default for PhasePositions that returns
PhasePositions::new() (or have Default call PhasePositions::new()) so
constructing with Default yields 0°/120°/240° instead of 0/0/0; update/remove
#[derive(Default)] and add impl Default for PhasePositions { fn default() ->
Self { Self::new() } } referencing the PhasePositions type and its new()
constructor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bf4369a-06e0-44c2-b15a-c2074750f034

📥 Commits

Reviewing files that changed from the base of the PR and between fa7d161 and 53d49bc.

📒 Files selected for processing (7)
  • rm32/src/brushed.rs
  • rm32/src/edt.rs
  • rm32/src/servo.rs
  • rm32/src/sine.rs
  • rm32/src/telemetry.rs
  • rm32/src/transfer.rs
  • rm32_stm32/src/bin/main.rs

@kaidokert kaidokert merged commit a84070f into main May 1, 2026
23 checks passed
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors several structs to restrict field visibility and removes unused fields and logic related to tone playback in the transfer state. However, the removal of the logic handling ServoResult::CalibrationDone appears to be a critical regression that discards calibration data. Additionally, the change to pub(crate) visibility for BrushedOutput fields prevents cross-crate access, and the PhasePositions struct now has an inconsistent Default implementation compared to its new new() constructor. Finally, the KissTelemPacket struct should be removed entirely if it is confirmed to be dead code.

Comment thread rm32/src/transfer.rs
Comment on lines 138 to 140
ServoResult::CalibrationDone { .. } => {
actions.save_settings = true;
actions.play_tone = 2; // changed tone
actions.signal_timeout_reset = true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The removal of the logic to handle ServoResult::CalibrationDone is a regression. While the save_settings field in TransferActions might have been unused by the consumer, the CalibrationDone variant carries the actual calibration results (low_threshold_eeprom, high_threshold_eeprom). By matching with .. and removing the code that triggers a save, the calibration values are lost and will never be persisted to EEPROM. If the intent is to move this logic, it should be done in a way that doesn't discard the data.

Comment thread rm32/src/brushed.rs
Comment on lines +34 to +35
pub(crate) duty: u16,
pub(crate) direction: BrushedDirection,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Changing these fields to pub(crate) makes them inaccessible to other crates. Since brushed_tick is a public function that returns BrushedOutput, callers in other crates (like rm32_stm32) will no longer be able to access the duty and direction results. Per the general rules, visibility must be pub for cross-crate access. If the goal is encapsulation, consider providing public getters instead of pub(crate) fields.

References
  1. A function's visibility must be pub if it is called from another crate; pub(crate) is insufficient for cross-crate access.
  2. Do not restrict the visibility of fields to pub(crate) if they are accessed across crate boundaries, as this would prevent proper initialization.
  3. Visibility of fields should be restricted to pub(crate) if they are only accessed internally within the module, even if they contain critical status information. External access should be provided via accessor methods if needed later.

Comment thread rm32/src/sine.rs
@@ -204,12 +204,21 @@ pub fn gimbal_step(
/// Phase positions for sinusoidal drive (0-359 degrees).
#[derive(Clone, Default)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is an inconsistency between the derived Default implementation and the new new() constructor. Default::default() will initialize phase positions to (0, 0, 0), whereas new() initializes them to the standard three-phase set (0, 120, 240). It is safer to implement Default manually to ensure the struct is always initialized to a valid state for its intended use case.

impl Default for PhasePositions {
    fn default() -> Self {
        Self::new()
    }
}

Comment thread rm32/src/telemetry.rs
Comment on lines +7 to +8
#[allow(dead_code)]
struct KissTelemPacket {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The KissTelemPacket struct appears to be dead code as it is not used within the crate and is now private with #[allow(dead_code)]. If it is no longer needed for documentation or future wire-format casting, it should be removed entirely rather than just hidden.

References
  1. Fields that are strictly internal to a specific logic (e.g., telemetry packet assembly) and not part of the public API should use pub(crate) visibility for encapsulation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant