1616from pydantic import Field
1717
1818from ..config import get_global_settings
19+ from ..utils .python_sandbox import PythonSandboxError , get_security_documentation , safe_execute
1920from .helpers import log_tool_usage
2021from .util_helpers import parse_json_param
2122
2223logger = logging .getLogger (__name__ )
2324
2425# Try to import jq - it's not available on Windows ARM64
2526try :
26- import jq
27+ import jq # noqa: F401 - Used to check availability, re-imported in function
2728 JQ_AVAILABLE = True
2829except ImportError :
2930 JQ_AVAILABLE = False
@@ -427,14 +428,29 @@ async def ha_config_set_dashboard(
427428 str | None ,
428429 Field (
429430 description = "jq expression to transform existing dashboard config. "
430- "Mutually exclusive with config. Requires config_hash for validation. "
431+ "Mutually exclusive with config and python_transform . Requires config_hash for validation. "
431432 "Examples: '.views[0].sections[1].cards[0].icon = \" mdi:thermometer\" ', "
432433 "'.views[0].cards += [{\" type\" : \" button\" , \" entity\" : \" light.bedroom\" }]', "
433434 "'del(.views[0].sections[0].cards[2])'. "
434435 "MULTI-OP: Chain with '|': 'del(.views[0].cards[2]) | .views[0].cards[0].icon = \" mdi:new\" '. "
435436 "Use ha_dashboard_find_card() to get jq_path for targeted edits."
436437 ),
437438 ] = None ,
439+ python_transform : Annotated [
440+ str | None ,
441+ Field (
442+ description = "Python expression to transform existing dashboard config. "
443+ "Mutually exclusive with config and jq_transform. "
444+ "Requires config_hash for validation. "
445+ "See PYTHON TRANSFORM SECURITY below for allowed operations. "
446+ "Examples: "
447+ "Simple: python_transform=\" config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'\" "
448+ "Pattern: python_transform=\" for card in config['views'][0]['cards']: if 'light' in card.get('entity', ''): card['icon'] = 'mdi:lightbulb'\" "
449+ "Multi-op: python_transform=\" config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'; del config['views'][0]['cards'][2]\" "
450+ "\n \n "
451+ + get_security_documentation (),
452+ ),
453+ ] = None ,
438454 config_hash : Annotated [
439455 str | None ,
440456 Field (
@@ -465,12 +481,13 @@ async def ha_config_set_dashboard(
465481 Create or update a Home Assistant dashboard.
466482
467483 Creates a new dashboard or updates an existing one with the provided configuration.
468- Supports two modes: full config replacement OR jq-based transformation.
484+ Supports three modes: full config replacement, Python transformation, OR jq-based transformation.
469485
470486 IMPORTANT: url_path must contain a hyphen (-) to be valid.
471487
472488 WHEN TO USE WHICH MODE:
473- - jq_transform: Preferred for edits. Surgical changes, fewer tokens.
489+ - python_transform: RECOMMENDED for edits. Surgical/pattern-based updates, works on all platforms.
490+ - jq_transform: Legacy mode. Requires jq binary (not available on Windows ARM64).
474491 - config: New dashboards only, or full restructure. Replaces everything.
475492
476493 JQ TRANSFORM EXAMPLES:
@@ -489,6 +506,13 @@ async def ha_config_set_dashboard(
489506
490507 TIP: Use ha_dashboard_find_card() to get the jq_path for any card.
491508
509+ PYTHON TRANSFORM EXAMPLES (RECOMMENDED):
510+ - Update card icon: 'config["views"][0]["cards"][0]["icon"] = "mdi:thermometer"'
511+ - Add card: 'config["views"][0]["cards"].append({"type": "button", "entity": "light.bedroom"})'
512+ - Delete card: 'del config["views"][0]["cards"][2]'
513+ - Pattern-based update: 'for card in config["views"][0]["cards"]: if "light" in card.get("entity", ""): card["icon"] = "mdi:lightbulb"'
514+ - Multi-operation: 'config["views"][0]["cards"][0]["icon"] = "mdi:a"; config["views"][0]["cards"][1]["icon"] = "mdi:b"'
515+
492516 MODERN DASHBOARD BEST PRACTICES (2024+):
493517 - Use "sections" view type (default) with grid-based layouts
494518 - Use "tile" cards as primary card type (replaces legacy entity/light/climate cards)
@@ -589,18 +613,148 @@ async def ha_config_set_dashboard(
589613 ],
590614 }
591615
592- # Validate mutual exclusivity of config and jq_transform
593- if config is not None and jq_transform is not None :
616+ # Validate mutual exclusivity of config, jq_transform, and python_transform
617+ transforms_provided = sum (
618+ [
619+ config is not None ,
620+ jq_transform is not None ,
621+ python_transform is not None ,
622+ ]
623+ )
624+
625+ if transforms_provided > 1 :
594626 return {
595627 "success" : False ,
596628 "action" : "set" ,
597- "error" : "Cannot use both 'config' and 'jq_transform' parameters " ,
629+ "error" : "Cannot use multiple transform methods simultaneously " ,
598630 "suggestions" : [
599- "Use 'config' for full replacement" ,
600- "Use 'jq_transform' for targeted changes to existing dashboard" ,
631+ "Use only ONE of: config, jq_transform, or python_transform" ,
632+ "config: Full replacement" ,
633+ "jq_transform: jq-based edits (requires jq installation)" ,
634+ "python_transform: Python-based edits (recommended, works everywhere)" ,
601635 ],
602636 }
603637
638+ # Handle python_transform mode
639+ if python_transform is not None :
640+ # config_hash is REQUIRED
641+ if config_hash is None :
642+ return {
643+ "success" : False ,
644+ "action" : "python_transform" ,
645+ "url_path" : url_path ,
646+ "error" : "config_hash is required for python_transform" ,
647+ "suggestions" : [
648+ "Call ha_config_get_dashboard() first" ,
649+ "Use the config_hash from that response" ,
650+ ],
651+ }
652+
653+ # Fetch current dashboard config
654+ get_data : dict [str , Any ] = {"type" : "lovelace/config" , "force" : True }
655+ if url_path :
656+ get_data ["url_path" ] = url_path
657+
658+ response = await client .send_websocket_message (get_data )
659+
660+ if isinstance (response , dict ) and not response .get ("success" , True ):
661+ error_msg = response .get ("error" , {})
662+ if isinstance (error_msg , dict ):
663+ error_msg = error_msg .get ("message" , str (error_msg ))
664+ return {
665+ "success" : False ,
666+ "action" : "python_transform" ,
667+ "url_path" : url_path ,
668+ "error" : f"Dashboard not found or inaccessible: { error_msg } " ,
669+ "suggestions" : [
670+ "python_transform requires an existing dashboard" ,
671+ "Use 'config' parameter to create a new dashboard" ,
672+ "Verify dashboard exists with ha_config_get_dashboard(list_only=True)" ,
673+ ],
674+ }
675+
676+ current_config = (
677+ response .get ("result" ) if isinstance (response , dict ) else response
678+ )
679+ if not isinstance (current_config , dict ):
680+ return {
681+ "success" : False ,
682+ "action" : "python_transform" ,
683+ "url_path" : url_path ,
684+ "error" : "Current dashboard config is invalid" ,
685+ "suggestions" : [
686+ "Initialize dashboard with 'config' parameter first"
687+ ],
688+ }
689+
690+ # Validate config_hash for optimistic locking
691+ current_hash = _compute_config_hash (current_config )
692+ if current_hash != config_hash :
693+ return {
694+ "success" : False ,
695+ "action" : "python_transform" ,
696+ "url_path" : url_path ,
697+ "error" : "Dashboard modified since last read (conflict)" ,
698+ "suggestions" : [
699+ "Call ha_config_get_dashboard() again" ,
700+ "Use the fresh config_hash from that response" ,
701+ ],
702+ }
703+
704+ # Apply Python transformation with validation
705+ try :
706+ transformed_config = safe_execute (python_transform , current_config )
707+ except PythonSandboxError as e :
708+ return {
709+ "success" : False ,
710+ "action" : "python_transform" ,
711+ "url_path" : url_path ,
712+ "error" : str (e ),
713+ "suggestions" : [
714+ "Check expression syntax" ,
715+ "Ensure only allowed operations are used" ,
716+ "See tool description for allowed operations" ,
717+ f"Expression: { python_transform [:100 ]} ..." ,
718+ ],
719+ }
720+
721+ # Save transformed config
722+ save_data : dict [str , Any ] = {
723+ "type" : "lovelace/config/save" ,
724+ "config" : transformed_config ,
725+ }
726+ if url_path :
727+ save_data ["url_path" ] = url_path
728+
729+ save_result = await client .send_websocket_message (save_data )
730+
731+ if isinstance (save_result , dict ) and not save_result .get ("success" , True ):
732+ error_msg = save_result .get ("error" , {})
733+ if isinstance (error_msg , dict ):
734+ error_msg = error_msg .get ("message" , str (error_msg ))
735+ return {
736+ "success" : False ,
737+ "action" : "python_transform" ,
738+ "url_path" : url_path ,
739+ "error" : f"Failed to save transformed config: { error_msg } " ,
740+ "suggestions" : [
741+ "Expression may have produced invalid dashboard structure" ,
742+ "Verify config format is valid Lovelace JSON" ,
743+ ],
744+ }
745+
746+ # Compute new hash for potential chaining
747+ new_config_hash = _compute_config_hash (transformed_config )
748+
749+ return {
750+ "success" : True ,
751+ "action" : "python_transform" ,
752+ "url_path" : url_path ,
753+ "config_hash" : new_config_hash ,
754+ "python_expression" : python_transform ,
755+ "message" : f"Dashboard { url_path } updated via Python transform" ,
756+ }
757+
604758 # Handle jq_transform mode
605759 if jq_transform is not None :
606760 # config_hash is REQUIRED for jq_transform
0 commit comments