code: async def get_attached_pipettes(request):
      
      hw = hw_from_req(request)
      if request.url.query.get('refresh') == 'true':
          if ff.use_protocol_api_v2():
              await hw.cache_instruments()
          else:
              hw.cache_instrument_models()
      response = {}
      for mount, data in hw.get_attached_pipettes().items():
          response[mount] = {
              'model': data['model'],
              'mount_axis': str(data['mount_axis']).lower(),
              'plunger_axis': str(data['plunger_axis']).lower(),
              'id': data['id']
          }
          if 'tip_length' in data:
              response[mount]['tip_length'] = data.get('tip_length', 0)
      return web.json_response(response, status=200)
docs: Query robot for model strings on 'left' and 'right' mounts, and return a
      dict with the results keyed by mount. By default, this endpoint provides
      cached values, which will not interrupt a running session. WARNING: if the
      caller supplies the "refresh=true" query parameter, this method will
      interrupt a sequence of Smoothie operations that are in progress, such as a
      protocol run.
  
      Example:
  
      ```
      {
        'left': {
          'model': 'p300_single_v1',
          'tip_length': 51.7,
          'mount_axis': 'z',
          'plunger_axis': 'b',
          'id': '<pipette id string>'
        },
        'right': {
          'model': 'p10_multi_v1',
          'tip_length': 40,
          'mount_axis': 'a',
          'plunger_axis': 'c',
          'id': '<pipette id string>'
        }
      }
      ```
  
      If a pipette is "uncommissioned" (e.g.: does not have a model string
      written to on-board memory), or if no pipette is present, the corresponding
      mount will report `'model': null`