diff --git a/notecard/card.py b/notecard/card.py index 4edf1af..ccfa14b 100644 --- a/notecard/card.py +++ b/notecard/card.py @@ -9,7 +9,6 @@ # This module contains helper methods for calling card.* Notecard API commands. # This module is optional and not required for use with the Notecard. -import notecard from notecard.validators import validate_card_object @@ -106,7 +105,8 @@ def version(card): @validate_card_object -def voltage(card, hours=None, offset=None, vmax=None, vmin=None): +def voltage(card, hours=None, offset=None, vmax=None, vmin=None, + usb=None, alert=None): """Retrieve current and historical voltage info from the Notecard. Args: @@ -115,9 +115,17 @@ def voltage(card, hours=None, offset=None, vmax=None, vmin=None): offset (int): Number of hours to offset. vmax (decimal): max voltage level to report. vmin (decimal): min voltage level to report. + usb (bool): Enable USB power state monitoring. + alert (bool): Enable alerts for USB power state changes. Only works + when usb=True. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing voltage and power + state information. + + Note: + For Mojo-based power consumption monitoring with temperature and + milliamp-hour tracking, see card.power(). """ req = {"req": "card.voltage"} if hours: @@ -128,6 +136,30 @@ def voltage(card, hours=None, offset=None, vmax=None, vmin=None): req["vmax"] = vmax if vmin: req["vmin"] = vmin + if usb is not None: + req["usb"] = usb + if alert is not None: + req["alert"] = alert + return card.Transaction(req) + + +@validate_card_object +def power(card, minutes=None, reset=None): + """Configure and query the Mojo-based power consumption monitoring. + + Args: + card (Notecard): The current Notecard object. + minutes (int, optional): How often to log power consumption. + reset (bool, optional): Reset consumption counters if True. + + Returns: + dict: Contains temperature, voltage, and milliamp_hours readings. + """ + req = {"req": "card.power"} + if minutes is not None: + req["minutes"] = minutes + if reset: + req["reset"] = True return card.Transaction(req) diff --git a/notecard/env.py b/notecard/env.py index 58de6b8..5494e73 100644 --- a/notecard/env.py +++ b/notecard/env.py @@ -34,19 +34,29 @@ def default(card, name=None, text=None): @validate_card_object -def get(card, name=None): +def get(card, name=None, names=None, time=None): """Perform an env.get request against a Notecard. Args: card (Notecard): The current Notecard object. - name (string): The name of an environment variable to get. + name (str, optional): The name of an environment variable to get. + names (list, optional): List of environment variable names to retrieve. + time (int, optional): UNIX epoch time to get variables modified after. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing either: + - text: Value of the requested variable if name was specified + - body: Object with name/value pairs if names was specified or + if neither name nor names was specified + - time: UNIX epoch time of the last variable change """ req = {"req": "env.get"} if name: req["name"] = name + if names: + req["names"] = names + if time is not None: + req["time"] = time return card.Transaction(req) @@ -82,3 +92,26 @@ def set(card, name=None, text=None): if text: req["text"] = text return card.Transaction(req) + + +@validate_card_object +def template(card, body=None): + """Perform an env.template request against a Notecard. + + Args: + card (Notecard): The current Notecard object. + body (dict, optional): Schema with variable names and type hints. + Supported type hints: + - Boolean: true + - String: numeric string for max length (pre v3.2.1) + - Integer: 11-14, 18 (signed), 21-24 (unsigned) + - Float: 12.1 (2-byte), 14.1 (4-byte), 18.1 (8-byte) + + Returns: + dict: The result of the Notecard request, including 'bytes' field + indicating storage size. + """ + req = {"req": "env.template"} + if body is not None: + req["body"] = body + return card.Transaction(req) diff --git a/notecard/file.py b/notecard/file.py index 91cb5a9..ee34103 100644 --- a/notecard/file.py +++ b/notecard/file.py @@ -9,7 +9,6 @@ # This module contains helper methods for calling file.* Notecard API commands. # This module is optional and not required for use with the Notecard. -import notecard from notecard.validators import validate_card_object @@ -28,9 +27,21 @@ def changes(card, tracker=None, files=None): req = {"req": "file.changes"} if tracker: req["tracker"] = tracker - if files: + if files is not None: # Allow empty list req["files"] = files - return card.Transaction(req) + + response = card.Transaction(req) + if "err" in response: + return response + + # Only validate types when fields are present + if "total" in response and not isinstance(response["total"], int): + return {"err": "Malformed response: total must be an integer"} + if "changes" in response and not isinstance(response["changes"], int): + return {"err": "Malformed response: changes must be an integer"} + if "info" in response and not isinstance(response["info"], dict): + return {"err": "Malformed response: info must be a dictionary"} + return response @validate_card_object @@ -51,18 +62,34 @@ def delete(card, files=None): @validate_card_object -def stats(card): +def stats(card, file=None): """Obtain statistics about local notefiles. Args: card (Notecard): The current Notecard object. + file (str, optional): Returns stats for the specified Notefile only. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing: + - total (int): Total number of Notes across all Notefiles + - changes (int): Number of Notes pending sync + - sync (bool): True if sync is recommended based on pending notes """ req = {"req": "file.stats"} - - return card.Transaction(req) + if file: + req["file"] = file + response = card.Transaction(req) + if "err" in response: + return response + + # Only validate types when fields are present + if "total" in response and not isinstance(response["total"], int): + return {"err": "Malformed response: total must be an integer"} + if "changes" in response and not isinstance(response["changes"], int): + return {"err": "Malformed response: changes must be an integer"} + if "sync" in response and not isinstance(response["sync"], bool): + return {"err": "Malformed response: sync must be a boolean"} + return response @validate_card_object @@ -73,8 +100,16 @@ def pendingChanges(card): card (Notecard): The current Notecard object. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request. """ req = {"req": "file.changes.pending"} - - return card.Transaction(req) + response = card.Transaction(req) + if "err" in response: + return response + + # Only validate types when fields are present + if "total" in response and not isinstance(response["total"], int): + return {"err": "Malformed response: total must be an integer"} + if "changes" in response and not isinstance(response["changes"], int): + return {"err": "Malformed response: changes must be an integer"} + return response diff --git a/notecard/note.py b/notecard/note.py index 2716687..0561904 100644 --- a/notecard/note.py +++ b/notecard/note.py @@ -44,8 +44,16 @@ def add(card, file=None, body=None, payload=None, sync=None, port=None): @validate_card_object -def changes(card, file=None, tracker=None, maximum=None, - start=None, stop=None, deleted=None, delete=None): +def changes( + card, + file=None, + tracker=None, + maximum=None, + start=None, + stop=None, + deleted=None, + delete=None, +): """Incrementally retrieve changes within a Notefile. Args: @@ -178,10 +186,16 @@ def template(card, file=None, body=None, length=None, port=None, compact=False): req["file"] = file if body: req["body"] = body - if length: + if length is not None: req["length"] = length if port: req["port"] = port + + format = None if compact: + format = "compact" + + if format == "compact": req["format"] = "compact" + return card.Transaction(req) diff --git a/notecard/validators.py b/notecard/validators.py index 86ee922..323fdc9 100644 --- a/notecard/validators.py +++ b/notecard/validators.py @@ -12,6 +12,7 @@ def validate_card_object(func): """Ensure that the passed-in card is a Notecard.""" + @functools.wraps(func) def wrap_validator(*args, **kwargs): """Check the instance of the passed-in card.""" @@ -22,11 +23,13 @@ def wrap_validator(*args, **kwargs): return func(*args, **kwargs) return wrap_validator + else: # MicroPython and CircuitPython do not support # functools. Do not perform validation for these platforms def validate_card_object(func): """Skip validation.""" + def wrap_validator(*args, **kwargs): """Check the instance of the passed-in card.""" card = args[0] diff --git a/test/fluent_api/conftest.py b/test/fluent_api/conftest.py index ced1a94..ba6dbdf 100644 --- a/test/fluent_api/conftest.py +++ b/test/fluent_api/conftest.py @@ -9,6 +9,14 @@ import notecard # noqa: E402 +@pytest.fixture +def card(): + """Create a mock Notecard instance for testing.""" + card = notecard.Notecard() + card.Transaction = MagicMock() + return card + + @pytest.fixture def run_fluent_api_notecard_api_mapping_test(): def _run_test(fluent_api, notecard_api_name, req_params, rename_map=None): diff --git a/test/fluent_api/test_card.py b/test/fluent_api/test_card.py index beb246d..2178096 100644 --- a/test/fluent_api/test_card.py +++ b/test/fluent_api/test_card.py @@ -46,6 +46,28 @@ 'vmin': 1.2 } ), + ( + card.voltage, + 'card.voltage', + { + 'usb': True + } + ), + ( + card.voltage, + 'card.voltage', + { + 'alert': True + } + ), + ( + card.voltage, + 'card.voltage', + { + 'usb': True, + 'alert': True + } + ), ( card.wireless, 'card.wireless', diff --git a/test/fluent_api/test_card_power.py b/test/fluent_api/test_card_power.py new file mode 100644 index 0000000..f897449 --- /dev/null +++ b/test/fluent_api/test_card_power.py @@ -0,0 +1,99 @@ +"""Test power management features in card module.""" +from notecard import card + + +def test_card_power_no_params(run_fluent_api_notecard_api_mapping_test): + """Test power() with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {} + ) + + +def test_card_power_minutes(run_fluent_api_notecard_api_mapping_test): + """Test power() with minutes parameter.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {'minutes': 120} + ) + + +def test_card_power_reset(run_fluent_api_notecard_api_mapping_test): + """Test power() with reset parameter.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {'reset': True} + ) + + +def test_card_power_all_params(run_fluent_api_notecard_api_mapping_test): + """Test power() with all parameters.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {'minutes': 60, 'reset': True} + ) + + +def test_card_power_minutes_type(run_fluent_api_notecard_api_mapping_test): + """Test that minutes parameter is properly handled as integer.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {'minutes': 30} + ) + + +def test_card_power_reset_type(run_fluent_api_notecard_api_mapping_test): + """Test that reset parameter is properly handled as boolean.""" + run_fluent_api_notecard_api_mapping_test( + card.power, + 'card.power', + {'reset': True} + ) + + +def test_voltage_usb_monitoring(run_fluent_api_notecard_api_mapping_test): + """Test USB power state monitoring.""" + run_fluent_api_notecard_api_mapping_test( + card.voltage, + 'card.voltage', + {'usb': True} + ) + + +def test_voltage_alert_handling(run_fluent_api_notecard_api_mapping_test): + """Test alert parameter handling.""" + run_fluent_api_notecard_api_mapping_test( + card.voltage, + 'card.voltage', + {'alert': True} + ) + + +def test_voltage_usb_with_alert(run_fluent_api_notecard_api_mapping_test): + """Test combined USB monitoring and alert functionality.""" + run_fluent_api_notecard_api_mapping_test( + card.voltage, + 'card.voltage', + {'usb': True, 'alert': True} + ) + + +def test_voltage_with_all_parameters(run_fluent_api_notecard_api_mapping_test): + """Test voltage with all available parameters.""" + run_fluent_api_notecard_api_mapping_test( + card.voltage, + 'card.voltage', + { + 'hours': 24, + 'offset': 1, + 'vmax': 5.0, + 'vmin': 3.3, + 'usb': True, + 'alert': True + } + ) diff --git a/test/fluent_api/test_env.py b/test/fluent_api/test_env.py index 963af6b..c0d9fdb 100644 --- a/test/fluent_api/test_env.py +++ b/test/fluent_api/test_env.py @@ -15,6 +15,16 @@ 'env.get', {'name': 'my_var'} ), + ( + env.get, + 'env.get', + {'names': ['var1', 'var2']} + ), + ( + env.get, + 'env.get', + {'time': 1609459200} + ), ( env.modified, 'env.modified', diff --git a/test/fluent_api/test_env_template.py b/test/fluent_api/test_env_template.py new file mode 100644 index 0000000..1d5f1fe --- /dev/null +++ b/test/fluent_api/test_env_template.py @@ -0,0 +1,99 @@ +"""Tests for env.template functionality.""" +from notecard import env + + +def test_env_template_basic(run_fluent_api_notecard_api_mapping_test): + """Test env.template with no body parameter.""" + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {}) + + +def test_env_template_with_boolean(run_fluent_api_notecard_api_mapping_test): + """Test env.template with boolean type hint.""" + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': {'my_bool': True}}) + + +def test_env_template_with_string_pre_321( + run_fluent_api_notecard_api_mapping_test): + """Test string type hint in env.template. + + For pre v3.2.1 format.""" + body = {'my_string': '42'} + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_with_string_post_321( + run_fluent_api_notecard_api_mapping_test): + """Test string type hint in env.template. + + For post v3.2.1 format.""" + body = {'my_string': 'variable'} + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_with_signed_integers( + run_fluent_api_notecard_api_mapping_test): + """Test signed integer hints. + + Covers all supported sizes.""" + body = { + 'int8': 11, # 1 byte signed + 'int16': 12, # 2 byte signed + 'int24': 13, # 3 byte signed + 'int32': 14, # 4 byte signed + 'int64': 18 # 8 byte signed + } + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_with_unsigned_integers( + run_fluent_api_notecard_api_mapping_test): + """Test unsigned integer hints. + + Covers all supported sizes.""" + body = { + 'uint8': 21, # 1 byte unsigned + 'uint16': 22, # 2 byte unsigned + 'uint24': 23, # 3 byte unsigned + 'uint32': 24 # 4 byte unsigned + } + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_with_floats(run_fluent_api_notecard_api_mapping_test): + """Test env.template with float type hints.""" + body = { + 'float16': 12.1, # 2 byte float + 'float32': 14.1, # 4 byte float + 'float64': 18.1 # 8 byte float + } + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_with_mixed_types( + run_fluent_api_notecard_api_mapping_test): + """Test mixed type hints. + + Tests bool, str, float, int.""" + body = { + 'active': True, + 'name': '32', + 'temperature': 14.1, + 'counter': 12 + } + run_fluent_api_notecard_api_mapping_test( + env.template, 'env.template', {'body': body}) + + +def test_env_template_response(card): + """Test env.template response contains bytes field.""" + card.Transaction.return_value = {'bytes': 42} + response = env.template(card) + assert 'bytes' in response + assert isinstance(response['bytes'], int) diff --git a/test/fluent_api/test_file_changes.py b/test/fluent_api/test_file_changes.py new file mode 100644 index 0000000..a874336 --- /dev/null +++ b/test/fluent_api/test_file_changes.py @@ -0,0 +1,70 @@ +"""Tests for file.changes functionality.""" +from notecard import file + + +def test_file_changes_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {}) + + +def test_file_changes_with_tracker(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with tracker parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {'tracker': 'my_tracker'}) + + +def test_file_changes_with_files(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with files parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {'files': ['file1.qo', 'file2.qo']}) + + +def test_file_changes_with_all_params( + run_fluent_api_notecard_api_mapping_test): + """Test file.changes with all parameters.""" + params = {'tracker': 'my_tracker', 'files': ['file1.qo', 'file2.qo']} + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', params) + + +def test_file_changes_response(card): + """Test file.changes response structure.""" + card.Transaction.return_value = { + 'changes': 5, + 'total': 42, + 'info': { + 'file1.qo': {'changes': 2, 'total': 20}, + 'file2.qo': {'changes': 3, 'total': 22} + } + } + response = file.changes(card) + # Validate types only when fields are present + if 'changes' in response: + assert isinstance(response['changes'], int) + if 'total' in response: + assert isinstance(response['total'], int) + if 'info' in response: + assert isinstance(response['info'], dict) + for filename, file_info in response['info'].items(): + assert isinstance(file_info, dict) + if 'changes' in file_info: + assert isinstance(file_info['changes'], int) + if 'total' in file_info: + assert isinstance(file_info['total'], int) + + +def test_file_changes_with_invalid_tracker(card): + """Test file.changes with invalid tracker format.""" + card.Transaction.return_value = {"err": "Invalid tracker format"} + response = file.changes(card, tracker="@@@!!!") + assert "err" in response + assert "Invalid tracker format" in response["err"] + + +def test_file_changes_with_nonexistent_files(card): + """Test file.changes with non-existent files.""" + card.Transaction.return_value = {"err": "File not found"} + response = file.changes(card, files=["nonexistent.qo"]) + assert "err" in response + assert "File not found" in response["err"] diff --git a/test/fluent_api/test_file_changes_pending.py b/test/fluent_api/test_file_changes_pending.py new file mode 100644 index 0000000..7f59232 --- /dev/null +++ b/test/fluent_api/test_file_changes_pending.py @@ -0,0 +1,30 @@ +"""Tests for file.changes.pending functionality.""" +from notecard import file + + +def test_file_changes_pending_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.changes.pending with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.pendingChanges, 'file.changes.pending', {}) + + +def test_file_changes_pending_response(card): + """Test file.changes.pending response structure.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5 + } + response = file.pendingChanges(card) + # Validate types only when fields are present + if 'total' in response: + assert isinstance(response['total'], int) + if 'changes' in response: + assert isinstance(response['changes'], int) + + +def test_file_changes_pending_with_error(card): + """Test file.changes.pending error handling.""" + card.Transaction.return_value = {"err": "Internal error"} + response = file.pendingChanges(card) + assert "err" in response + assert "Internal error" in response["err"] diff --git a/test/fluent_api/test_file_stats.py b/test/fluent_api/test_file_stats.py new file mode 100644 index 0000000..fa84ff1 --- /dev/null +++ b/test/fluent_api/test_file_stats.py @@ -0,0 +1,39 @@ +"""Tests for file.stats functionality.""" +from notecard import file + + +def test_file_stats_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.stats with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.stats, 'file.stats', {}) + + +def test_file_stats_with_file(run_fluent_api_notecard_api_mapping_test): + """Test file.stats with file parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.stats, 'file.stats', {'file': 'test.qo'}) + + +def test_file_stats_response(card): + """Test file.stats response structure.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5, + 'sync': True + } + response = file.stats(card) + # Validate types only when fields are present + if 'total' in response: + assert isinstance(response['total'], int) + if 'changes' in response: + assert isinstance(response['changes'], int) + if 'sync' in response: + assert isinstance(response['sync'], bool) + + +def test_file_stats_with_error(card): + """Test file.stats error handling.""" + card.Transaction.return_value = {"err": "File not found"} + response = file.stats(card, file="nonexistent.qo") + assert "err" in response + assert "File not found" in response["err"] diff --git a/test/fluent_api/test_note_template_arrays.py b/test/fluent_api/test_note_template_arrays.py new file mode 100644 index 0000000..e038899 --- /dev/null +++ b/test/fluent_api/test_note_template_arrays.py @@ -0,0 +1,41 @@ +"""Tests for note.template with array support.""" +from notecard import note + + +def test_template_with_array_body(run_fluent_api_notecard_api_mapping_test): + """Test note.template with an array body.""" + run_fluent_api_notecard_api_mapping_test( + note.template, 'note.template', + {'body': ['item1', 'item2', 'item3']}) + + +def test_template_with_array_in_body(run_fluent_api_notecard_api_mapping_test): + """Test note.template with an object containing an array.""" + run_fluent_api_notecard_api_mapping_test( + note.template, 'note.template', + {'body': {'list_field': ['item1', 'item2']}}) + + +def test_template_with_nested_arrays(run_fluent_api_notecard_api_mapping_test): + """Test note.template with nested arrays in body.""" + run_fluent_api_notecard_api_mapping_test( + note.template, 'note.template', + {'body': {'matrix': [[1, 2], [3, 4]]}}) + + +def test_template_with_mixed_types(run_fluent_api_notecard_api_mapping_test): + """Test note.template with mixed types in arrays.""" + run_fluent_api_notecard_api_mapping_test( + note.template, 'note.template', + {'body': {'mixed': [1, "text", True, 3.14]}}) + + +def test_template_response_with_array(card): + """Test note.template response handling with array data.""" + card.Transaction.return_value = { + 'body': ['response_item1', 'response_item2'] + } + response = note.template(card, body=['test1', 'test2']) + assert isinstance(response, dict) + if 'body' in response: + assert isinstance(response['body'], list)